diff --git a/config/scripts/branch-compare-head-benchmark.mjs b/config/scripts/branch-compare-head-benchmark.mjs new file mode 100644 index 00000000000..6adb1c277e5 --- /dev/null +++ b/config/scripts/branch-compare-head-benchmark.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// Benchmark: the head-of-chain reads in getBranchCompare (src/main/git/status.ts). +// +// Four spawns ran strictly in series before any compare work started: branch +// --show-current, the base-ref probe, rev-parse HEAD, and rev-parse . compareRef is +// display-only metadata and HEAD's oid does not depend on the base ref, so the first three +// can overlap. The probe oid also replaces the fourth spawn when it proves refs/heads/*; +// remote-tracking refs require a raw rev-parse because they may store annotated tags. +// +// This spawns the real git binary against this repo, so it measures actual process-launch +// cost rather than a model of it. Over SSH these are host-local spawns inside the relay, +// so the saving applies to remote spawn time, not to network round trips. +// +// Both arms are compared for identical resolved values before timing. +// +// Run with: node config/scripts/branch-compare-head-benchmark.mjs +import { execFile } from 'node:child_process' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { readBranchCompareHead } from '../../src/shared/git-branch-compare-head.ts' + +const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)) +const ITERATIONS = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_ITERATIONS ?? '8') +const WARMUP = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_WARMUP ?? '2') +const ROUNDS = 6 + +for (const [name, value] of [ + ['ORCA_BRANCH_COMPARE_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_BRANCH_COMPARE_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +function git(args) { + return new Promise((resolve, reject) => { + execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) => + error ? reject(error) : resolve(stdout.trim()) + ) + }) +} + +async function probeOid(qualifiedRef) { + try { + const out = await git(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`]) + return out.length > 0 ? out : null + } catch { + return null + } +} + +// Pre-fix: serial chain, and the probe's oid discarded then re-resolved. +async function readSerial(baseRef) { + const compareRef = (await git(['branch', '--show-current']).catch(() => '')) || 'HEAD' + let resolvedBaseRef = baseRef + if (!baseRef.startsWith('refs/')) { + const candidates = baseRef.includes('/') + ? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`] + : [`refs/heads/${baseRef}`] + for (const candidate of candidates) { + if ((await probeOid(candidate)) !== null) { + resolvedBaseRef = candidate + break + } + } + } + const headOid = await git(['rev-parse', '--verify', '--end-of-options', 'HEAD']) + const baseOid = await git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef]) + return { compareRef, resolvedBaseRef, headOid, baseOid } +} + +// Production head reader: overlaps independent reads and reuses only safe probe oids. +async function readConcurrent(baseRef) { + const reusableProbedOidByRef = new Map() + const resolveBaseRef = async () => { + if (baseRef.startsWith('refs/')) { + return baseRef + } + const candidates = baseRef.includes('/') + ? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`] + : [`refs/heads/${baseRef}`] + for (const candidate of candidates) { + const oid = await probeOid(candidate) + if (oid !== null) { + if (candidate.startsWith('refs/heads/')) { + reusableProbedOidByRef.set(candidate, oid) + } + return candidate + } + } + return baseRef + } + const result = await readBranchCompareHead({ + readCompareRef: () => + git(['branch', '--show-current']) + .then((out) => out || 'HEAD') + .catch(() => 'HEAD'), + resolveBaseRef, + readHeadOid: () => git(['rev-parse', '--verify', '--end-of-options', 'HEAD']), + readBaseOid: (resolvedBaseRef) => { + const reusableOid = reusableProbedOidByRef.get(resolvedBaseRef) + return reusableOid === undefined + ? git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef]) + : Promise.resolve(reusableOid) + } + }) + if (!result.headOidResult.ok) { + throw result.headOidResult.error + } + if (!result.baseOidResult.ok) { + throw result.baseOidResult.error + } + return { + compareRef: result.compareRef, + resolvedBaseRef: result.resolvedBaseRef, + headOid: result.headOidResult.oid, + baseOid: result.baseOidResult.oid + } +} + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const mid = sorted.length / 2 + return (sorted[mid - 1] + sorted[mid]) / 2 +} + +async function timeArm(read, baseRef) { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + await read(baseRef) + } + return (performance.now() - start) / ITERATIONS +} + +// Arms alternate which one leads so within-round drift cannot favour either. +async function measure(baseRef) { + for (let index = 0; index < WARMUP; index += 1) { + await readSerial(baseRef) + await readConcurrent(baseRef) + } + const serialSamples = [] + const concurrentSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + if (round % 2 === 0) { + serialSamples.push(await timeArm(readSerial, baseRef)) + concurrentSamples.push(await timeArm(readConcurrent, baseRef)) + } else { + concurrentSamples.push(await timeArm(readConcurrent, baseRef)) + serialSamples.push(await timeArm(readSerial, baseRef)) + } + } + return { serialMs: median(serialSamples), concurrentMs: median(concurrentSamples) } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('getBranchCompare head-of-chain reads, per call. Lower is better.') +console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`) +console.log( + `${pad('base ref', 30)} ${pad('serial', 11)} ${pad('concurrent', 11)} ${pad('speedup', 9)}` +) + +// A short remote label is the common case (Orca's base picker emits `origin/main`); the +// already-qualified ref skips the probe entirely, so only the concurrency half applies. +const upstream = await git(['rev-parse', '--abbrev-ref', 'HEAD@{upstream}']).catch(() => null) +const baseRefs = ['origin/main', 'refs/remotes/origin/main', 'main'] +if (upstream && !baseRefs.includes(upstream)) { + baseRefs.push(upstream) +} + +for (const baseRef of baseRefs) { + const serial = await readSerial(baseRef) + const concurrent = await readConcurrent(baseRef) + if (JSON.stringify(serial) !== JSON.stringify(concurrent)) { + throw new Error( + `resolved values differ for ${baseRef}:\n serial ${JSON.stringify(serial)}\n concurrent ${JSON.stringify(concurrent)}` + ) + } + if (!serial.headOid) { + throw new Error(`fixture resolved no HEAD oid for ${baseRef}`) + } + const { serialMs, concurrentMs } = await measure(baseRef) + console.log( + `${pad(baseRef, 30)} ${pad(`${serialMs.toFixed(1)} ms`, 11)} ${pad(`${concurrentMs.toFixed(1)} ms`, 11)} ${pad(`${(serialMs / concurrentMs).toFixed(2)}x`, 9)}` + ) +} + +console.log( + '\nThe already-qualified refs/... row skips the probe by design, so it only shows the\nconcurrency half. This times the native/WSL head-of-chain reads, not the whole compare;\nthe relay path has separate production-concurrency coverage.' +) diff --git a/config/scripts/cli-runtime-client-deferral-benchmark.mjs b/config/scripts/cli-runtime-client-deferral-benchmark.mjs new file mode 100644 index 00000000000..4a7560fc7dd --- /dev/null +++ b/config/scripts/cli-runtime-client-deferral-benchmark.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node +// Benchmark: CLI process startup with the RuntimeClient module graph deferred. +// +// src/cli/index.ts used to value-import RuntimeClient at module scope, and five +// modules that load on every invocation (args, flags, dispatch, format, +// selectors) pulled RuntimeClientError from the ./runtime-client barrel. Either +// edge alone drags in the whole client graph: zod (via shared/pairing -> +// shared/mobile-relay-pairing-offer), ws + tweetnacl (via websocket-transport), +// plus the environment store and secure-file stack. +// +// The fix repoints those five at ./runtime/types (zero children) and loads the +// client through `await import()` after flag validation, so --help, `help +// ` and every command/flag error return without ever touching it. +// +// Both arms are REAL tsc emits of real source: the baseline arm restores the +// seven touched files from a git rev and compiles that. Each sample is a FRESH +// process (module-graph cost is a once-per-process cost; timing it in-process +// would measure a warm require cache). +// +// Arms alternate lead across an even number of rounds and report per-arm +// medians. Byte-for-byte output equality is checked BEFORE timing. +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const REPO = fileURLToPath(new URL('../..', import.meta.url)) + +const ROUNDS = Number(process.env.ORCA_CLI_DEFER_BENCH_ROUNDS ?? '30') +const WARMUP = Number(process.env.ORCA_CLI_DEFER_BENCH_WARMUP ?? '3') + +for (const [name, value] of [ + ['ORCA_CLI_DEFER_BENCH_ROUNDS', ROUNDS], + ['ORCA_CLI_DEFER_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} +if (ROUNDS % 2 !== 0) { + // Why: arms alternate which one leads; an odd count biases one arm. + throw new Error(`ORCA_CLI_DEFER_BENCH_ROUNDS must be even, received ${ROUNDS}`) +} + +const TOUCHED = [ + 'src/cli/args.ts', + 'src/cli/dispatch.ts', + 'src/cli/flags.ts', + 'src/cli/format.ts', + 'src/cli/index.ts', + 'src/cli/runtime/client.ts', + 'src/cli/selectors.ts' +] + +// Why: if the deferral is reverted or reshaped, both arms would compile to the +// same thing and this would quietly report 1.00x forever. Re-read the real call +// forms out of the source. Matching the CALL form (not a bare identifier) so a +// comment that merely names the function cannot satisfy the check. +function assertMarkersFresh() { + const checks = [ + ['src/cli/index.ts', "await import('./runtime-client.js')"], + ['src/cli/index.ts', 'await loadRuntimeClientClass()'], + ['src/cli/index.ts', "import type { RuntimeClient } from './runtime-client'"], + ['src/cli/runtime/client.ts', "await import('./websocket-transport.js')"], + ['src/cli/runtime/client.ts', 'await loadSendWebSocketRequest()'], + ['src/cli/args.ts', "import { RuntimeClientError } from './runtime/types'"], + ['src/cli/flags.ts', "import { RuntimeClientError } from './runtime/types'"], + ['src/cli/dispatch.ts', "import { RuntimeClientError } from './runtime/types'"], + ['src/cli/selectors.ts', "import { RuntimeClientError } from './runtime/types'"], + ['src/cli/format.ts', "} from './runtime/types'"] + ] + for (const [file, marker] of checks) { + if (!readFileSync(join(REPO, file), 'utf8').includes(marker)) { + throw new Error( + `${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-benchmark.mjs is stale` + ) + } + } +} + +function buildArm(label, baselineRev) { + // Why: a build under /tmp cannot resolve the repo's node_modules, so the + // output has to live inside the repo. + const outDir = join(REPO, `.bench-out-${label}`) + rmSync(outDir, { recursive: true, force: true }) + const restore = [] + try { + if (baselineRev) { + for (const file of TOUCHED) { + const path = join(REPO, file) + restore.push([path, readFileSync(path)]) + writeFileSync( + path, + execFileSync('git', ['show', `${baselineRev}:${file}`], { + cwd: REPO, + maxBuffer: 64 * 1024 * 1024 + }) + ) + } + } + execFileSync( + 'npx', + [ + 'tsc', + '-p', + 'config/tsconfig.cli.json', + '--outDir', + outDir, + '--composite', + 'false', + '--incremental', + 'false' + ], + { cwd: REPO, stdio: 'inherit' } + ) + } finally { + for (const [path, contents] of restore) { + writeFileSync(path, contents) + } + } + return join(outDir, 'cli/index.js') +} + +function run(entry, argv, env) { + const result = spawnSync(process.execPath, [entry, ...argv], { + cwd: REPO, + env: { ...process.env, ...env }, + encoding: 'buffer' + }) + if (result.error) { + throw result.error + } + return { + status: result.status, + stdout: result.stdout.toString('utf8'), + stderr: result.stderr.toString('utf8') + } +} + +// Counts the eager CommonJS module graph of a built entry point by hooking +// Module._load in a child process. This is the quantity the change moves. +function countEagerModules(entry) { + const probe = ` + const Module = require('module') + const original = Module._load + const seen = new Set() + Module._load = function (request, parent, isMain) { + try { seen.add(Module._resolveFilename(request, parent, isMain)) } catch { seen.add(request) } + return original.apply(this, arguments) + } + require(${JSON.stringify(entry)}) + const all = [...seen] + process.stdout.write(JSON.stringify({ + total: all.length, + nodeModules: all.filter((p) => p.includes('node_modules')).length + })) + ` + const result = spawnSync(process.execPath, ['-e', probe], { cwd: REPO, encoding: 'utf8' }) + if (result.status !== 0) { + throw new Error(`module probe failed: ${result.stderr}`) + } + return JSON.parse(result.stdout) +} + +const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.floor(sorted.length / 2)] +} + +const baselineIndex = process.argv.indexOf('--baseline') +const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1] + +assertMarkersFresh() + +const userDataPath = mkdtempSync(join(REPO, '.bench-userdata-')) + +try { + console.log(`Building eager baseline (${baselineRev}) …`) + const eagerEntry = buildArm('eager', baselineRev) + console.log('Building deferred (working tree) …') + const deferredEntry = buildArm('deferred', null) + + const eagerGraph = countEagerModules(eagerEntry) + const deferredGraph = countEagerModules(deferredEntry) + console.log( + `\nEager modules at process load: ${eagerGraph.total} -> ${deferredGraph.total} ` + + `(node_modules ${eagerGraph.nodeModules} -> ${deferredGraph.nodeModules})` + ) + if (deferredGraph.total >= eagerGraph.total) { + throw new Error( + 'deferred arm loads no fewer modules — the fixture does not exercise the change' + ) + } + + // Each case is (label, argv, env). The runtime-dependent ones point at an + // empty user-data dir so both arms get the same deterministic answer. + const isolated = { ORCA_USER_DATA_PATH: userDataPath } + const cases = [ + ['orca --help', ['--help'], {}], + ['orca help worktree', ['help', 'worktree'], {}], + ['orca (no args)', [], {}], + ['unknown command', ['no-such-command'], {}], + ['unknown flag', ['worktree', 'list', '--nope'], {}], + ['orca agent-context --json', ['agent-context', '--json'], {}], + ['orca status --json', ['status', '--json'], isolated], + ['orca worktree list --json', ['worktree', 'list', '--json'], isolated] + ] + + // Why: a semantically broken arm that prints nothing would look fastest. + // Compare bytes and exit codes BEFORE timing anything. + for (const [label, argv, env] of cases) { + const before = run(eagerEntry, argv, env) + const after = run(deferredEntry, argv, env) + if ( + before.status !== after.status || + before.stdout !== after.stdout || + before.stderr !== after.stderr + ) { + throw new Error(`arms disagree for "${label}" — refusing to report a timing`) + } + if (before.stdout.length + before.stderr.length === 0) { + throw new Error(`"${label}" produced no output on either arm; it proves nothing`) + } + } + + const pad = (value, width) => String(value).padStart(width) + console.log('\nFresh process per sample, wall clock. Lower is better.') + console.log(`rounds=${ROUNDS} warmup=${WARMUP} (per-arm median, arms alternate lead)`) + console.log(`${pad('case', 26)} ${pad('eager', 10)} ${pad('deferred', 10)} ${pad('speedup', 9)}`) + + // Accumulated so V8 cannot treat the spawn loop as dead code. + let consumed = 0 + + for (const [label, argv, env] of cases) { + for (let index = 0; index < WARMUP; index += 1) { + consumed += run(eagerEntry, argv, env).stdout.length + consumed += run(deferredEntry, argv, env).stdout.length + } + const samples = { eager: [], deferred: [] } + for (let round = 0; round < ROUNDS; round += 1) { + // Alternate which arm leads so a drifting machine load cannot be + // attributed to one arm. + const order = + round % 2 === 0 + ? [ + ['eager', eagerEntry], + ['deferred', deferredEntry] + ] + : [ + ['deferred', deferredEntry], + ['eager', eagerEntry] + ] + for (const [arm, entry] of order) { + const started = performance.now() + const result = run(entry, argv, env) + samples[arm].push(performance.now() - started) + consumed += result.stdout.length + } + } + const eagerMs = median(samples.eager) + const deferredMs = median(samples.deferred) + console.log( + `${pad(label, 26)} ${pad(`${eagerMs.toFixed(1)} ms`, 10)} ${pad(`${deferredMs.toFixed(1)} ms`, 10)} ${pad(`${(eagerMs / deferredMs).toFixed(2)}x`, 9)}` + ) + } + + if (consumed === 0) { + throw new Error('no output consumed — the timing loop was optimised away') + } + console.log( + '\nThe help and error rows are the ones the change targets: they return\n' + + 'before any client construction, so they drop the whole graph. `status` and\n' + + '`worktree list` still construct a client, so they only save the eager parse\n' + + 'of the parts the local path never uses (ws/tweetnacl via websocket-transport).' + ) +} finally { + rmSync(userDataPath, { recursive: true, force: true }) + for (const label of ['eager', 'deferred']) { + rmSync(join(REPO, `.bench-out-${label}`), { recursive: true, force: true }) + } +} diff --git a/config/scripts/cli-runtime-client-deferral-equivalence.mjs b/config/scripts/cli-runtime-client-deferral-equivalence.mjs new file mode 100644 index 00000000000..f443bf3b9f9 --- /dev/null +++ b/config/scripts/cli-runtime-client-deferral-equivalence.mjs @@ -0,0 +1,485 @@ +#!/usr/bin/env node +// Equivalence check for deferring the RuntimeClient module graph in the CLI. +// +// Builds the CLI twice with the REAL tsc emit — once from the working tree and +// once with the seven touched files restored from git HEAD~ (the pre-deferral +// implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE +// across a matrix of invocations. +// +// Why a script and not a vitest case: this compiles two full CLI trees. It is +// the artifact that proves the refactor is behaviour-preserving; the fast +// invariants (class identity, no eager barrel import) live in +// src/cli/runtime-client-deferral.test.ts and run in the normal suite. +// +// Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline ] +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO = fileURLToPath(new URL('../..', import.meta.url)) + +// The files this change touches. Restoring exactly these from the baseline rev +// reconstructs the old implementation without disturbing anything else. +const TOUCHED = [ + 'src/cli/args.ts', + 'src/cli/dispatch.ts', + 'src/cli/flags.ts', + 'src/cli/format.ts', + 'src/cli/index.ts', + 'src/cli/runtime/client.ts', + 'src/cli/selectors.ts' +] + +// Why: if the deferral is ever reverted or reshaped, the "old" arm would +// silently become identical to the new one and every case would pass +// vacuously. Re-read the real call form out of the source and fail loudly. +function assertMarkersFresh() { + const index = readFileSync(join(REPO, 'src/cli/index.ts'), 'utf8') + const client = readFileSync(join(REPO, 'src/cli/runtime/client.ts'), 'utf8') + const checks = [ + ['src/cli/index.ts', index, 'await loadRuntimeClientClass()'], + ['src/cli/index.ts', index, "await import('./runtime-client.js')"], + ['src/cli/index.ts', index, "import type { RuntimeClient } from './runtime-client'"], + ['src/cli/runtime/client.ts', client, 'await loadSendWebSocketRequest()'], + ['src/cli/runtime/client.ts', client, "await import('./websocket-transport.js')"] + ] + for (const [file, source, marker] of checks) { + // Match the call form, not a bare word: a comment naming the function must + // not satisfy the check. + if (!source.includes(marker)) { + throw new Error( + `${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-equivalence.mjs is stale` + ) + } + } + const repointed = ['src/cli/args.ts', 'src/cli/flags.ts', 'src/cli/dispatch.ts'] + for (const file of repointed) { + const source = readFileSync(join(REPO, file), 'utf8') + if (!source.includes("import { RuntimeClientError } from './runtime/types'")) { + throw new Error(`${file} no longer repoints RuntimeClientError at ./runtime/types — stale`) + } + } +} + +function buildTree(label, baselineRev) { + // Why: builds under /tmp cannot resolve the repo's node_modules, so the + // output dir has to live inside the repo. + const outDir = join(REPO, `.equiv-out-${label}`) + rmSync(outDir, { recursive: true, force: true }) + const restored = [] + try { + if (baselineRev) { + for (const file of TOUCHED) { + const path = join(REPO, file) + restored.push([path, readFileSync(path)]) + const old = execFileSync('git', ['show', `${baselineRev}:${file}`], { + cwd: REPO, + maxBuffer: 64 * 1024 * 1024 + }) + writeFileSync(path, old) + } + } + execFileSync( + 'npx', + [ + 'tsc', + '-p', + 'config/tsconfig.cli.json', + '--outDir', + outDir, + '--composite', + 'false', + '--incremental', + 'false' + ], + { cwd: REPO, stdio: 'inherit' } + ) + } finally { + for (const [path, contents] of restored) { + writeFileSync(path, contents) + } + } + return join(outDir, 'cli/index.js') +} + +// Why: every case is a one-shot CLI invocation that must exit on its own. An +// unbounded spawnSync turns "this argv reached a blocking command" into an +// indefinite stall — a guard that can hang instead of failing is not a guard. +const RUN_TIMEOUT_MS = 30_000 + +function run(entry, argv, env) { + const result = spawnSync(process.execPath, [entry, ...argv], { + cwd: REPO, + env: { ...process.env, ...env }, + encoding: 'buffer', + timeout: RUN_TIMEOUT_MS, + killSignal: 'SIGKILL' + }) + if (result.error) { + if (result.error.code === 'ETIMEDOUT') { + throw new Error( + `orca ${argv.join(' ')} did not exit within ${RUN_TIMEOUT_MS} ms — it reached a blocking command` + ) + } + throw result.error + } + return { + status: result.status, + stdout: result.stdout.toString('utf8'), + stderr: result.stderr.toString('utf8') + } +} + +// Every case must produce identical bytes on both arms. The runtime-dependent +// ones (status/worktree list) are pointed at an empty user-data dir so the +// answer is deterministic "not running" rather than whatever the dev machine +// happens to be doing. +function buildCases(isolatedUserData) { + const isolated = { ORCA_USER_DATA_PATH: isolatedUserData } + const cases = [ + // Paths that must never load the runtime client at all. + [[], {}], + [['--help'], {}], + [['help'], {}], + [['help', 'worktree'], {}], + [['help', 'browser'], {}], + [['worktree', '--help'], {}], + [['help', 'no-such-command'], {}], + [['no-such-command'], {}], + [['no-such-command'], { ORCA_PAIRING_CODE: 'garbage' }], + [['wrktree', 'list'], {}], + [['agent-context'], {}], + [['agent-context', '--json'], {}], + // Flag validation must still fire before any runtime lookup. + [['worktree', 'list', '--nonexistent-flag'], {}], + [['worktree', 'list', '--nonexistent-flag', '--json'], {}], + [['browser', 'snapshot', '--nonexistent-flag'], {}], + // resolveRemotePairing throws from the RuntimeClient CONSTRUCTOR. + [['status', '--pairing-code', 'x', '--environment', 'y'], isolated], + [['status', '--pairing-code', 'x', '--environment', 'y', '--json'], isolated], + [['status', '--pairing-code', 'not-a-pairing-code'], isolated], + [['status', '--pairing-code', 'not-a-pairing-code', '--json'], isolated], + [['status', '--pairing-code', 'orca://pair?code=zzzz'], isolated], + [['status', '--environment', 'no-such-environment'], isolated], + [['status', '--environment', 'no-such-environment', '--json'], isolated], + [['worktree', 'list', '--environment', 'no-such-environment', '--json'], isolated], + // The env-var fallback must stay live for non-suppressed commands... + [['status', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }], + [['status', '--json'], { ...isolated, ORCA_REMOTE_PAIRING: 'not-a-pairing-code' }], + [['status', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }], + // ...and must stay suppressed for the local-only command groups. + // + // NOTE: the only commands that both live in a suppressed group AND touch + // ctx.client are `agent hooks on|off`, which rewrite the user's real agent + // hook configuration in ~/.claude and friends — far outside + // ORCA_USER_DATA_PATH. They are deliberately NOT invoked here. The + // null-vs-undefined suppression they would exercise is covered + // side-effect-free by the constructor-argument assertions in + // src/cli/runtime-client-deferral.test.ts instead. + [['environment', 'list', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }], + [['environment', 'list', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }], + [['agent-context', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }], + // Runtime-unavailable reporting (RuntimeClientError formatting). + [['status'], isolated], + [['status', '--json'], isolated], + [['worktree', 'list', '--json'], isolated], + [['terminal', 'list', '--json'], isolated] + ] + // Fuzz: random argv drawn from real command tokens, flags and hostile + // strings. Seeded so a failure is reproducible. Every token here must be + // safe to actually execute — see UNSAFE_TOKENS, which is cross-checked + // against this list before a single case runs. + const tokens = [ + 'worktree', + 'list', + 'status', + 'browser', + 'snapshot', + 'terminal', + 'environment', + 'agent', + 'agent-context', + 'vm', + '--json', + '--help', + '--pairing-code', + '--environment', + '--worktree', + 'orca://pair?code=!!!', + '', + '-', + '--', + '--=', + 'a'.repeat(300), + 'näme-ünicode', + '💥', + '../..', + 'x\ty' + ] + let seed = 0x9e3779b9 + const next = () => { + seed ^= seed << 13 + seed ^= seed >>> 17 + seed ^= seed << 5 + return (seed >>> 0) / 0x100000000 + } + // Why: check the draw POOL, not just the 400 cases it happens to produce. + // `serve` sat in this array for the whole review because the per-case scan + // never named it, and the cases that drew it only survived by accident. + assertTokensSafe(tokens, 'fuzz token pool') + assertFuzzPoolDeclaredReadOnly(tokens) + for (let index = 0; index < 400; index += 1) { + const length = 1 + Math.floor(next() * 4) + const argv = [] + for (let part = 0; part < length; part += 1) { + argv.push(tokens[Math.floor(next() * tokens.length)]) + } + cases.push([argv, isolated]) + } + for (const [argv] of cases) { + assertTokensSafe(argv, `argv ${JSON.stringify(argv)}`) + } + return cases +} + +// Why: this script shells out to the REAL CLI with the developer's own HOME and +// PATH, so an argv that reaches the wrong verb does real damage. Two classes: +// +// 1. FOREGROUND — `orca serve` runs Orca until Ctrl+C and `orca open` / +// `claude-teams` spawn processes that outlive the case. A blocking case +// does not fail the run, it stalls it, which is worse than a mismatch. +// 2. MUTATING — writes outside ORCA_USER_DATA_PATH (`agent hooks off` parks +// the real ~/.claude hooks) or drives real browser/desktop input. +// +// Group tokens whose subcommands split read/write (`capture`, `intercept`, +// `label`, `relation`) are denied wholesale: the fuzzer cannot tell them apart. +const FOREGROUND_TOKENS = ['serve', 'open', 'claude-teams', 'exec', 'eval', 'launch', 'attach'] +const MUTATING_TOKENS = [ + // persistent config and registry state + 'on', + 'off', + 'hooks', + 'create', + 'remove', + 'rm', + 'delete', + 'add', + 'edit', + 'set', + 'set-value', + 'set-base-ref', + 'setup-clone', + 'setup-create', + 'setup-update', + 'setup-delete', + 'setup-existing-folder', + 'install', + 'uninstall', + 'reinstall', + 'update', + 'clone', + 'apply', + 'write', + 'reset', + 'clear', + 'enable', + 'disable', + 'use-default', + 'permissions', + // process and pane lifecycle + 'run', + 'run-stop', + 'start', + 'stop', + 'kill', + 'shutdown', + 'close', + 'split', + 'switch', + 'rename', + 'focus', + // orchestration writes + 'send', + 'reply', + 'dispatch', + 'task-create', + 'task-update', + 'gate-create', + 'gate-resolve', + // issue-tracker writes + 'save-issue', + 'comment', + 'label', + 'relation', + 'assignee', + 'priority', + 'estimate', + 'due-date', + // browser / computer / emulator input and navigation + 'goto', + 'back', + 'forward', + 'reload', + 'click', + 'dblclick', + 'hover', + 'fill', + 'type', + 'type-text', + 'select', + 'select-all', + 'uncheck', + 'keypress', + 'press-key', + 'inserttext', + 'hotkey', + 'paste-text', + 'perform-secondary-action', + 'drag', + 'scroll', + 'scrollintoview', + 'wheel', + 'move', + 'up', + 'down', + 'tap', + 'gesture', + 'button', + 'rotate', + 'upload', + 'download', + 'dismiss', + 'accept', + 'highlight', + 'viewport', + 'geolocation', + 'headers', + 'credentials', + 'offline', + 'media', + 'device', + 'capture', + 'intercept' +] + +const UNSAFE_TOKENS = new Map([ + ...FOREGROUND_TOKENS.map((token) => [token, 'runs in the foreground or spawns a process']), + ...MUTATING_TOKENS.map((token) => [token, 'can write outside ORCA_USER_DATA_PATH']) +]) + +// Why: the deny list only catches verbs someone already thought of — `serve` +// sat in the fuzz pool for the whole review because nobody added it. The pool +// is therefore ALSO checked against this allowlist, so a token added to the +// pool fails closed until it is consciously declared read-only here. +const READ_ONLY_FUZZ_TOKENS = new Set([ + // command tokens: every path they can form is a list/show or a parse error + 'agent', + 'agent-context', + 'browser', + 'environment', + 'list', + 'snapshot', + 'status', + 'terminal', + 'vm', + 'worktree', + // global flags and hostile strings, which reach no handler at all + '--json', + '--help', + '--pairing-code', + '--environment', + '--worktree', + 'orca://pair?code=!!!', + '', + '-', + '--', + '--=', + 'a'.repeat(300), + 'näme-ünicode', + '💥', + '../..', + 'x\ty' +]) + +function assertTokensSafe(tokens, context) { + for (const token of tokens) { + const reason = UNSAFE_TOKENS.get(token) + if (reason) { + throw new Error(`Refusing to run ${context}: "${token}" ${reason}`) + } + } +} + +// Why: a token on neither list (or, worse, on both) means the two lists have +// drifted apart. Fail before any case runs rather than sampling and hoping. +function assertFuzzPoolDeclaredReadOnly(tokens) { + for (const token of tokens) { + if (!READ_ONLY_FUZZ_TOKENS.has(token)) { + throw new Error( + `Fuzz token ${JSON.stringify(token)} is not declared in READ_ONLY_FUZZ_TOKENS — declare it read-only or drop it` + ) + } + } + for (const token of READ_ONLY_FUZZ_TOKENS) { + if (UNSAFE_TOKENS.has(token)) { + throw new Error( + `Token ${JSON.stringify(token)} is declared both read-only and unsafe — the two lists disagree` + ) + } + } +} + +const baselineIndex = process.argv.indexOf('--baseline') +const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1] + +assertMarkersFresh() + +const isolatedUserData = mkdtempSync(join(REPO, '.equiv-userdata-')) +mkdirSync(join(isolatedUserData, 'empty'), { recursive: true }) + +let oldEntry +let newEntry +try { + // Why: build the case list (and run its safety guards) BEFORE the two tsc + // compiles, so an unsafe token fails in a second instead of two minutes in. + const cases = buildCases(isolatedUserData) + + console.log(`Building baseline (${baselineRev}) …`) + oldEntry = buildTree('old', baselineRev) + console.log('Building working tree …') + newEntry = buildTree('new', null) + + console.log(`Comparing ${cases.length} invocations byte for byte …`) + let mismatches = 0 + for (const [argv, env] of cases) { + const before = run(oldEntry, argv, env) + const after = run(newEntry, argv, env) + if ( + before.status !== after.status || + before.stdout !== after.stdout || + before.stderr !== after.stderr + ) { + mismatches += 1 + console.error(`\nMISMATCH argv=${JSON.stringify(argv)} env=${JSON.stringify(env)}`) + console.error(` exit before=${before.status} after=${after.status}`) + if (before.stdout !== after.stdout) { + console.error(` stdout before=${JSON.stringify(before.stdout.slice(0, 400))}`) + console.error(` after =${JSON.stringify(after.stdout.slice(0, 400))}`) + } + if (before.stderr !== after.stderr) { + console.error(` stderr before=${JSON.stringify(before.stderr.slice(0, 400))}`) + console.error(` after =${JSON.stringify(after.stderr.slice(0, 400))}`) + } + } + } + if (mismatches > 0) { + throw new Error(`${mismatches} of ${cases.length} invocations differ`) + } + console.log(`\nAll ${cases.length} invocations byte-identical (stdout, stderr, exit code).`) +} finally { + rmSync(isolatedUserData, { recursive: true, force: true }) + for (const label of ['old', 'new']) { + rmSync(resolve(REPO, `.equiv-out-${label}`), { recursive: true, force: true }) + } +} diff --git a/config/scripts/hydrate-worktree-lookup-benchmark.mjs b/config/scripts/hydrate-worktree-lookup-benchmark.mjs new file mode 100644 index 00000000000..76b737501e3 --- /dev/null +++ b/config/scripts/hydrate-worktree-lookup-benchmark.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +// Benchmark: the per-id worktree/tab lookups in session hydration and terminal reconnect. +// +// Four sites in store/slices/terminals.ts re-flattened worktreesByRepo (or tabsByWorktree) +// and linearly searched it once per loop iteration -- O(rows x ids) for O(rows + ids) +// distinct work. The fix builds one first-wins index per loop. +// +// This runs on the renderer's synchronous cold-start path and gates workspaceSessionReady, +// which blocks terminal pane mounting, so the cost is paid before the first frame. +// +// Both arms produce the resolved rows and are compared for equality before timing, so an +// index that resolved differently could not be reported as a win. +// +// Run with: node config/scripts/hydrate-worktree-lookup-benchmark.mjs +import { performance } from 'node:perf_hooks' + +const ITERATIONS = Number(process.env.ORCA_HYDRATE_BENCH_ITERATIONS ?? '60') +const WARMUP = Number(process.env.ORCA_HYDRATE_BENCH_WARMUP ?? '10') +const ROUNDS = 6 + +for (const [name, value] of [ + ['ORCA_HYDRATE_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_HYDRATE_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +// Pre-fix: re-flatten and linear-search per id. +function resolveByFlatten(worktreesByRepo, ids) { + const resolved = [] + for (const id of ids) { + const worktree = Object.values(worktreesByRepo) + .flat() + .find((entry) => entry.id === id) + resolved.push(worktree ? worktree.repoId : null) + } + return resolved +} + +// Post-fix: mirrors buildWorktreeByIdIndex in store/slices/worktree-by-id-index.ts. +function resolveByIndex(worktreesByRepo, ids) { + const index = new Map() + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + if (!index.has(worktree.id)) { + index.set(worktree.id, worktree) + } + } + } + const resolved = [] + for (const id of ids) { + const worktree = index.get(id) + resolved.push(worktree ? worktree.repoId : null) + } + return resolved +} + +function makeStore(repoCount, worktreesPerRepo) { + const worktreesByRepo = {} + for (let repo = 0; repo < repoCount; repo += 1) { + const repoId = `repo-${repo}` + worktreesByRepo[repoId] = Array.from({ length: worktreesPerRepo }, (_value, index) => ({ + id: `${repoId}/wt-${index}`, + repoId, + path: `/Users/dev/worktrees/${repoId}/wt-${index}`, + branch: `feature/branch-${index}` + })) + } + // Why a deliberate duplicate: `.find()` is first-wins, so an index that overwrote on + // collision would resolve a different repo. Without a collision in the fixture that + // difference is unobservable and the equality check below would pass a broken index. + if (repoCount > 1) { + const [firstRepo, secondRepo] = Object.keys(worktreesByRepo) + worktreesByRepo[secondRepo] = [ + { ...worktreesByRepo[firstRepo][0], repoId: secondRepo }, + ...worktreesByRepo[secondRepo] + ] + } + return worktreesByRepo +} + +// Why a miss fraction: SSH worktrees are absent from worktreesByRepo at cold start, so +// the real workload includes ids that scan the whole list without matching -- the worst +// case for the linear arm, and the one the code comments call out explicitly. +function makeIds(worktreesByRepo, count) { + const all = Object.values(worktreesByRepo).flat() + const ids = Array.from({ length: count }, (_value, index) => + index % 7 === 0 ? `absent/wt-${index}` : all[(index * 31) % all.length].id + ) + // Always look up the duplicated id, so first-wins is exercised, not just present. + ids[1] = all[0].id + return ids +} + +function timeArm(resolve, worktreesByRepo, ids) { + let sink = 0 + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + // Consume the result so V8 cannot drop the call as dead. + sink += resolve(worktreesByRepo, ids).length + } + const elapsed = (performance.now() - start) / ITERATIONS + if (sink === -1) { + throw new Error('unreachable') + } + return elapsed +} + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const mid = sorted.length / 2 + return (sorted[mid - 1] + sorted[mid]) / 2 +} + +// Arms alternate which one leads so within-round drift cannot favour either. +function measure(worktreesByRepo, ids) { + for (let index = 0; index < WARMUP; index += 1) { + resolveByFlatten(worktreesByRepo, ids) + resolveByIndex(worktreesByRepo, ids) + } + const flattenSamples = [] + const indexSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + if (round % 2 === 0) { + flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids)) + indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids)) + } else { + indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids)) + flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids)) + } + } + return { flattenMs: median(flattenSamples), indexMs: median(indexSamples) } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Session-hydration worktree lookup, per cold start. Lower is better.') +console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`) +console.log( + `${pad('repos', 6)} ${pad('worktrees', 10)} ${pad('ids', 5)} ${pad('flatten', 11)} ${pad('indexed', 11)} ${pad('speedup', 9)}` +) + +// Row shapes are synthetic, sized against a real orca-data.json on a heavy machine +// (10 repos / 423 worktrees / 188 pending reconnect). They are not that dataset: the +// generator spreads worktrees evenly and injects one duplicate id, so treat the counts +// as "about this scale", not a replay. +for (const [repoCount, worktreesPerRepo, idCount] of [ + [1, 5, 5], + [3, 20, 20], + [10, 42, 188], + [10, 100, 400] +]) { + const worktreesByRepo = makeStore(repoCount, worktreesPerRepo) + const ids = makeIds(worktreesByRepo, idCount) + const flattenResult = resolveByFlatten(worktreesByRepo, ids) + const indexResult = resolveByIndex(worktreesByRepo, ids) + if (JSON.stringify(flattenResult) !== JSON.stringify(indexResult)) { + throw new Error(`resolution differs at ${repoCount} repos x ${worktreesPerRepo} worktrees`) + } + if (!flattenResult.some((value) => value !== null)) { + throw new Error(`fixture resolved nothing at ${repoCount} repos`) + } + if (!flattenResult.some((value) => value === null)) { + throw new Error(`fixture had no absent ids at ${repoCount} repos`) + } + // Count the generated rows rather than multiplying: makeStore injects a duplicate + // id for multi-repo cases, so the product would misreport the fixture by one. + const worktreeCount = Object.values(worktreesByRepo).reduce((sum, rows) => sum + rows.length, 0) + const { flattenMs, indexMs } = measure(worktreesByRepo, ids) + console.log( + `${pad(repoCount, 6)} ${pad(worktreeCount, 10)} ${pad(idCount, 5)} ${pad(`${flattenMs.toFixed(4)} ms`, 11)} ${pad(`${indexMs.toFixed(4)} ms`, 11)} ${pad(`${(flattenMs / indexMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + '\nFixtures are synthetic at real-world scale, not a replay of a real session.\nThis times one of the four lookup sites. A one-repo session sees almost nothing;\nthe win scales with worktrees x pending ids, and lands on the cold-start path that\ngates terminal pane mounting.' +) diff --git a/config/scripts/rich-markdown-doc-link-scan-benchmark.mjs b/config/scripts/rich-markdown-doc-link-scan-benchmark.mjs new file mode 100644 index 00000000000..e0f88bd4189 --- /dev/null +++ b/config/scripts/rich-markdown-doc-link-scan-benchmark.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +// Measures the complete ProseMirror doc traversal used by the two doc-link plugins. +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { Schema } from '@tiptap/pm/model' +import { + canHoldDocLink, + DOC_LINK_PATTERN, + isDocLinkLiteralCodeTextNode +} from '../../src/renderer/src/components/editor/rich-markdown-doc-link-scan.ts' + +const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)) +const ITERATIONS = Number(process.env.ORCA_DOC_LINK_BENCH_ITERATIONS ?? '41') +const WARMUP_ITERATIONS = Math.min(9, ITERATIONS) + +if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) { + throw new Error(`ORCA_DOC_LINK_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`) +} + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'text*' }, + text: { group: 'inline' } + } +}) + +function walkUngated(doc) { + let matches = 0 + let visited = 0 + doc.descendants((node, _pos, parent) => { + visited += 1 + if (node.type.name !== 'text' || !node.text || isDocLinkLiteralCodeTextNode(node, parent)) { + return + } + for (const _match of node.text.matchAll(DOC_LINK_PATTERN)) { + matches += 1 + } + }) + return { matches, visited } +} + +function walkGated(doc) { + let matches = 0 + let visited = 0 + doc.descendants((node, _pos, parent) => { + visited += 1 + if (!canHoldDocLink(node, parent)) { + return + } + for (const _match of node.text.matchAll(DOC_LINK_PATTERN)) { + matches += 1 + } + }) + return { matches, visited } +} + +function countMatches(source) { + let matches = 0 + for (const _match of source.matchAll(DOC_LINK_PATTERN)) { + matches += 1 + } + return matches +} + +function loadDocs() { + const files = execFileSync('git', ['ls-files', '*.md', 'docs/*.md'], { + cwd: REPO_ROOT, + maxBuffer: 256 * 1024 * 1024 + }) + .toString() + .split('\n') + .filter(Boolean) + const docs = [] + for (const file of files) { + try { + const source = readFileSync(join(REPO_ROOT, file), 'utf8') + const lines = source.split('\n').filter(Boolean) + if (lines.length > 0) { + docs.push({ file, lines, size: source.length, matches: countMatches(source) }) + } + } catch { + // Indexed paths can disappear while the benchmark is running. + } + } + return docs +} + +function createFixture(doc, nonce) { + const paragraphs = doc.lines.map((line, index) => { + const text = index === 0 ? `${line} bench-${nonce}` : line + return schema.node('paragraph', null, text ? schema.text(text) : undefined) + }) + return schema.node('doc', null, paragraphs) +} + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + return sorted[Math.floor(sorted.length / 2)] +} + +function measureCorpus(docs) { + const samples = { ungated: [], gated: [] } + const totals = { + ungated: { matches: 0, visited: 0 }, + gated: { matches: 0, visited: 0 } + } + const seenFixtures = new WeakSet() + let expectedMatches = 0 + let expectedVisited = 0 + const measuredOrder = [] + + for (let round = -WARMUP_ITERATIONS; round < ITERATIONS; round += 1) { + const doc = docs[(round + WARMUP_ITERATIONS) % docs.length] + const measured = round >= 0 + const order = + (round + WARMUP_ITERATIONS) % 2 === 0 ? ['ungated', 'gated'] : ['gated', 'ungated'] + const fixtures = { + ungated: createFixture(doc, String(round)), + gated: createFixture(doc, String(round)) + } + + for (const arm of order) { + const fixture = fixtures[arm] + if (seenFixtures.has(fixture)) { + throw new Error('timed fixture was reused') + } + seenFixtures.add(fixture) + const start = performance.now() + const result = arm === 'ungated' ? walkUngated(fixture) : walkGated(fixture) + const elapsed = performance.now() - start + if (measured) { + samples[arm].push(elapsed) + totals[arm].matches += result.matches + totals[arm].visited += result.visited + measuredOrder.push(arm) + } + } + if (measured) { + expectedMatches += doc.matches + expectedVisited += doc.lines.length * 2 + } + } + + if (totals.ungated.matches !== expectedMatches || totals.gated.matches !== expectedMatches) { + throw new Error( + `gate changed matches: expected ${expectedMatches}, ungated ${totals.ungated.matches}, gated ${totals.gated.matches}` + ) + } + if (totals.ungated.visited !== expectedVisited || totals.gated.visited !== expectedVisited) { + throw new Error( + `full traversal result was not consumed: expected ${expectedVisited}, ungated ${totals.ungated.visited}, gated ${totals.gated.visited}` + ) + } + for (let index = 0; index < measuredOrder.length; index += 2) { + const pair = measuredOrder.slice(index, index + 2).join(',') + const previousPair = index === 0 ? null : measuredOrder.slice(index - 2, index).join(',') + if (!['ungated,gated', 'gated,ungated'].includes(pair) || pair === previousPair) { + throw new Error('benchmark arms were not interleaved in alternating order') + } + } + + return { ungated: median(samples.ungated), gated: median(samples.gated) } +} + +const docs = loadDocs() +if (docs.length === 0) { + throw new Error('no markdown files found in the index') +} +const large = docs.filter((doc) => doc.size > 3000) +const biggest = docs.reduce((a, b) => (b.size > a.size ? b : a)) +const pad = (value, width) => String(value).padStart(width) + +console.log('Doc-link ProseMirror traversal, per editor transaction. Lower is better.') +console.log( + `docs=${docs.length} (>3KB: ${large.length}) iterations=${ITERATIONS} (interleaved median)` +) +console.log( + `${pad('corpus', 26)} ${pad('ungated', 11)} ${pad('gated', 11)} ${pad('delta', 11)} ${pad('speedup', 9)}` +) + +for (const [label, set] of [ + ['all repo markdown', docs], + ['docs over 3 KB', large], + [`biggest (${biggest.file.split('/').pop()})`, [biggest]] +]) { + if (set.length === 0) { + console.log(`${pad(label, 26)} ${pad('no docs in this corpus — skipped', 44)}`) + continue + } + const { ungated, gated } = measureCorpus(set) + const delta = ungated - gated + console.log( + `${pad(label, 26)} ${pad(`${(ungated * 1000).toFixed(1)} us`, 11)} ${pad(`${(gated * 1000).toFixed(1)} us`, 11)} ${pad(`${(delta * 1000).toFixed(1)} us`, 11)} ${pad(`${(ungated / gated).toFixed(2)}x`, 9)}` + ) +} +console.log( + '\nAuto-conversion pays this once per keystroke; preview decorations pay it once\nper keystroke and once per caret move.' +) diff --git a/config/scripts/telemetry-bundle-constant-patterns.mjs b/config/scripts/telemetry-bundle-constant-patterns.mjs new file mode 100644 index 00000000000..04944b7b156 --- /dev/null +++ b/config/scripts/telemetry-bundle-constant-patterns.mjs @@ -0,0 +1,2 @@ +export const BUILD_IDENTITY_RE = /\b(?:const|let|var)\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/ +export const WRITE_KEY_RE = /\b(?:const|let|var)\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/ diff --git a/config/scripts/telemetry-bundle-constant-patterns.test.mjs b/config/scripts/telemetry-bundle-constant-patterns.test.mjs new file mode 100644 index 00000000000..b8df5ec3d0f --- /dev/null +++ b/config/scripts/telemetry-bundle-constant-patterns.test.mjs @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { BUILD_IDENTITY_RE, WRITE_KEY_RE } from './telemetry-bundle-constant-patterns.mjs' + +describe('telemetry bundle constant patterns', () => { + it.each(['const', 'let', 'var'])('accepts %s declarations', (declaration) => { + expect(`${declaration} BUILD_IDENTITY = "rc"`).toMatch(BUILD_IDENTITY_RE) + expect(`${declaration} WRITE_KEY = "phc_example-key_123"`).toMatch(WRITE_KEY_RE) + }) + + it('rejects assignments and invalid values', () => { + expect('BUILD_IDENTITY = "rc"').not.toMatch(BUILD_IDENTITY_RE) + expect('const BUILD_IDENTITY = "dev"').not.toMatch(BUILD_IDENTITY_RE) + expect('const WRITE_KEY = null').not.toMatch(WRITE_KEY_RE) + expect('const WRITE_KEY = "example-key"').not.toMatch(WRITE_KEY_RE) + }) +}) diff --git a/config/scripts/terminal-output-frame-chunk-benchmark.mjs b/config/scripts/terminal-output-frame-chunk-benchmark.mjs new file mode 100644 index 00000000000..6dd6364524c --- /dev/null +++ b/config/scripts/terminal-output-frame-chunk-benchmark.mjs @@ -0,0 +1,347 @@ +#!/usr/bin/env node +// Benchmark: iterateTerminalOutputFrameChunks, which every byte of remote terminal +// output passes through on its way to a mobile/remote-desktop multiplex stream. +// +// The pre-fix loop was `for (const part of data)`: V8 materializes a fresh 1-2 code +// unit string per code point, then measureClipboardTextByteLength re-walked that +// string through codePointAt to get its UTF-8 width, and the chunk was rebuilt with +// `chunk += part`. The gate in front of it (terminalStreamByteLengthExceeds) ran the +// same per-code-point walk over the whole payload a second time. +// +// The fix: one charCodeAt scan computing the UTF-8 width inline with no per-code-point +// string, chunk text taken as data.slice(chunkStart, end), and a gate of +// `data.length > CHUNK || Buffer.byteLength(data, 'utf8') > CHUNK` -- sound because a +// string's UTF-8 length is never below its UTF-16 length. +// +// BOTH arms run the complete production path, encodeTerminalStreamText included, and +// their emitted frames (base64 + seq + opcode) are compared before any timing, so an +// arm that split differently or renumbered a seq cannot be reported as a win. +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import nodeModule from 'node:module' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +// terminal-stream-protocol.ts declares a TS enum, which Node's default strip-only +// loader rejects; re-exec once with type transformation rather than re-modelling +// the opcodes here (a hand copy could drift from the wire contract). +if (!process.execArgv.includes('--experimental-transform-types')) { + const result = spawnSync( + process.execPath, + ['--experimental-transform-types', '--no-warnings', import.meta.filename], + { stdio: 'inherit' } + ) + process.exit(result.status ?? 1) +} + +// The app's TS sources import siblings without an extension; Node's ESM resolver needs it. +nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) { + const candidate = new URL(`${specifier}.ts`, context.parentURL) + if (existsSync(fileURLToPath(candidate))) { + return { url: candidate.href, shortCircuit: true } + } + } + return nextResolve(specifier, context) + } +}) + +const ITERATIONS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ITERATIONS ?? '40') +const WARMUP = Number(process.env.ORCA_FRAME_CHUNK_BENCH_WARMUP ?? '8') +const ROUNDS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ROUNDS ?? '6') + +for (const [name, value] of [ + ['ORCA_FRAME_CHUNK_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_FRAME_CHUNK_BENCH_WARMUP', WARMUP], + ['ORCA_FRAME_CHUNK_BENCH_ROUNDS', ROUNDS] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} +if (ROUNDS % 2 !== 0) { + throw new Error(`ORCA_FRAME_CHUNK_BENCH_ROUNDS must be even so each arm leads equally`) +} + +const CHUNK_SOURCE = readFileSync( + new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url), + 'utf8' +) +const CLIPBOARD_SOURCE = readFileSync( + new URL('../../src/shared/clipboard-text.ts', import.meta.url), + 'utf8' +) + +// Why re-read the sources: this benchmark's claim is that the OLD arm paid a +// measureClipboardTextByteLength call per code point and the NEW one pays a single +// Buffer.byteLength gate. Match the CALL form, not a bare word -- a comment naming +// the function would satisfy a substring check. +for (const [source, label, marker] of [ + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', "Buffer.byteLength(data, 'utf8')"], + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'const text = data.slice(chunkStart, end)'], + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'data.charCodeAt(index + 1)'], + [CLIPBOARD_SOURCE, 'clipboard-text.ts', 'export function measureClipboardTextByteLength('], + [CLIPBOARD_SOURCE, 'clipboard-text.ts', 'text.codePointAt(index)'] +]) { + if (!source.includes(marker)) { + throw new Error(`${label} no longer contains \`${marker}\`; this benchmark is stale`) + } +} +if (CHUNK_SOURCE.includes('for (const part of data)')) { + throw new Error( + 'terminal-output-frame-chunks.ts still iterates code points as strings; this benchmark is stale' + ) +} + +const { TERMINAL_STREAM_CHUNK_BYTES } = await import( + new URL('../../src/shared/terminal-multiplex-flow-control.ts', import.meta.url).href +) +const { measureClipboardTextByteLength } = await import( + new URL('../../src/shared/clipboard-text.ts', import.meta.url).href +) +const { TerminalStreamOpcode, encodeTerminalStreamJson, encodeTerminalStreamText } = await import( + new URL('../../src/shared/terminal-stream-protocol.ts', import.meta.url).href +) +const { iterateTerminalOutputFrameChunks } = await import( + new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url).href +) + +// Pre-fix arm: the exact code that shipped, including the second full walk in the gate. +function* iterateBefore(data, meta) { + const rawLength = meta?.rawLength ?? data.length + if (meta?.transformed || rawLength !== data.length) { + yield { + opcode: TerminalStreamOpcode.OutputSpan, + bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), + seq: meta?.seq + } + return + } + if ( + !measureClipboardTextByteLength(data, { stopAfterBytes: TERMINAL_STREAM_CHUNK_BYTES }) + .exceededLimit + ) { + yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } + return + } + const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' + const startSeq = canPreserveChunkSeq ? meta.seq - rawLength : undefined + let chunk = '' + let chunkBytes = 0 + let chunkStartOffset = 0 + let offset = 0 + let delayedChunk = null + + const takeChunk = () => { + if (!chunk) { + return null + } + const chunkSeq = canPreserveChunkSeq ? startSeq + chunkStartOffset + chunk.length : undefined + const current = { text: chunk, seq: chunkSeq } + chunk = '' + chunkBytes = 0 + chunkStartOffset = offset + return current + } + + for (const part of data) { + const partBytes = measureClipboardTextByteLength(part).byteLength + if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { + const nextChunk = takeChunk() + if (nextChunk) { + if (shouldDelayFinalSeq) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = nextChunk + } else { + yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } + } + } + } + chunk += part + chunkBytes += partBytes + offset += part.length + } + const finalChunk = takeChunk() + if (shouldDelayFinalSeq) { + if (finalChunk) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = finalChunk + } + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq } + } + return + } + if (finalChunk) { + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } + } +} + +// The multiplex stream consumes every frame's bytes/seq/opcode; charge both arms for it. +let frameChecksum = 0 +function drain(iterate, data, meta) { + let frames = 0 + let bytes = 0 + let seqSum = 0 + for (const frame of iterate(data, meta)) { + frames += 1 + bytes += frame.bytes.byteLength + seqSum += frame.seq ?? 0 + } + frameChecksum = Math.imul(frameChecksum ^ (frames + bytes + seqSum), 16777619) >>> 0 + return frames +} + +function describeFrames(iterate, data, meta) { + const shapes = [] + for (const frame of iterate(data, meta)) { + shapes.push( + `${Buffer.from(frame.bytes).toString('base64')}|${frame.seq ?? 'u'}|${frame.opcode ?? 'u'}` + ) + } + return shapes.join('\n') +} + +const SURROGATE_PAIR = '\u{1f600}' +const LONE_HIGH = '\ud83d' + +function repeatTo(unit, codeUnits) { + let out = '' + while (out.length < codeUnits) { + out += unit + } + return out.slice(0, out.length - (out.length % unit.length)) +} + +// A realistic agent-TUI line: SGR runs, a wide glyph, a currency sign, an emoji. +const TUI_LINE = + '\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed, 0 failed\r\n' + +const fixtures = [ + { + label: 'ascii 4KiB (typical batch)', + data: 'x'.repeat(4 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: `ascii ${TERMINAL_STREAM_CHUNK_BYTES}B (at cap)`, + data: 'x'.repeat(TERMINAL_STREAM_CHUNK_BYTES), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'ascii 64KiB (batch cap, 2 chunks)', + data: 'x'.repeat(64 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'mixed TUI 64KiB (2 chunks)', + data: repeatTo(TUI_LINE, 64 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + // seq without an explicit rawLength: the batcher's ordinary shape. + label: 'mixed TUI 64KiB (implicit raw)', + data: repeatTo(TUI_LINE, 64 * 1024), + meta: () => ({ seq: 5_000_000 }) + }, + { + label: 'emoji 64KiB (4-byte, 2 chunks)', + data: repeatTo(SURROGATE_PAIR, 32 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'lone surrogates 64KiB', + data: repeatTo(LONE_HIGH, 64 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'ascii 512KiB (snapshot chunking)', + data: 'x'.repeat(512 * 1024), + meta: () => undefined + } +] + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +// Why interleaved with an alternating lead: running one arm's whole batch first lets +// CPU frequency drift correlate with whichever arm is being measured. Elsewhere in this +// effort that alone reported 23.3x for a real 6.7x. +function measureInterleaved(data, meta) { + for (let index = 0; index < WARMUP; index += 1) { + drain(iterateBefore, data, meta) + drain(iterateTerminalOutputFrameChunks, data, meta) + } + const beforeSamples = [] + const afterSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + const runBefore = () => { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + drain(iterateBefore, data, meta) + } + beforeSamples.push((performance.now() - start) / ITERATIONS) + } + const runAfter = () => { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + drain(iterateTerminalOutputFrameChunks, data, meta) + } + afterSamples.push((performance.now() - start) / ITERATIONS) + } + if (round % 2 === 0) { + runBefore() + runAfter() + } else { + runAfter() + runBefore() + } + } + return { beforeMs: median(beforeSamples), afterMs: median(afterSamples) } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('iterateTerminalOutputFrameChunks, per flushed batch. Lower is better.') +console.log( + `iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (alternating lead, per-arm median)` +) +console.log( + `${pad('fixture', 34)} ${pad('frames', 7)} ${pad('per-part', 11)} ${pad('scanned', 11)} ${pad('speedup', 9)}` +) + +let comparedFixtures = 0 +for (const fixture of fixtures) { + const meta = fixture.meta(fixture.data) + const before = describeFrames(iterateBefore, fixture.data, meta) + const after = describeFrames(iterateTerminalOutputFrameChunks, fixture.data, meta) + if (before !== after) { + throw new Error(`frames differ for ${fixture.label}`) + } + const frames = before.split('\n').length + // Guard against a fixture that never reaches the chunking loop; then both arms would + // just be measuring the gate and the comparison above would prove nothing about it. + if (fixture.data.length > TERMINAL_STREAM_CHUNK_BYTES && frames < 2) { + throw new Error(`${fixture.label} never split; fixture does not exercise the chunk loop`) + } + comparedFixtures += 1 + const { beforeMs, afterMs } = measureInterleaved(fixture.data, meta) + console.log( + `${pad(fixture.label, 34)} ${pad(frames, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + `\nvalidated=${comparedFixtures} fixtures frame-identical before timing, checksum=${frameChecksum >>> 0}` +) +console.log( + 'Every byte of remote terminal output crosses this function; the batcher flushes at\nTERMINAL_OUTPUT_BATCH_MAX_BYTES (64 KiB) or every 5 ms, so a busy remote agent pane\nruns it tens of times a second per subscribed stream.' +) diff --git a/config/scripts/terminal-stream-byte-length-benchmark.mjs b/config/scripts/terminal-stream-byte-length-benchmark.mjs new file mode 100644 index 00000000000..4ea2548b393 --- /dev/null +++ b/config/scripts/terminal-stream-byte-length-benchmark.mjs @@ -0,0 +1,416 @@ +#!/usr/bin/env node +// Benchmarks the production terminal byte-measurement exports at their real budgets: the output +// batcher push and the snapshot budget scan. Every scenario asserts whether production invoked +// Buffer.byteLength, so implementation drift cannot preserve stale speedup claims. +import { spawnSync } from 'node:child_process' +import { performance } from 'node:perf_hooks' +import fs from 'node:fs' +import nodeModule from 'node:module' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +if (!process.execArgv.includes('--experimental-transform-types')) { + const result = spawnSync( + process.execPath, + ['--experimental-transform-types', '--no-warnings', import.meta.filename], + { stdio: 'inherit' } + ) + process.exit(result.status ?? 1) +} + +// The app's TS sources import siblings without an extension; Node's ESM resolver needs it. +nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) { + const candidate = new URL(`${specifier}.ts`, context.parentURL) + if (fs.existsSync(fileURLToPath(candidate))) { + return { url: candidate.href, shortCircuit: true } + } + } + return nextResolve(specifier, context) + } +}) + +const ROOT = path.resolve(import.meta.dirname, '../..') +const ITERATIONS = Number(process.env.ORCA_BYTE_LENGTH_BENCH_ITERATIONS ?? '61') +let resultChecksum = 0 +let validatedPairs = 0 + +if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) { + throw new Error(`ORCA_BYTE_LENGTH_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`) +} + +function readSource(relative) { + return fs.readFileSync(path.join(ROOT, relative), 'utf8') +} + +const TERMINAL_SOURCE = readSource('src/main/runtime/rpc/methods/terminal.ts') + +function requireCallForm(source, needle, label) { + if (!source.includes(needle)) { + throw new Error(`${label} is stale: expected call form \`${needle}\` was not found`) + } +} + +const { TERMINAL_OUTPUT_BATCH_MAX_BYTES, TERMINAL_STREAM_CHUNK_BYTES } = await import( + new URL('../../src/shared/terminal-multiplex-flow-control.ts', import.meta.url).href +) +const { measureClipboardTextByteLength } = await import( + new URL('../../src/shared/clipboard-text.ts', import.meta.url).href +) +const { + MIN_NATIVE_BYTE_LENGTH_CODE_UNITS, + measureTerminalStreamByteLength, + terminalStreamByteLengthExceeds +} = await import( + new URL('../../src/main/runtime/rpc/terminal-stream-byte-length.ts', import.meta.url).href +) + +const REQUESTED_SNAPSHOT_BYTE_BUDGET = (() => { + const match = /const REQUESTED_SNAPSHOT_BYTE_BUDGET = ([^\n]+)/.exec(TERMINAL_SOURCE) + if (!match) { + throw new Error('terminal.ts is stale: REQUESTED_SNAPSHOT_BYTE_BUDGET is gone') + } + return Number(new Function(`return (${match[1].trim()})`)()) +})() + +requireCallForm(TERMINAL_SOURCE, 'measureTerminalStreamByteLength(data, {', 'terminal.ts') +requireCallForm(TERMINAL_SOURCE, 'stopAfterBytes: remainingBudget', 'terminal.ts') +requireCallForm( + TERMINAL_SOURCE, + 'terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET)', + 'terminal.ts' +) + +const nativeByteLength = Buffer.byteLength +function runWithNativeCallCount(fn) { + let calls = 0 + Buffer.byteLength = (...args) => { + calls += 1 + return Reflect.apply(nativeByteLength, Buffer, args) + } + try { + return { output: fn(), calls } + } finally { + Buffer.byteLength = nativeByteLength + } +} + +// ---- OLD ARM: the production implementation terminal.ts called before this change. +const legacyMeasure = measureClipboardTextByteLength +const legacyExceeds = (data, maxBytes) => + measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit + +// ---- Fixtures. Deterministic, seeded, and varied per sample so V8 cannot hoist. +function mulberry32(seed) { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Realistic agent-TUI output: mostly ASCII with SGR runs, box drawing, and emoji status +// glyphs, plus a per-sample marker so no two measured strings are identical. +function makeTerminalText(codeUnits, sampleId) { + const random = mulberry32(sampleId * 2654435761) + const lines = [ + '✻ Thinking…\r\n', + ' ⏺ Running tests… 42 passed, 0 failed\r\n', + '│ src/main/runtime/rpc/methods/terminal.ts │\r\n', + ' ✅ build succeeded in 12.4s — café naïve\r\n', + '+ added line\r\n' + ] + let text = `sample:${sampleId}\r\n` + while (text.length < codeUnits) { + text += lines[Math.floor(random() * lines.length)] + } + return text.slice(0, codeUnits) +} + +// Keystroke echo and tiny interactive writes: the shapes a PTY emits between key presses. +function makeInteractiveText(codeUnits, sampleId) { + const random = mulberry32(sampleId * 40503) + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789 ./-_' + let text = '' + while (text.length < codeUnits) { + text += alphabet[Math.floor(random() * alphabet.length)] + } + return text.slice(0, codeUnits) +} + +// Trim to just under a BYTE budget so the legacy arm runs its full scan without tripping the limit. +function makeTerminalTextUnderBytes(byteBudget, sampleId) { + let text = makeTerminalText(byteBudget, sampleId) + while (Buffer.byteLength(text, 'utf8') > byteBudget) { + text = text.slice(0, Math.floor(text.length * (byteBudget / Buffer.byteLength(text, 'utf8')))) + } + return text +} + +// The TRUE adversary for the exceeds gate: stay at the code-unit cap so `length > maxBytes` +// cannot short-circuit, but pack 3-byte BMP scalars so the legacy scan bails out after only a +// THIRD of the string while Buffer.byteLength still walks all of it. +function makeEarlyTripText(byteBudget, sampleId) { + const tripUnits = Math.ceil((byteBudget + 1) / 3) + const marker = String.fromCharCode(0x4e00 + (sampleId % 4096)) + const prefix = `${marker}${'走'.repeat(tripUnits - 1)}` + return `${prefix}${'a'.repeat(byteBudget - tripUnits)}` +} + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +function consume(value) { + resultChecksum = Math.imul(resultChecksum ^ (value | 0), 16777619) >>> 0 +} + +// Small inputs are far below timer resolution, so batch them: build `repeats` distinct +// samples, time the whole loop, and report per-call cost. Consuming the running total +// inside the timed region keeps V8 from hoisting the calls out. +function runScenario(scenario) { + const repeats = scenario.repeats ?? 1 + const samples = { legacy: [], next: [] } + const runArm = (fn, inputs) => { + const start = performance.now() + let total = 0 + for (const input of inputs) { + total += scenario.checksum(fn(input)) + } + const elapsed = performance.now() - start + return { elapsed, total } + } + for (let index = 0; index < ITERATIONS; index += 1) { + // Alternate which arm leads on every iteration so cache/JIT warmup is shared evenly. + for (const legacyFirst of index % 2 === 0 ? [true, false] : [false, true]) { + const batch = index * 2 + (legacyFirst ? 0 : 1) + const inputs = [] + for (let repeat = 0; repeat < repeats; repeat += 1) { + inputs.push(scenario.make(batch * repeats + repeat)) + } + const legacyOutputs = inputs.map(scenario.legacy) + const observed = runWithNativeCallCount(() => inputs.map(scenario.next)) + for (let inputIndex = 0; inputIndex < inputs.length; inputIndex += 1) { + const input = inputs[inputIndex] + const legacyOutput = legacyOutputs[inputIndex] + const nextOutput = observed.output[inputIndex] + if (!scenario.equal(legacyOutput, nextOutput)) { + throw new Error( + `${scenario.label}: arms disagree on ${JSON.stringify(input.slice(0, 40))}` + ) + } + scenario.assertResult(legacyOutput, input) + validatedPairs += 1 + } + scenario.assertNativeCalls(inputs.length, observed.calls) + let legacyResult + let nextResult + if (legacyFirst) { + legacyResult = runArm(scenario.legacy, inputs) + nextResult = runArm(scenario.next, inputs) + } else { + nextResult = runArm(scenario.next, inputs) + legacyResult = runArm(scenario.legacy, inputs) + } + consume(legacyResult.total) + consume(nextResult.total) + samples.legacy.push(legacyResult.elapsed / repeats) + samples.next.push(nextResult.elapsed / repeats) + } + } + return { legacy: median(samples.legacy), next: median(samples.next) } +} + +const measurementEqual = (a, b) => + a.byteLength === b.byteLength && a.exceededLimit === b.exceededLimit +const measurementChecksum = (m) => m.byteLength + (m.exceededLimit ? 1 : 0) +const booleanChecksum = (value) => (value ? 1 : 0) + +// Every scenario states which production branch it expects. Native fixtures require exactly one +// Buffer.byteLength call per input; fallback fixtures require none. +function requireBranch(expected) { + return (inputCount, calls) => { + const expectedCalls = expected === 'nativeFastPath' ? inputCount : 0 + if (calls !== expectedCalls) { + throw new Error( + `expected the production ${expected} branch (${expectedCalls} Buffer.byteLength calls), got ${calls}` + ) + } + } +} + +const batchScenario = (label, make, options = {}) => ({ + label, + make, + repeats: options.repeats, + legacy: (input) => legacyMeasure(input, { stopAfterBytes: TERMINAL_OUTPUT_BATCH_MAX_BYTES }), + next: (input) => + measureTerminalStreamByteLength(input, { + stopAfterBytes: TERMINAL_OUTPUT_BATCH_MAX_BYTES + }), + equal: measurementEqual, + checksum: measurementChecksum, + assertResult: (out, input) => options.assert?.(out, input), + assertNativeCalls: requireBranch(options.branch) +}) + +const gateScenario = (label, budget, make, options = {}) => ({ + label, + make, + repeats: options.repeats, + legacy: (input) => legacyExceeds(input, budget), + next: (input) => terminalStreamByteLengthExceeds(input, budget), + equal: (a, b) => a === b, + checksum: booleanChecksum, + assertResult: (out, input) => options.assert?.(out, input), + assertNativeCalls: requireBranch(options.branch) +}) + +const scenarios = [ + batchScenario('batcher push 8KiB', (sampleId) => makeTerminalText(8 * 1024, sampleId), { + branch: 'nativeFastPath', + assert: (out) => { + if (out.exceededLimit) { + throw new Error('batcher push 8KiB should stay under the batch budget') + } + } + }), + batchScenario( + 'batcher push over budget', + // Oversized on purpose: this is the case where the arms MUST both return the partial count. + (sampleId) => makeTerminalText(3 * TERMINAL_OUTPUT_BATCH_MAX_BYTES, sampleId), + { + branch: 'scanFallback', + assert: (out) => { + if (!out.exceededLimit) { + throw new Error('over-budget fixture never exceeded the limit') + } + } + } + ), + // TRUE WORST CASE for the batcher push. The guard only takes the native count when + // length*3 <= stopAfterBytes, which PROVES the limit cannot trip, so the fast path can never + // pay for both a Buffer.byteLength and a scan. What is left is the shape where the native + // call replaces the fewest scan iterations: a chunk sitting just above the code-unit floor. + batchScenario( + `batcher push ${MIN_NATIVE_BYTE_LENGTH_CODE_UNITS}B (floor)`, + (sampleId) => makeInteractiveText(MIN_NATIVE_BYTE_LENGTH_CODE_UNITS, sampleId), + { branch: 'nativeFastPath', repeats: 4096 } + ), + // Below the floor the new arm deliberately keeps the scan, so it is the legacy code exactly. + batchScenario('batcher push 4B keystroke', (sampleId) => makeInteractiveText(4, sampleId), { + branch: 'scanFallback', + repeats: 4096 + }), + gateScenario( + `snapshot scan ${(REQUESTED_SNAPSHOT_BYTE_BUDGET / (1024 * 1024)).toFixed(0)}MiB`, + REQUESTED_SNAPSHOT_BYTE_BUDGET, + (sampleId) => makeTerminalTextUnderBytes(REQUESTED_SNAPSHOT_BYTE_BUDGET, sampleId), + { + branch: 'nativeFastPath', + assert: (out) => { + if (out) { + throw new Error('snapshot fixture should sit under the budget so the full scan runs') + } + } + } + ), + // TRUE WORST CASE for the boolean gate: at the code-unit cap so `length > maxBytes` cannot + // short-circuit, but 3-byte scalars let the legacy scan bail out a THIRD of the way in while + // Buffer.byteLength still walks the whole string. This is where the new arm can actually lose. + gateScenario( + 'snapshot gate early-trip', + REQUESTED_SNAPSHOT_BYTE_BUDGET, + (sampleId) => makeEarlyTripText(REQUESTED_SNAPSHOT_BYTE_BUDGET, sampleId), + { + branch: 'nativeFastPath', + assert: (out, input) => { + if (!out) { + throw new Error('early-trip gate fixture must exceed the budget') + } + if (input.length > REQUESTED_SNAPSHOT_BYTE_BUDGET) { + throw new Error('early-trip fixture must not hit the code-unit short circuit') + } + } + } + ), + gateScenario( + 'chunk gate early-trip', + TERMINAL_STREAM_CHUNK_BYTES, + (sampleId) => makeEarlyTripText(TERMINAL_STREAM_CHUNK_BYTES, sampleId), + { + branch: 'nativeFastPath', + assert: (out, input) => { + if (!out) { + throw new Error('early-trip chunk fixture must exceed the budget') + } + if (input.length > TERMINAL_STREAM_CHUNK_BYTES) { + throw new Error('early-trip fixture must not hit the code-unit short circuit') + } + } + } + ), + gateScenario( + `chunk gate ${TERMINAL_STREAM_CHUNK_BYTES / 1024}KiB`, + TERMINAL_STREAM_CHUNK_BYTES, + (sampleId) => makeTerminalTextUnderBytes(TERMINAL_STREAM_CHUNK_BYTES, sampleId), + { + branch: 'nativeFastPath', + assert: (out) => { + if (out) { + throw new Error('chunk gate fixture should sit under the chunk budget') + } + } + } + ) +] + +const pad = (value, width) => String(value).padStart(width) +const formatTime = (ms) => + ms >= 0.001 ? `${(ms * 1000).toFixed(1)} us` : `${(ms * 1e6).toFixed(1)} ns` +console.log('Production terminal byte-measurement paths. Lower is better.') +console.log( + `iterations=${ITERATIONS} (${ITERATIONS * 2} counterbalanced batches/scenario, per-arm medians)` +) +console.log(`${pad('scenario', 30)} ${pad('legacy', 12)} ${pad('new', 12)} ${pad('speedup', 9)}`) +for (const scenario of scenarios) { + const { legacy, next } = runScenario(scenario) + console.log( + `${pad(scenario.label, 30)} ${pad(formatTime(legacy), 12)} ${pad(formatTime(next), 12)} ${pad(`${(legacy / next).toFixed(2)}x`, 9)}` + ) +} + +// Small-chunk sweep across real interactive PTY write sizes. The floor makes everything below +// MIN_NATIVE_BYTE_LENGTH_CODE_UNITS byte-identical to the legacy scan, so those rows must land +// at ~1.00x; anything materially below that is a regression the change would be shipping. +{ + console.log( + `\nbatcher push small-chunk sweep (stopAfterBytes=${TERMINAL_OUTPUT_BATCH_MAX_BYTES}):` + ) + console.log( + `${pad('bytes', 10)} ${pad('legacy', 12)} ${pad('new', 12)} ${pad('speedup', 9)} branch` + ) + for (const codeUnits of [4, 8, 16, 64, 256, 1024, 4096]) { + const expectedBranch = + codeUnits >= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS ? 'nativeFastPath' : 'scanFallback' + const { legacy, next } = runScenario( + batchScenario(`sweep ${codeUnits}`, (sampleId) => makeInteractiveText(codeUnits, sampleId), { + branch: expectedBranch, + repeats: Math.max(64, Math.min(4096, Math.ceil(2 ** 18 / codeUnits))) + }) + ) + const branch = expectedBranch === 'nativeFastPath' ? 'native' : 'scan (unchanged)' + console.log( + `${pad(`${codeUnits} B`, 10)} ${pad(formatTime(legacy), 12)} ${pad(formatTime(next), 12)} ${pad(`${(legacy / next).toFixed(2)}x`, 9)} ${branch}` + ) + } +} + +console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`) diff --git a/config/scripts/verify-telemetry-constants.mjs b/config/scripts/verify-telemetry-constants.mjs index 3cbac32df26..6b90c89279c 100644 --- a/config/scripts/verify-telemetry-constants.mjs +++ b/config/scripts/verify-telemetry-constants.mjs @@ -38,6 +38,7 @@ import { join, resolve } from 'node:path' // `node_modules`). If electron-builder ever drops it, promote this to a // direct devDependency in package.json. import { extractFile, listPackage } from '@electron/asar' +import { BUILD_IDENTITY_RE, WRITE_KEY_RE } from './telemetry-bundle-constant-patterns.mjs' // Why resolve from import.meta.url instead of cwd: a release runner (or a // developer debugging locally) may invoke this script from a non-root cwd. @@ -118,20 +119,8 @@ for (const m of asarMatches) { // Why these regexes: electron-vite's `define` block substitutes the bare // identifiers `ORCA_BUILD_IDENTITY` and `ORCA_POSTHOG_WRITE_KEY` with their // JSON-stringified values at build time. `src/main/telemetry/client.ts` -// then assigns those into module-local consts named `BUILD_IDENTITY` and -// `WRITE_KEY`. electron-vite's main config is not minified (Vite default for -// Electron main builds), so Rollup emits the substituted constants verbatim -// as `const BUILD_IDENTITY = "stable";`. Match that exact emitted shape so a -// regression — e.g. the env var unset and the substitution falling back to -// literal `null` — fails the grep instead of slipping through as a falsy- -// but-stringy value. NOTE: if `build.minify` is ever enabled on the main -// bundle, esbuild/terser will rename top-level consts and this regex must -// be revisited (or replaced with a value-based assertion). -// -// WRITE_KEY char class includes `_` and `-` because PostHog project API -// keys use URL-safe base64 alphabet beyond `phc_`. -const BUILD_IDENTITY_RE = /const\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/ -const WRITE_KEY_RE = /const\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/ +// then assigns those into module-local declarations named `BUILD_IDENTITY` +// and `WRITE_KEY`. Rollup may preserve `const` or lower it to `var`. function verifyAsar(asarPath) { console.log(`Verifying ${asarPath}`) diff --git a/mobile/src/notifications/local-notification-scheduling.ts b/mobile/src/notifications/local-notification-scheduling.ts new file mode 100644 index 00000000000..f511346250e --- /dev/null +++ b/mobile/src/notifications/local-notification-scheduling.ts @@ -0,0 +1,191 @@ +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' +import { loadPushNotificationsEnabled } from '../storage/preferences' +import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing' +import { ensureNotificationPermissions } from './notification-permissions' + +export type NotificationEvent = { + type: 'notification' + source: DesktopNotificationSource + title: string + body: string + worktreeId?: string + notificationId?: string + // Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it. + notificationSeq?: number + // Counter lifetime the seq belongs to (#8591); absent on older runtimes. + notificationEpoch?: string +} + +export type DismissNotificationEvent = { + type: 'dismiss' + notificationId: string + notificationSeq?: number + notificationEpoch?: string +} + +type ScheduledNotificationState = { + identifier?: string + pending?: Promise + dismissAfterSchedule?: boolean +} + +const scheduledNotificationsByHostAndNotificationId = new Map() + +// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth. +const MAX_SCHEDULED_NOTIFICATIONS = 256 +let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS + +function getStoredNotificationKey(hostId: string, notificationId: string): string { + return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}` +} + +// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest. +function boundScheduledNotifications(): void { + while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) { + let evicted = false + for (const [key, state] of scheduledNotificationsByHostAndNotificationId) { + if (!state.pending) { + scheduledNotificationsByHostAndNotificationId.delete(key) + evicted = true + break + } + } + if (!evicted) { + break + } + } +} + +/** Test-only: override the cap (pass no arg to restore the default). */ +export function setScheduledNotificationsMaxForTests(max?: number): void { + maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS +} + +export function configureNotificationChannel(): void { + if (Platform.OS === 'android') { + void Notifications.setNotificationChannelAsync('orca-desktop', { + name: 'Desktop Notifications', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250], + lightColor: '#6366f1' + }) + } +} + +export async function showLocalNotification( + event: NotificationEvent, + hostId: string +): Promise { + const storedKey = event.notificationId + ? getStoredNotificationKey(hostId, event.notificationId) + : null + + if (!storedKey) { + const enabled = await loadPushNotificationsEnabled() + if (!enabled) { + return + } + + const granted = await ensureNotificationPermissions() + if (!granted) { + return + } + + await Notifications.scheduleNotificationAsync({ + content: { + title: event.title, + body: event.body, + data: buildLocalNotificationData(event, hostId), + ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) + }, + trigger: null + }) + return + } + + let state = scheduledNotificationsByHostAndNotificationId.get(storedKey) + if (state?.pending) { + return + } + if (!state) { + state = {} + scheduledNotificationsByHostAndNotificationId.set(storedKey, state) + } + const notificationState = state + + const pending = (async () => { + const enabled = await loadPushNotificationsEnabled() + if (!enabled) { + return null + } + + const granted = await ensureNotificationPermissions() + if (!granted) { + return null + } + + if (notificationState.identifier) { + await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {}) + notificationState.identifier = undefined + } + + return Notifications.scheduleNotificationAsync({ + content: { + title: event.title, + body: event.body, + data: buildLocalNotificationData(event, hostId), + ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) + }, + trigger: null + }) + })() + notificationState.pending = pending + + try { + const scheduledIdentifier = await pending + if (!scheduledIdentifier) { + if (!notificationState.identifier) { + scheduledNotificationsByHostAndNotificationId.delete(storedKey) + } + return + } + if (notificationState.dismissAfterSchedule) { + notificationState.dismissAfterSchedule = false + scheduledNotificationsByHostAndNotificationId.delete(storedKey) + await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {}) + return + } + notificationState.identifier = scheduledIdentifier + boundScheduledNotifications() + } finally { + if (notificationState.pending === pending) { + notificationState.pending = undefined + notificationState.dismissAfterSchedule = false + } + } +} + +export async function dismissLocalNotification( + event: DismissNotificationEvent, + hostId: string +): Promise { + if (!event.notificationId) { + return + } + const storedKey = getStoredNotificationKey(hostId, event.notificationId) + const state = scheduledNotificationsByHostAndNotificationId.get(storedKey) + if (!state) { + return + } + if (state.pending) { + // Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives. + state.dismissAfterSchedule = true + return + } + if (!state.identifier) { + return + } + scheduledNotificationsByHostAndNotificationId.delete(storedKey) + await Notifications.dismissNotificationAsync(state.identifier).catch(() => {}) +} diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts index b1f7c89578e..d85b1363005 100644 --- a/mobile/src/notifications/mobile-notifications.test.ts +++ b/mobile/src/notifications/mobile-notifications.test.ts @@ -9,6 +9,7 @@ import { import AsyncStorage from '@react-native-async-storage/async-storage' import type { RpcClient } from '../transport/rpc-client' import { loadPushNotificationsEnabled } from '../storage/preferences' +import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup' vi.mock('expo-notifications', () => ({ AndroidImportance: { HIGH: 'high' }, @@ -39,6 +40,10 @@ vi.mock('../storage/preferences', () => ({ beforeEach(() => { Object.assign(Platform, { OS: 'ios', Version: 18 }) + // Why (#8591): the reconnect watermark/seen-set now live per host at module + // scope so they survive the app's unsubscribe-on-disconnect. Reset between + // tests so each case starts from a genuine cold open. + resetHostNotificationSessionsForTests() }) describe('getNotificationPermissionState', () => { @@ -68,10 +73,14 @@ describe('subscribeToDesktopNotifications', () => { vi.clearAllMocks() }) - async function flushAsync(): Promise { - for (let i = 0; i < 10; i += 1) { - await Promise.resolve() - } + // Why the macrotask and not N microtask ticks (#8591): deliveries now run through + // the per-host serialization queue, so a delivery is several more `await` hops deep + // than it used to be and a fixed tick count silently under-drains. Yielding to the + // macrotask queue drains whatever depth the chain happens to have. + function flushAsync(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0) + }) } function makeDeferred(): { promise: Promise; resolve: (value: T) => void } { @@ -489,6 +498,120 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => { expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1) }) + it('voids a persisted watermark whose epoch predates a desktop restart', async () => { + // #8591: the desktop's seq counter restarts at 0 each launch while this watermark + // is persisted. Reconnecting to a restarted desktop with seq 57 would make + // `57 >= 2` true and silently kill catch-up. The epoch on 'ready' is what tells + // the client the counter changed, so the stale watermark must be dropped. + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => + key.startsWith('orca:mobileNotificationsWatermark:') + ? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' }) + : null + ) + + const sub = makeClient() + subscribeToDesktopNotifications(sub.client, 'host-1') + // Cold open under the OLD desktop process, so the watermark loads as 57. + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-before-restart' }) + await flushAsync() + await flushAsync() + + // Desktop restarts: new epoch, counter back near 0. + sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' }) + await flushAsync() + await flushAsync() + + const missedCalls = vi + .mocked(sub.client.sendRequest) + .mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince') + // The cold open catches up from its stored watermark against the SAME counter — + // 57 is meaningful there, so it is the correct cut (#8591 second pass). + expect(missedCalls[0]?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-before-restart' }) + // After the restart the watermark is reset to 0 and tagged with the live epoch — + // not the stale 57, which would make `57 >= 2` true and kill catch-up silently. + expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-after-restart' }) + }) + + it('refuses to seed a stored watermark that lost the race to a newer live epoch', async () => { + // The seed read is deliberately not awaited (so subscribe doesn't block on + // AsyncStorage), which means it can land AFTER 'ready' already adopted the live + // epoch. If it seeds unconditionally it reinstates the exact stale cut #8591 is + // about — the reset having already happened doesn't help, because the seed runs + // last and wins. Only a stored epoch matching the live one may seed. + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') + + // Hold the storage read open so 'ready' is guaranteed to be processed first. + let releaseStorage: () => void = () => {} + const storageGate = new Promise((resolve) => { + releaseStorage = resolve + }) + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => { + await storageGate + return key.startsWith('orca:mobileNotificationsWatermark:') + ? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' }) + : null + }) + + const sub = makeClient() + subscribeToDesktopNotifications(sub.client, 'host-1') + // Live epoch adopted while the stored one is still in flight. + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-after-restart' }) + await flushAsync() + + releaseStorage() + await flushAsync() + + sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' }) + await flushAsync() + await flushAsync() + + const missedCall = vi + .mocked(sub.client.sendRequest) + .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') + expect(missedCall?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-after-restart' }) + }) + + it('keeps the persisted watermark when the desktop epoch is unchanged', async () => { + // The reset must be narrow: a plain socket reap with the same desktop process + // still has to send the real watermark, or every reconnect re-pushes the buffer. + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1') + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => + key.startsWith('orca:mobileNotificationsWatermark:') + ? JSON.stringify({ seq: 57, epoch: 'epoch-stable' }) + : null + ) + + const sub = makeClient() + subscribeToDesktopNotifications(sub.client, 'host-1') + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-stable' }) + await flushAsync() + await flushAsync() + sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-stable' }) + await flushAsync() + await flushAsync() + + const missedCall = vi + .mocked(sub.client.sendRequest) + .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') + expect(missedCall?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-stable' }) + }) + it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => { vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ @@ -577,8 +700,8 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => { await flushAsync() expect(AsyncStorageMock.setItem).toHaveBeenCalledWith( - 'orca:mobileNotificationsLastSeq:host-1', - '5' + 'orca:mobileNotificationsWatermark:host-1', + JSON.stringify({ seq: 5, epoch: null }) ) }) @@ -627,8 +750,8 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => { // Watermark advanced to the replayed seq and was persisted. expect(AsyncStorageMock.setItem).toHaveBeenCalledWith( - 'orca:mobileNotificationsLastSeq:host-1', - '8' + 'orca:mobileNotificationsWatermark:host-1', + JSON.stringify({ seq: 8, epoch: null }) ) // Second reconnect resumes from the advanced watermark, not 0. @@ -639,4 +762,208 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => { .mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince') expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 }) }) + + it('replays a terminal bell at a seq the previous desktop counter already used', async () => { + // Round-1 review finding: seen-keys are seq-derived, and terminal bells carry no + // notificationId (they key on `seq:N` alone). Epoch A delivers a bell at seq 1; + // after a restart, epoch B's first bell is ALSO seq 1. The catch-up path is the + // one that consults the seen-set, so without clearing it on epoch change the + // replayed post-restart bell is mistaken for a duplicate and silently skipped — + // #8591's silent loss again, now one notification at a time. + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') + + const sub = makeClient() + // Catch-up returns epoch B's first bell — same seq 1 the old counter used. + sub.client.sendRequest = vi.fn(async (method: string) => { + if (method === 'notifications.getMissedSince') { + return { + ok: true, + result: { + epoch: 'epoch-B', + notifications: [{ type: 'notification', title: 'bell', body: 'B', notificationSeq: 1 }] + } + } as never + } + return { ok: true, result: undefined } as never + }) + + subscribeToDesktopNotifications(sub.client, 'host-1') + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' }) + await flushAsync() + // A live bell under epoch A — no notificationId, so its seen-key is `seq:1`. + sub.onData?.({ type: 'notification', title: 'bell', body: 'A', notificationSeq: 1 }) + await flushAsync() + expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1) + + // Desktop restarts; reconnect triggers catch-up against the fresh counter. + sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-B' }) + await flushAsync() + await flushAsync() + + // The post-restart bell must reach the user, not be swallowed as a stale `seq:1`. + expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(2) + }) + + it('does not trust a legacy epoch-less watermark against a live counter', async () => { + // Round-1 review finding: pre-upgrade installs stored a bare seq with no epoch. + // Seeding it and then treating the first observed epoch as "nothing changed" + // leaves 57 cutting a counter it was never measured against — #8591 reached + // through the upgrade path. An unprovenanced seq may not survive epoch adoption. + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') + // Only the LEGACY key exists — exactly what an upgrading install has on disk. + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => + key.startsWith('orca:mobileNotificationsLastSeq:') ? '57' : null + ) + + const sub = makeClient() + subscribeToDesktopNotifications(sub.client, 'host-1') + // Seed lands FIRST (no epoch known yet), so 57 is provisionally adopted... + await flushAsync() + await flushAsync() + // ...then the live epoch arrives for the first time. + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' }) + await flushAsync() + sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-live' }) + await flushAsync() + await flushAsync() + + const missedCall = vi + .mocked(sub.client.sendRequest) + .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') + // Must not be 57: that seq was never shown to belong to this counter. + expect(missedCall?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-live' }) + }) + + it('catches up on the FIRST connection after an upgrade, without a second ready', async () => { + // Round-2 review finding: catch-up hung off `connectedBefore`, which is false on + // the first 'ready' of a process. So a cold app open — post-upgrade, or after the + // OS evicted the app — adopted the epoch but never replayed. Everything between + // the stored watermark and the next live seq was then lost permanently, because + // the first live event advances the watermark past the gap. + // + // The earlier migration test masked this by emitting a SECOND 'ready'. This one + // emits exactly one, which is what a real cold open does. + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => + key.startsWith('orca:mobileNotificationsWatermark:') + ? JSON.stringify({ seq: 57, epoch: 'epoch-live' }) + : null + ) + + const sub = makeClient() + vi.mocked(sub.client.sendRequest).mockImplementation(async (method: string) => + method === 'notifications.getMissedSince' + ? { + ok: true, + result: { + epoch: 'epoch-live', + notifications: [ + { + type: 'notification', + notificationId: 'missed-58', + notificationSeq: 58, + notificationEpoch: 'epoch-live', + title: 'while the app was closed', + body: 'b' + } + ] + } + } + : { ok: true, result: {} } + ) + subscribeToDesktopNotifications(sub.client, 'host-1') + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' }) + await flushAsync() + await flushAsync() + await flushAsync() + + const missedCall = vi + .mocked(sub.client.sendRequest) + .mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince') + // The single 'ready' must replay from the stored watermark, not skip it. + expect(missedCall?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-live' }) + // And the missed notification must actually reach the user. + expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1) + }) + + it('does not replay the desktop buffer at a first-ever pairing', async () => { + // The other side of the finding above: with nothing stored, this device has never + // delivered for this host. Catching up would push the whole retained buffer at a + // user who was never subscribed for any of it. + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + + const sub = makeClient() + subscribeToDesktopNotifications(sub.client, 'host-1') + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' }) + await flushAsync() + await flushAsync() + await flushAsync() + + expect( + vi + .mocked(sub.client.sendRequest) + .mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince') + ).toHaveLength(0) + }) + + it('persists seq and epoch as one value so a crash cannot split the pair', async () => { + // Round-1 review finding: written as two keys, a process death between the writes + // leaves epoch-B beside seq-57-from-A. That pair looks internally valid on the + // next launch and is therefore trusted — silently cutting B's first 57 events. + // One key means the pair is always written whole or not at all. + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s') + + const sub = makeClient() + subscribeToDesktopNotifications(sub.client, 'host-1') + sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' }) + await flushAsync() + sub.onData?.({ + type: 'notification', + title: 't', + body: 'b', + notificationId: 'agent:x', + notificationSeq: 9 + }) + await flushAsync() + + // Every watermark write is a single key carrying both halves together. + const watermarkWrites = AsyncStorageMock.setItem.mock.calls.filter((c: unknown[]) => + String(c[0]).startsWith('orca:mobileNotifications') + ) + expect(watermarkWrites.length).toBeGreaterThan(0) + for (const [key, value] of watermarkWrites) { + expect(key).toBe('orca:mobileNotificationsWatermark:host-1') + expect(JSON.parse(String(value))).toHaveProperty('epoch') + expect(JSON.parse(String(value))).toHaveProperty('seq') + } + expect(JSON.parse(String(watermarkWrites.at(-1)?.[1]))).toEqual({ + seq: 9, + epoch: 'epoch-A' + }) + }) }) diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 7e1444d9b0c..c451fff3a3e 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -1,228 +1,34 @@ -import * as Notifications from 'expo-notifications' -import { Platform } from 'react-native' import type { RpcClient } from '../transport/rpc-client' -import { loadPushNotificationsEnabled } from '../storage/preferences' -import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing' +// Re-exported so the existing importers (and their vi.mock paths) keep working. +export { + ensureNotificationPermissions, + getNotificationPermissionState, + type NotificationPermissionState +} from './notification-permissions' +export { setScheduledNotificationsMaxForTests } from './local-notification-scheduling' import { - createSeenNotificationGuard, - loadLastSeenSeq, - saveLastSeenSeq, - seenKeyForEvent + configureNotificationChannel, + dismissLocalNotification, + showLocalNotification, + type DismissNotificationEvent, + type NotificationEvent +} from './local-notification-scheduling' +import { + adoptNotificationEpoch, + enqueueHostDelivery, + getHostNotificationSession, + releaseQueuedShowNotificationId, + saveWatermark, + seedWatermarkFromStorage, + seenKeyForEvent, + shouldQueueShowForNotificationId } from './notification-reconnect-catchup' -type NotificationEvent = { - type: 'notification' - source: DesktopNotificationSource - title: string - body: string - worktreeId?: string - notificationId?: string - // Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it. - notificationSeq?: number -} - -type DismissNotificationEvent = { - type: 'dismiss' - notificationId: string - notificationSeq?: number -} - type SubscribeResult = { type: 'ready' subscriptionId: string -} - -type ScheduledNotificationState = { - identifier?: string - pending?: Promise - dismissAfterSchedule?: boolean -} - -const scheduledNotificationsByHostAndNotificationId = new Map() - -// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth. -const MAX_SCHEDULED_NOTIFICATIONS = 256 -let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS - -function getStoredNotificationKey(hostId: string, notificationId: string): string { - return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}` -} - -// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest. -function boundScheduledNotifications(): void { - while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) { - let evicted = false - for (const [key, state] of scheduledNotificationsByHostAndNotificationId) { - if (!state.pending) { - scheduledNotificationsByHostAndNotificationId.delete(key) - evicted = true - break - } - } - if (!evicted) { - break - } - } -} - -/** Test-only: override the cap (pass no arg to restore the default). */ -export function setScheduledNotificationsMaxForTests(max?: number): void { - maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS -} - -export type NotificationPermissionState = { - granted: boolean - status: string - canAskAgain: boolean - authorizationReflectsUserChoice: boolean -} - -export async function getNotificationPermissionState(): Promise { - const { status, canAskAgain } = await Notifications.getPermissionsAsync() - return { - granted: status === 'granted', - status, - canAskAgain, - // Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent. - authorizationReflectsUserChoice: - status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33) - } -} - -// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded. -export async function ensureNotificationPermissions(): Promise { - const existing = await getNotificationPermissionState() - if (existing.granted) { - return true - } - - const { status } = await Notifications.requestPermissionsAsync() - return status === 'granted' -} - -function configureNotificationChannel(): void { - if (Platform.OS === 'android') { - void Notifications.setNotificationChannelAsync('orca-desktop', { - name: 'Desktop Notifications', - importance: Notifications.AndroidImportance.HIGH, - vibrationPattern: [0, 250], - lightColor: '#6366f1' - }) - } -} - -async function showLocalNotification(event: NotificationEvent, hostId: string): Promise { - const storedKey = event.notificationId - ? getStoredNotificationKey(hostId, event.notificationId) - : null - - if (!storedKey) { - const enabled = await loadPushNotificationsEnabled() - if (!enabled) { - return - } - - const granted = await ensureNotificationPermissions() - if (!granted) { - return - } - - await Notifications.scheduleNotificationAsync({ - content: { - title: event.title, - body: event.body, - data: buildLocalNotificationData(event, hostId), - ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) - }, - trigger: null - }) - return - } - - let state = scheduledNotificationsByHostAndNotificationId.get(storedKey) - if (state?.pending) { - return - } - if (!state) { - state = {} - scheduledNotificationsByHostAndNotificationId.set(storedKey, state) - } - const notificationState = state - - const pending = (async () => { - const enabled = await loadPushNotificationsEnabled() - if (!enabled) { - return null - } - - const granted = await ensureNotificationPermissions() - if (!granted) { - return null - } - - if (notificationState.identifier) { - await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {}) - notificationState.identifier = undefined - } - - return Notifications.scheduleNotificationAsync({ - content: { - title: event.title, - body: event.body, - data: buildLocalNotificationData(event, hostId), - ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) - }, - trigger: null - }) - })() - notificationState.pending = pending - - try { - const scheduledIdentifier = await pending - if (!scheduledIdentifier) { - if (!notificationState.identifier) { - scheduledNotificationsByHostAndNotificationId.delete(storedKey) - } - return - } - if (notificationState.dismissAfterSchedule) { - notificationState.dismissAfterSchedule = false - scheduledNotificationsByHostAndNotificationId.delete(storedKey) - await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {}) - return - } - notificationState.identifier = scheduledIdentifier - boundScheduledNotifications() - } finally { - if (notificationState.pending === pending) { - notificationState.pending = undefined - notificationState.dismissAfterSchedule = false - } - } -} - -async function dismissLocalNotification( - event: DismissNotificationEvent, - hostId: string -): Promise { - if (!event.notificationId) { - return - } - const storedKey = getStoredNotificationKey(hostId, event.notificationId) - const state = scheduledNotificationsByHostAndNotificationId.get(storedKey) - if (!state) { - return - } - if (state.pending) { - // Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives. - state.dismissAfterSchedule = true - return - } - if (!state.identifier) { - return - } - scheduledNotificationsByHostAndNotificationId.delete(storedKey) - await Notifications.dismissNotificationAsync(state.identifier).catch(() => {}) + // Desktop counter lifetime (#8591); absent from runtimes that predate it. + epoch?: string } // Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent. @@ -231,68 +37,129 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin let subscriptionId: string | null = null let disposed = false - // Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut. - let lastDeliveredSeq = 0 - // Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary. - const seenReplay = createSeenNotificationGuard() + // Why (#8591): survives the unsubscribe/resubscribe the app performs on every + // socket drop, so a reconnect still knows its watermark and that it reconnected. + const session = getHostNotificationSession(hostId) - function deliverLive( + /** + * Queue one delivery on the host chain, dropping a show whose notificationId + * already has one queued. + * + * Why the claim is taken HERE and not inside deliverLive (#8591): the point of + * the dedup is to notice a second event arriving while the first is still + * outstanding. Inside the queued task the first has already finished, so the + * overlap is no longer observable — it has to be checked before enqueueing. + */ + function queueDelivery( type: 'notification' | 'dismiss', event: NotificationEvent | DismissNotificationEvent ): Promise { - if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) { - lastDeliveredSeq = event.notificationSeq - void saveLastSeenSeq(hostId, lastDeliveredSeq) + if ( + type === 'notification' && + !shouldQueueShowForNotificationId(session, event.notificationId) + ) { + return Promise.resolve() } + return enqueueHostDelivery(session, async () => { + try { + await deliverLive(type, event) + } finally { + if (type === 'notification') { + releaseQueuedShowNotificationId(session, event.notificationId) + } + } + }) + } + + async function deliverLive( + type: 'notification' | 'dismiss', + event: NotificationEvent | DismissNotificationEvent + ): Promise { + adoptNotificationEpoch(session, hostId, event.notificationEpoch) // Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing. const key = seenKeyForEvent(event) if (key) { - seenReplay.add(key) + session.seen.add(key) } if (type === 'notification') { - return showLocalNotification(event as NotificationEvent, hostId) + await showLocalNotification(event as NotificationEvent, hostId) + } else { + await dismissLocalNotification(event as DismissNotificationEvent, hostId) + } + // Why after the await (#8591): the watermark is a promise that everything up + // to this seq has been shown. Advancing it before the local notification lands + // means a process death in between silently drops it — the next launch asks the + // desktop for seq greater than one the user never saw. + if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) { + session.lastDeliveredSeq = event.notificationSeq + // Persisted as a pair: a seq is only trustworthy alongside the epoch it indexes. + void saveWatermark(hostId, { + seq: session.lastDeliveredSeq, + epoch: session.lastDeliveredEpoch + }) } - return dismissLocalNotification(event as DismissNotificationEvent, hostId) } - // Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap). + // Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap). async function fetchMissed(): Promise { if (disposed) { return } const missed = await client - .sendRequest('notifications.getMissedSince', { lastSeenSeq: lastDeliveredSeq }) + .sendRequest('notifications.getMissedSince', { + lastSeenSeq: session.lastDeliveredSeq, + // Why: sending the epoch lets the desktop reject a watermark from a counter + // it no longer has and return the whole retained buffer instead of nothing. + ...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {}) + }) .then((response) => { if (!response.ok) { return [] } - const result = response.result as { notifications?: unknown[] } | undefined + const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined + adoptNotificationEpoch(session, hostId, result?.epoch) return Array.isArray(result?.notifications) ? result.notifications : [] }) .catch(() => []) - for (const raw of missed) { - const event = raw as NotificationEvent | DismissNotificationEvent - const key = seenKeyForEvent(event) - if (key && seenReplay.has(key)) { - continue + // Why the whole batch is ONE queue entry (#8591): awaiting per event returns to + // the event loop between replays, so a live seq 11 slots into the chain between + // seq 6 and 7 and persists a watermark past a notification still unshown. Why the + // request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the + // chain for that would stall live delivery on a slow link. + await enqueueHostDelivery(session, async () => { + for (const raw of missed) { + // Re-checked per event: the batch can start before a teardown and still be + // draining after it, and a torn-down host must stop pushing. + if (disposed) { + return + } + const event = raw as NotificationEvent | DismissNotificationEvent + const key = seenKeyForEvent(event) + if (key && session.seen.has(key)) { + continue + } + if (key) { + session.seen.add(key) + } + // Claimed inline rather than via queueDelivery: the batch is already one + // queue entry, and re-enqueueing per item is what let a live event cut in. + if (event.type === 'notification') { + if (!shouldQueueShowForNotificationId(session, event.notificationId)) { + continue + } + try { + await deliverLive('notification', event) + } finally { + releaseQueuedShowNotificationId(session, event.notificationId) + } + } else if (event.type === 'dismiss') { + await deliverLive('dismiss', event) + } } - if (key) { - seenReplay.add(key) - } - if (event.type === 'notification') { - await deliverLive('notification', event) - } else if (event.type === 'dismiss') { - await deliverLive('dismiss', event) - } - } + }) } - // Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read. - let watermarkLoaded = false - void loadLastSeenSeq(hostId).then((seq) => { - lastDeliveredSeq = Math.max(lastDeliveredSeq, seq) - watermarkLoaded = true - }) + seedWatermarkFromStorage(session, hostId) function unsubscribeServer(id: string) { if (client.getState() === 'connected') { @@ -300,7 +167,6 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin } } - let reconnectReadyCount = 0 const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => { const event = data as | NotificationEvent @@ -309,16 +175,34 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin | { type: 'end' } if (event.type === 'ready') { subscriptionId = (event as SubscribeResult).subscriptionId - reconnectReadyCount += 1 + const isReconnect = session.connectedBefore + session.connectedBefore = true if (disposed) { unsubscribeServer(subscriptionId) unsubscribeStream() return } - // Why: only reconnects fetch missed; watermarkLoaded guards against fetching from a stale 0 (which re-pushes everything). - if (reconnectReadyCount > 1 && watermarkLoaded) { - void fetchMissed() - } + const readyEpoch = (event as SubscribeResult).epoch + // Why (#8591) the await: on a cold app open the persisted read is still in + // flight, so deciding here would see watermarkLoaded false and skip catch-up — + // which is precisely the post-upgrade / post-process-death case that loses + // every notification between the stored watermark and the next live seq. + void (async () => { + await session.watermarkSeeded + if (disposed) { + return + } + // Why before fetchMissed: adopting the epoch here is what voids a watermark + // left over from a previous desktop lifetime, so the catch-up request carries + // a watermark that means something against the counter now answering it. + adoptNotificationEpoch(session, hostId, readyEpoch) + // A reconnect always catches up. A cold open catches up only when this device + // has delivered for this host before — a first-ever pairing must not be handed + // the desktop's whole retained buffer. + if (isReconnect || session.hadStoredWatermark) { + await fetchMissed() + } + })() return } if (event.type === 'end') { @@ -330,11 +214,27 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin if (disposed) { return } - if (event.type === 'notification') { - void deliverLive('notification', event as NotificationEvent) - } else if (event.type === 'dismiss') { - void deliverLive('dismiss', event as DismissNotificationEvent) + if (event.type !== 'notification' && event.type !== 'dismiss') { + return } + // Why the await (#8591): deliverLive advances the watermark. A live event landing + // while the persisted read is still in flight would push it past the buffered seqs + // the catch-up is about to ask for, and getMissedSince would cut them. Ordering is + // preserved — every handler waits on the same promise, and the 'ready' continuation + // registered on it first, so catch-up still builds its request before any live seq. + const liveEvent = event + void (async () => { + await session.watermarkSeeded + if (disposed) { + return + } + // Why the queue (#8591): a live event must not overtake an in-flight + // catch-up replay, or it persists a watermark past seqs still unshown. + await queueDelivery( + liveEvent.type === 'notification' ? 'notification' : 'dismiss', + liveEvent as NotificationEvent | DismissNotificationEvent + ) + })() }) return () => { diff --git a/mobile/src/notifications/notification-delivery-ordering.test.ts b/mobile/src/notifications/notification-delivery-ordering.test.ts new file mode 100644 index 00000000000..68d64d7b3de --- /dev/null +++ b/mobile/src/notifications/notification-delivery-ordering.test.ts @@ -0,0 +1,248 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { subscribeToDesktopNotifications } from './mobile-notifications' +import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup' +import type { RpcClient } from '../transport/rpc-client' +import { loadPushNotificationsEnabled } from '../storage/preferences' + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { HIGH: 'high' }, + setNotificationChannelAsync: vi.fn(), + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios', Version: 18 } +})) + +const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1' +const storage = new Map() +let getItemImpl: (key: string) => Promise = async (key) => storage.get(key) ?? null + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn((key: string) => getItemImpl(key)), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value) + }) + } +})) + +vi.mock('../storage/preferences', () => ({ + loadPushNotificationsEnabled: vi.fn() +})) + +function flushAsync(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 10) + }) +} + +function persistedSeq(): number { + return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0 +} + +describe('#8591 per-host delivery ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + storage.clear() + getItemImpl = async (key) => storage.get(key) ?? null + resetHostNotificationSessionsForTests() + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1') + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) + }) + + it('never persists a watermark past a notification the catch-up has not shown', async () => { + // The watermark is a promise that everything up to that seq reached the user. + // If a live seq 11 is processed while catch-up is still showing seq 6, it + // persists 11 — and a process death before 7 is shown loses 7 forever, because + // the next launch asks the desktop for seq > 11. That is the original #8591 + // loss re-entered through concurrency rather than through a restarted counter. + let releaseFirstShow!: () => void + const firstShowBlocked = new Promise((resolve) => { + releaseFirstShow = resolve + }) + let shown = 0 + vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => { + shown += 1 + if (shown === 1) { + await firstShowBlocked + } + return 'sched-1' + }) + + let onData: ((data: unknown) => void) | null = null + const client = { + subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { + onData = cb + return vi.fn() + }), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn(async (method: string) => { + if (method === 'notifications.getMissedSince') { + return { + ok: true, + result: { + notifications: [ + { + type: 'notification', + title: 'm6', + body: 'b', + notificationId: 'a:6', + notificationSeq: 6 + }, + { + type: 'notification', + title: 'm7', + body: 'b', + notificationId: 'a:7', + notificationSeq: 7 + } + ] + } + } as never + } + return { ok: true, result: undefined } as never + }) + } as unknown as RpcClient + + storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) + subscribeToDesktopNotifications(client, 'host-1') + onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) + await flushAsync() + + // Live seq 11 arrives while the replay is wedged on seq 6. + onData?.({ + type: 'notification', + title: 'live-11', + body: 'b', + notificationId: 'a:11', + notificationSeq: 11 + }) + await flushAsync() + + expect(persistedSeq()).toBeLessThan(6) + + releaseFirstShow() + await flushAsync() + + // Once the chain drains, everything is shown and the watermark catches up. + expect(persistedSeq()).toBe(11) + const titles = vi + .mocked(Notifications.scheduleNotificationAsync) + .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) + expect(titles).toEqual(['m6', 'm7', 'live-11']) + }) + + it('shows one banner when a replay and a live event carry the same notification id', async () => { + // Serializing deliveries removed the overlap the old dedup relied on: the + // replay's show now COMPLETES before the live duplicate starts, so nothing is + // pending for it to observe and the user gets the same notification twice. + let releaseFirstShow!: () => void + const firstShowBlocked = new Promise((resolve) => { + releaseFirstShow = resolve + }) + let shown = 0 + vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => { + shown += 1 + if (shown === 1) { + await firstShowBlocked + } + return `sched-${shown}` + }) + + let onData: ((data: unknown) => void) | null = null + const client = { + subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { + onData = cb + return vi.fn() + }), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn(async (method: string) => { + if (method === 'notifications.getMissedSince') { + return { + ok: true, + result: { + notifications: [ + { + type: 'notification', + title: 'dup', + body: 'b', + notificationId: 'agent:dup', + notificationSeq: 6 + } + ] + } + } as never + } + return { ok: true, result: undefined } as never + }) + } as unknown as RpcClient + + storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' })) + subscribeToDesktopNotifications(client, 'host-1') + onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) + await flushAsync() + + // Same id arrives live while the replay's show is still blocked. A different + // seq, so the seen-set does not catch it — only the queued-show claim does. + onData?.({ + type: 'notification', + title: 'dup', + body: 'b', + notificationId: 'agent:dup', + notificationSeq: 7 + }) + await flushAsync() + + releaseFirstShow() + await flushAsync() + + expect(vi.mocked(Notifications.scheduleNotificationAsync)).toHaveBeenCalledTimes(1) + }) + + it('still delivers when the persisted watermark read never resolves', async () => { + // Every delivery awaits the seed, so a wedged AsyncStorage read would disable + // this host's notifications for the whole app lifetime — silently. + getItemImpl = () => new Promise(() => {}) + + let onData: ((data: unknown) => void) | null = null + const client = { + subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { + onData = cb + return vi.fn() + }), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn(async () => ({ ok: true, result: undefined }) as never) + } as unknown as RpcClient + + vi.useFakeTimers() + try { + subscribeToDesktopNotifications(client, 'host-1') + onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' }) + onData?.({ + type: 'notification', + title: 'live-1', + body: 'b', + notificationId: 'a:1', + notificationSeq: 1 + }) + await vi.advanceTimersByTimeAsync(3100) + } finally { + vi.useRealTimers() + } + + const titles = vi + .mocked(Notifications.scheduleNotificationAsync) + .mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title) + expect(titles).toContain('live-1') + }) +}) diff --git a/mobile/src/notifications/notification-permissions.ts b/mobile/src/notifications/notification-permissions.ts new file mode 100644 index 00000000000..1266cda3aec --- /dev/null +++ b/mobile/src/notifications/notification-permissions.ts @@ -0,0 +1,36 @@ +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' + +// Why: OS notification-permission state, separate from the delivery pipeline in +// mobile-notifications.ts. Nothing here touches sockets, watermarks, or the +// scheduled-push registry — it only reflects what the OS currently allows. + +export type NotificationPermissionState = { + granted: boolean + status: string + canAskAgain: boolean + authorizationReflectsUserChoice: boolean +} + +export async function getNotificationPermissionState(): Promise { + const { status, canAskAgain } = await Notifications.getPermissionsAsync() + return { + granted: status === 'granted', + status, + canAskAgain, + // Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent. + authorizationReflectsUserChoice: + status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33) + } +} + +// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded. +export async function ensureNotificationPermissions(): Promise { + const existing = await getNotificationPermissionState() + if (existing.granted) { + return true + } + + const { status } = await Notifications.requestPermissionsAsync() + return status === 'granted' +} diff --git a/mobile/src/notifications/notification-reconnect-catchup.ts b/mobile/src/notifications/notification-reconnect-catchup.ts index d40af93a090..4b657f23e26 100644 --- a/mobile/src/notifications/notification-reconnect-catchup.ts +++ b/mobile/src/notifications/notification-reconnect-catchup.ts @@ -9,28 +9,73 @@ import AsyncStorage from '@react-native-async-storage/async-storage' // notification we already delivered. The in-memory seen-set is a second guard // against double-delivery for events that arrive on both the live stream and a // replay (e.g. a brief liveness spell before a reap). -const LAST_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:' +// Why (#8591): a seq is meaningless without the counter it indexes — after a +// desktop restart that counter is gone. The epoch names the counter's lifetime so +// a reconnect can tell "nothing missed" from "different counter". +// +// Why ONE key holding both, rather than a key each: they are only meaningful as a +// pair. Written separately, a process death between the two writes leaves an epoch +// from one counter beside a seq from another — a pair that looks internally valid +// on the next launch and is therefore trusted, silently cutting real notifications. +// A single JSON value cannot tear that way. +const WATERMARK_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsWatermark:' +// Pre-#8591 installs wrote the seq alone. Read once to migrate; never written. +const LEGACY_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:' -function lastSeqStorageKey(hostId: string): string { - return LAST_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId) +function watermarkStorageKey(hostId: string): string { + return WATERMARK_STORAGE_KEY_PREFIX + encodeURIComponent(hostId) } -export async function loadLastSeenSeq(hostId: string): Promise { +// A null epoch means "the counter this seq came from is unknown" — a legacy +// watermark, or nothing stored. It can never be assumed to be the live counter. +export type PersistedWatermark = { seq: number; epoch: string | null } +// `stored` is the record's existence, independent of its seq: it answers "has this +// device ever been subscribed to this host", which is what a cold open needs to tell +// a returning device from a first pairing. A seq of 0 is a real answer, not an absence. +export type LoadedWatermark = PersistedWatermark & { stored: boolean } + +function coerceSeq(value: unknown): number { + const parsed = typeof value === 'number' ? value : Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 +} + +export async function loadWatermark(hostId: string): Promise { try { - const raw = await AsyncStorage.getItem(lastSeqStorageKey(hostId)) - const parsed = raw == null ? 0 : Number(raw) - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 + const raw = await AsyncStorage.getItem(watermarkStorageKey(hostId)) + if (raw != null) { + const parsed = JSON.parse(raw) as { seq?: unknown; epoch?: unknown } + const epoch = + typeof parsed.epoch === 'string' && parsed.epoch.length > 0 ? parsed.epoch : null + return { seq: coerceSeq(parsed.seq), epoch, stored: true } + } } catch { - return 0 + // Unreadable or malformed: fall through to the legacy key rather than throw. + } + try { + const legacy = await AsyncStorage.getItem( + LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId) + ) + return { seq: coerceSeq(legacy), epoch: null, stored: legacy != null } + } catch { + return { seq: 0, epoch: null, stored: false } } } -export async function saveLastSeenSeq(hostId: string, seq: number): Promise { - if (!Number.isFinite(seq) || seq <= 0) { - return - } +export async function clearWatermark(hostId: string): Promise { + // Why both keys: loadWatermark falls back to the legacy one, so removing only the + // current key would let a re-paired host resurrect a pre-#8591 seq from a counter + // lifetime that is long gone — the exact stale cut this fix removes. + await Promise.all([ + AsyncStorage.removeItem(watermarkStorageKey(hostId)).catch(() => {}), + AsyncStorage.removeItem(LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)).catch( + () => {} + ) + ]) +} + +export async function saveWatermark(hostId: string, watermark: PersistedWatermark): Promise { try { - await AsyncStorage.setItem(lastSeqStorageKey(hostId), String(seq)) + await AsyncStorage.setItem(watermarkStorageKey(hostId), JSON.stringify(watermark)) } catch { // Why: persisting the watermark is best-effort. If it fails (or lags), the // stored value stays BELOW what we delivered, so a later cold start can @@ -53,6 +98,7 @@ const RECENTLY_SEEN_CAP = 512 export function createSeenNotificationGuard(): { has: (id: string) => boolean add: (id: string) => void + clear: () => void } { const seen = new Set() return { @@ -69,10 +115,224 @@ export function createSeenNotificationGuard(): { seen.delete(first) } } + }, + clear(): void { + seen.clear() } } } +// Why (#8591): app/index.tsx tears the notification subscription down on every +// non-'connected' state and builds a fresh one on reconnect, so everything held +// in the subscription closure — the ready counter, the delivered watermark, the +// seen-set — is destroyed exactly when a reconnect needs it. Keeping it per host +// at module scope is what makes the catch-up recognise a reconnect (instead of +// mistaking it for a cold open) and keeps dedup effective across the teardown. +export type HostNotificationSession = { + // Highest desktop seq delivered for this host in this app process. Outranks + // the persisted value, which lags because saveLastSeenSeq is fire-and-forget. + lastDeliveredSeq: number + // Counter lifetime lastDeliveredSeq belongs to; null until one is known. A + // mismatch on reconnect means the desktop restarted and the watermark is void. + lastDeliveredEpoch: string | null + seen: ReturnType + // False only until the host's first subscription reaches 'ready' — a true cold open. + connectedBefore: boolean + // Why (#8591): distinguishes "this device has delivered for this host before" + // from a first-ever pairing. Only the former may catch up on a cold open — a + // brand-new pairing fetching from seq 0 would push the desktop's whole buffer + // at someone who was never subscribed for any of it. + hadStoredWatermark: boolean + // Resolves once the persisted read has landed, so the first 'ready' can wait for + // it instead of deciding catch-up against an unread watermark. + watermarkSeeded: Promise | null + // Tail of the per-host delivery chain; see enqueueHostDelivery. + deliveryTail: Promise + // notificationIds with a show queued or in flight on that chain; see + // shouldQueueShowForNotificationId. + queuedShowIds: Set +} + +const sessionsByHost = new Map() + +export function getHostNotificationSession(hostId: string): HostNotificationSession { + let session = sessionsByHost.get(hostId) + if (!session) { + session = { + lastDeliveredSeq: 0, + lastDeliveredEpoch: null, + seen: createSeenNotificationGuard(), + connectedBefore: false, + hadStoredWatermark: false, + watermarkSeeded: null, + deliveryTail: Promise.resolve(), + queuedShowIds: new Set() + } + sessionsByHost.set(hostId, session) + } + return session +} + +/** + * Run `task` after every delivery already queued for this host, and return a + * promise for its completion. + * + * Why (#8591): the watermark is persisted by whichever delivery advances it, so + * replay and live delivery running concurrently can persist out of order. A live + * seq 11 handled while catch-up is still showing seq 6 writes watermark 11, and a + * process death before 7..10 are shown loses them permanently — the next launch + * asks the desktop for seq > 11. Serializing per host makes the watermark's + * monotonic advance mean "everything up to here was actually delivered". + * + * A rejected task does not break the chain: the tail swallows the failure so a + * single bad notification cannot wedge the host's queue forever. + */ +export function enqueueHostDelivery( + session: HostNotificationSession, + task: () => Promise +): Promise { + const run = session.deliveryTail.then(task) + session.deliveryTail = run.catch(() => {}) + return run +} + +/** + * Claim a notificationId for a queued show, returning false if one is already + * queued or in flight for it. + * + * Why this exists (#8591): showLocalNotification deduped two same-id events by + * observing that the first was still pending when the second arrived. Serializing + * deliveries removed that overlap — the first now COMPLETES before the second + * starts, so the second reads no pending state and schedules a second banner for + * the same notification. The dedup has to happen where concurrency is still + * visible, which after serialization is enqueue time rather than delivery time. + * + * Only shows are tracked. A dismiss for the same id must still run: it is the + * mechanism that retires the notification the show created. + */ +export function shouldQueueShowForNotificationId( + session: HostNotificationSession, + notificationId: string | undefined +): boolean { + if (notificationId == null) { + return true + } + if (session.queuedShowIds.has(notificationId)) { + return false + } + session.queuedShowIds.add(notificationId) + return true +} + +/** Release the claim taken by shouldQueueShowForNotificationId once the show settles. */ +export function releaseQueuedShowNotificationId( + session: HostNotificationSession, + notificationId: string | undefined +): void { + if (notificationId != null) { + session.queuedShowIds.delete(notificationId) + } +} + +/** Test-only: drop per-host session state so each test starts from a cold open. */ +export function resetHostNotificationSessionsForTests(): void { + sessionsByHost.clear() +} + +// Why (#8591): the desktop's seq counter restarts at 0 every launch, so a watermark +// from a previous lifetime indexes a counter that no longer exists. Comparing it +// against the fresh counter makes `lastSeenSeq >= seq` true for everything and +// catch-up dies silently until the new process out-dispatches the old watermark. +// Adopting the new epoch means dropping the watermark with it. +export function adoptNotificationEpoch( + session: HostNotificationSession, + hostId: string, + epoch: string | undefined +): void { + if (!epoch || epoch === session.lastDeliveredEpoch) { + return + } + // Why reset on a FIRST observation too (lastDeliveredEpoch === null): a seq seeded + // from a legacy store carries no epoch, so it cannot be shown to belong to this + // counter. Keeping it would let a pre-upgrade 57 cut the new counter's 1..57 — + // the exact #8591 failure, reached through the upgrade path instead of a restart. + session.lastDeliveredSeq = 0 + // Why clear `seen`: its keys are seq-derived, and terminal-bell notifications have + // no notificationId at all (they key on `seq:N` alone). Across a restart the new + // counter re-issues those same low seqs, so a stale `seq:1` would silently drop + // the new counter's first bell. The dedup window belongs to one counter lifetime. + session.seen.clear() + session.lastDeliveredEpoch = epoch + void saveWatermark(hostId, { seq: 0, epoch }) +} + +// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read. +// Only the first subscription for a host needs it; later ones inherit the live value. +/** + * Ms the persisted read may block catch-up and live delivery before they proceed + * without it. AsyncStorage normally answers in single-digit ms; a read that has + * not landed by now is assumed wedged. + * + * Why a bound at all (#8591): every delivery awaits this promise, so a read that + * never settles silently disables notifications for the host for the whole app + * lifetime — no error, no banner, nothing to see. Proceeding unseeded is strictly + * better: the watermark stays 0, so catch-up over-fetches and the seen-set + * de-duplicates, which costs a redundant request instead of every notification. + */ +const WATERMARK_SEED_TIMEOUT_MS = 3000 + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + void promise.then( + () => { + clearTimeout(timer) + resolve() + }, + () => { + clearTimeout(timer) + resolve() + } + ) + }) +} + +export function seedWatermarkFromStorage(session: HostNotificationSession, hostId: string): void { + if (session.watermarkSeeded) { + return + } + const seeded = loadWatermark(hostId).then(({ seq, epoch, stored }) => { + // Why the record's existence and not `seq > 0`: adoptNotificationEpoch persists + // `{seq: 0, epoch}` when it voids a watermark, so a device that HAS delivered for + // this host reloads as seq 0. Keying on the seq would read that as a first pairing + // and skip catch-up for the whole window the epoch change was meant to recover. + if (stored) { + session.hadStoredWatermark = true + } + // Why the epoch comparison: this read can land AFTER 'ready' already adopted a + // live epoch. If the stored watermark belongs to a different (older) counter, + // applying it here would silently reinstate exactly the stale cut this fixes. + // A null stored epoch is a legacy watermark of unknown provenance — it may only + // seed while no live epoch is known, and adopting one later resets it. + if (session.lastDeliveredEpoch === null || session.lastDeliveredEpoch === epoch) { + session.lastDeliveredSeq = Math.max(session.lastDeliveredSeq, seq) + if (session.lastDeliveredEpoch === null && epoch !== null) { + session.lastDeliveredEpoch = epoch + } + } + }) + // The late seed still applies when it eventually lands; the timeout only stops it + // from holding delivery hostage. `seeded` never rejects into the awaiters. + session.watermarkSeeded = withTimeout(seeded, WATERMARK_SEED_TIMEOUT_MS) +} + +// Why (#8591): sessions live at module scope so they survive the subscription +// teardown a reconnect performs. Nothing else drops them, so a host that is removed +// and re-paired would retain its session and up to 512 seen keys until app restart. +export function forgetHostNotificationSession(hostId: string): void { + sessionsByHost.delete(hostId) +} + // Why: key for the replay dedup guard. Uses notificationId when present, but // disambiguates by seq so a legitimate live re-delivery of the same id at a // NEW seq (content refresh, allowed by the existing behaviour) is NOT treated diff --git a/mobile/src/notifications/notification-reconnect-teardown.test.ts b/mobile/src/notifications/notification-reconnect-teardown.test.ts new file mode 100644 index 00000000000..a5e7433bf0f --- /dev/null +++ b/mobile/src/notifications/notification-reconnect-teardown.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { subscribeToDesktopNotifications } from './mobile-notifications' +import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup' +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { RpcClient } from '../transport/rpc-client' +import { loadPushNotificationsEnabled } from '../storage/preferences' + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { HIGH: 'high' }, + setNotificationChannelAsync: vi.fn(), + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios', Version: 18 } +})) + +// In-memory AsyncStorage so the persisted watermark survives across the +// subscribe/unsubscribe cycles this test exercises (the real device behaviour). +const storage = new Map() +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (k: string) => storage.get(k) ?? null), + setItem: vi.fn(async (k: string, v: string) => { + storage.set(k, v) + }) + } +})) + +vi.mock('../storage/preferences', () => ({ + loadPushNotificationsEnabled: vi.fn() +})) + +function flushAsync(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 10) + }) +} + +// Models mobile/app/index.tsx:497-537: a per-host client whose notification +// subscription is torn down on any non-'connected' state and re-created from +// scratch on the next 'connected'. +function makeHostClient() { + let onData: ((data: unknown) => void) | null = null + const getMissedCalls: { lastSeenSeq: number }[] = [] + const client = { + subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { + onData = cb + return vi.fn(() => { + onData = null + }) + }), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn(async (method: string, params: unknown = {}) => { + if (method === 'notifications.getMissedSince') { + getMissedCalls.push(params as { lastSeenSeq: number }) + return { ok: true, result: { notifications: missedQueue } } as never + } + return { ok: true, result: undefined } as never + }) + } + let missedQueue: unknown[] = [] + return { + client: client as unknown as RpcClient, + get onData() { + return onData + }, + getMissedCalls, + setMissed(events: unknown[]) { + missedQueue = events + } + } +} + +describe('#8591 reconnect catch-up under the real app teardown lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + storage.clear() + resetHostNotificationSessionsForTests() + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1') + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) + vi.mocked(AsyncStorage.getItem).mockClear() + }) + + it('fetches missed notifications after a disconnect tears the subscription down', async () => { + const host = makeHostClient() + + // ── Connected: cold open, one live notification delivered (desktop seq 7). + const unsub = subscribeToDesktopNotifications(host.client, 'host-1') + host.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + host.onData?.({ + type: 'notification', + title: 'live', + body: 'b', + notificationId: 'agent:live', + notificationSeq: 7 + }) + await flushAsync() + + // ── Socket drops. app/index.tsx wireUp() calls unsubNotif() on the + // non-'connected' state, destroying the subscribeToDesktopNotifications + // closure (and with it reconnectReadyCount / lastDeliveredSeq). + unsub() + await flushAsync() + + // ── While disconnected the desktop dispatched seq 8 and 9. + host.setMissed([ + { + type: 'notification', + title: 'missed-8', + body: 'b', + notificationId: 'agent:m8', + notificationSeq: 8 + }, + { + type: 'notification', + title: 'missed-9', + body: 'b', + notificationId: 'agent:m9', + notificationSeq: 9 + } + ]) + + // ── Reconnected: app re-subscribes with a FRESH closure. + subscribeToDesktopNotifications(host.client, 'host-1') + host.onData?.({ type: 'ready', subscriptionId: 'sub-2' }) + await flushAsync() + + // The user must be told about seq 8 and 9. Nothing else can deliver them: + // the desktop only fans out live, so this catch-up is the only path. + expect(host.getMissedCalls).toHaveLength(1) + expect(host.getMissedCalls[0]).toEqual({ lastSeenSeq: 7 }) + const titles = vi + .mocked(Notifications.scheduleNotificationAsync) + .mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title) + expect(titles).toContain('missed-8') + expect(titles).toContain('missed-9') + }) + + it('does not re-push a live notification the catch-up replays after a teardown', async () => { + // Why: the seen-set lives on the host session precisely so it survives the teardown. + // getMissedSince cuts by seq > lastSeenSeq, but a notification delivered live in the + // brief window before the drop is still inside the desktop's retained buffer, so the + // reconnect fetch returns it again. Only the session-scoped seen-set stops a duplicate + // banner for something the user was already shown. + const host = makeHostClient() + + const unsub = subscribeToDesktopNotifications(host.client, 'host-1') + host.onData?.({ type: 'ready', subscriptionId: 'sub-1' }) + await flushAsync() + host.onData?.({ + type: 'notification', + title: 'live-7', + body: 'b', + notificationId: 'agent:seven', + notificationSeq: 7 + }) + await flushAsync() + + unsub() + await flushAsync() + + // The desktop replays seq 7 alongside the genuinely-missed seq 8. + host.setMissed([ + { + type: 'notification', + title: 'live-7', + body: 'b', + notificationId: 'agent:seven', + notificationSeq: 7 + }, + { + type: 'notification', + title: 'missed-8', + body: 'b', + notificationId: 'agent:m8', + notificationSeq: 8 + } + ]) + + subscribeToDesktopNotifications(host.client, 'host-1') + host.onData?.({ type: 'ready', subscriptionId: 'sub-2' }) + await flushAsync() + + const titles = vi + .mocked(Notifications.scheduleNotificationAsync) + .mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title) + expect(titles.filter((title) => title === 'live-7')).toHaveLength(1) + expect(titles).toContain('missed-8') + }) +}) diff --git a/mobile/src/notifications/notification-watermark-seed-race.test.ts b/mobile/src/notifications/notification-watermark-seed-race.test.ts new file mode 100644 index 00000000000..742f0711982 --- /dev/null +++ b/mobile/src/notifications/notification-watermark-seed-race.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Notifications from 'expo-notifications' +import { subscribeToDesktopNotifications } from './mobile-notifications' +import { + adoptNotificationEpoch, + clearWatermark, + getHostNotificationSession, + resetHostNotificationSessionsForTests, + seedWatermarkFromStorage +} from './notification-reconnect-catchup' +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { RpcClient } from '../transport/rpc-client' +import { loadPushNotificationsEnabled } from '../storage/preferences' + +vi.mock('expo-notifications', () => ({ + AndroidImportance: { HIGH: 'high' }, + setNotificationChannelAsync: vi.fn(), + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), + scheduleNotificationAsync: vi.fn(), + dismissNotificationAsync: vi.fn() +})) + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios', Version: 18 } +})) + +// A storage whose reads can be held open, so a live event can be injected into the +// exact window a real cold open has: subscription up, persisted watermark not yet read. +const storage = new Map() +let heldReads: (() => void)[] = [] +let holdReads = false +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn((key: string) => { + const read = (): string | null => storage.get(key) ?? null + if (!holdReads) { + return Promise.resolve(read()) + } + return new Promise((resolve) => { + heldReads.push(() => resolve(read())) + }) + }), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value) + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key) + }) + } +})) + +vi.mock('../storage/preferences', () => ({ + loadPushNotificationsEnabled: vi.fn() +})) + +function flushAsync(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 10) + }) +} + +function releaseReads(): void { + const pending = heldReads + heldReads = [] + for (const resolve of pending) { + resolve() + } +} + +function makeHostClient() { + let onData: ((data: unknown) => void) | null = null + const getMissedCalls: { lastSeenSeq: number; epoch?: string }[] = [] + const client = { + subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => { + onData = cb + return vi.fn(() => { + onData = null + }) + }), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn(async (method: string, params: unknown = {}) => { + if (method === 'notifications.getMissedSince') { + getMissedCalls.push(params as { lastSeenSeq: number; epoch?: string }) + return { ok: true, result: { notifications: [] } } as never + } + return { ok: true, result: undefined } as never + }) + } + return { + client: client as unknown as RpcClient, + get onData() { + return onData + }, + getMissedCalls + } +} + +const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1' +const LEGACY_KEY = 'orca:mobileNotificationsLastSeq:host-1' + +describe('#8591 watermark seeding races a cold open', () => { + beforeEach(() => { + vi.clearAllMocks() + storage.clear() + heldReads = [] + holdReads = false + resetHostNotificationSessionsForTests() + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1') + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) + }) + + it('asks for catch-up from the persisted seq even if a live event lands first', async () => { + // The window is real: app/index.tsx subscribes immediately, and the desktop's + // 'ready' plus its first live fan-out can both beat an AsyncStorage read. If the + // live seq is allowed to advance the watermark first, getMissedSince is asked to + // start from it and the desktop cuts everything the device actually missed. + storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-a' })) + holdReads = true + const host = makeHostClient() + + subscribeToDesktopNotifications(host.client, 'host-1') + host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' }) + host.onData?.({ + type: 'notification', + title: 'live-12', + body: 'b', + notificationId: 'agent:live', + notificationSeq: 12, + notificationEpoch: 'epoch-a' + }) + await flushAsync() + + // Nothing may be decided while the read is outstanding. + expect(host.getMissedCalls).toHaveLength(0) + + releaseReads() + await flushAsync() + + expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 5, epoch: 'epoch-a' }]) + }) + + it('treats a zeroed-but-present watermark as a returning device, not a first pairing', async () => { + // adoptNotificationEpoch persists {seq: 0, epoch} when it voids a watermark from a + // dead counter. That record still proves this device has been subscribed here, so a + // cold open after it must catch up — reading it as "never paired" drops the window. + storage.set(WATERMARK_KEY, JSON.stringify({ seq: 0, epoch: 'epoch-a' })) + const host = makeHostClient() + + subscribeToDesktopNotifications(host.client, 'host-1') + host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' }) + await flushAsync() + + expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 0, epoch: 'epoch-a' }]) + }) + + it('does not catch up on a first-ever pairing', async () => { + const host = makeHostClient() + + subscribeToDesktopNotifications(host.client, 'host-1') + host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' }) + await flushAsync() + + expect(host.getMissedCalls).toEqual([]) + }) + + it('a seed landing after a live epoch is adopted cannot reinstate the dead watermark', async () => { + // Ordering invariant on the exported pair, not a path subscribeToDesktopNotifications + // can currently take — 'ready' awaits watermarkSeeded before adopting, so the seed + // always resolves first today. Pinned anyway because the guard is load-bearing the + // moment any caller adopts an epoch before seeding: applying a seq 40 from a counter + // that no longer exists would let getMissedSince cut the new counter's 1..40, which + // is the original #8591 loss re-entered through the seeding path. + const session = getHostNotificationSession('host-1') + adoptNotificationEpoch(session, 'host-1', 'epoch-new') + await flushAsync() + + storage.set(WATERMARK_KEY, JSON.stringify({ seq: 40, epoch: 'epoch-old' })) + seedWatermarkFromStorage(session, 'host-1') + await session.watermarkSeeded + await flushAsync() + + expect(session.lastDeliveredEpoch).toBe('epoch-new') + expect(session.lastDeliveredSeq).toBe(0) + }) + + it('clears the legacy seq key too, so an unpaired host cannot resurrect it', async () => { + // loadWatermark falls back to the legacy key, so leaving it behind lets a re-paired + // host read a pre-#8591 seq belonging to a counter lifetime that no longer exists. + storage.set(WATERMARK_KEY, JSON.stringify({ seq: 9, epoch: 'epoch-a' })) + storage.set(LEGACY_KEY, '57') + + await clearWatermark('host-1') + + expect(vi.mocked(AsyncStorage.removeItem).mock.calls.map((call) => call[0])).toEqual( + expect.arrayContaining([WATERMARK_KEY, LEGACY_KEY]) + ) + expect(storage.has(WATERMARK_KEY)).toBe(false) + expect(storage.has(LEGACY_KEY)).toBe(false) + }) +}) diff --git a/mobile/src/transport/host-removal-lifecycle.test.ts b/mobile/src/transport/host-removal-lifecycle.test.ts index e398062b270..6c96ef1c446 100644 --- a/mobile/src/transport/host-removal-lifecycle.test.ts +++ b/mobile/src/transport/host-removal-lifecycle.test.ts @@ -1,16 +1,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const removeHostMock = vi.hoisted(() => vi.fn()) +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + // Why removeItem is here: clearWatermark() swallows its own failures, so a mock + // missing this method turns the persisted-watermark cleanup into a caught + // TypeError — the assertion below would pass even if the call were deleted. + removeItem: vi.fn(async () => undefined) +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) vi.mock('./host-store', () => ({ removeHost: (hostId: string) => removeHostMock(hostId) })) import { removeHostAndCloseClient } from './host-removal-lifecycle' +import { + getHostNotificationSession, + resetHostNotificationSessionsForTests +} from '../notifications/notification-reconnect-catchup' describe('host removal lifecycle', () => { beforeEach(() => { removeHostMock.mockReset() + asyncStorage.removeItem.mockClear() + resetHostNotificationSessionsForTests() }) it('closes the client only after metadata removal commits', async () => { @@ -39,4 +55,37 @@ describe('host removal lifecycle', () => { ) expect(closeHostClient).not.toHaveBeenCalled() }) + + it('retires the notification session so a removed host leaves nothing behind', async () => { + // Round-1 review finding: the session lives at module scope (it must survive the + // subscription teardown a reconnect performs), so removal is the only thing that + // can retire it. Left behind, each remove/re-pair cycle strands a session plus up + // to 512 seen keys, and a re-paired host inherits a watermark it never earned. + removeHostMock.mockResolvedValue(undefined) + const session = getHostNotificationSession('host-1') + session.lastDeliveredSeq = 42 + session.lastDeliveredEpoch = 'epoch-A' + + await removeHostAndCloseClient('host-1', vi.fn()) + + // A fresh session for the same id — not the retained one. + const afterRemoval = getHostNotificationSession('host-1') + expect(afterRemoval).not.toBe(session) + expect(afterRemoval.lastDeliveredSeq).toBe(0) + expect(afterRemoval.lastDeliveredEpoch).toBeNull() + }) + + it('erases the persisted watermark, not just the in-memory session', async () => { + // Why separately from the test above: the session is process-local, the + // watermark is not. Retiring only the session lets a re-pair of the same host + // read the old seq off disk and resume against a counter it never saw — the + // catch-up would then start above the real cut and drop everything below it. + removeHostMock.mockResolvedValue(undefined) + + await removeHostAndCloseClient('host-1', vi.fn()) + // clearWatermark is fire-and-forget; let its microtask land. + await Promise.resolve() + + expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1') + }) }) diff --git a/mobile/src/transport/host-removal-lifecycle.ts b/mobile/src/transport/host-removal-lifecycle.ts index ba599614804..64007cb9552 100644 --- a/mobile/src/transport/host-removal-lifecycle.ts +++ b/mobile/src/transport/host-removal-lifecycle.ts @@ -1,3 +1,7 @@ +import { + clearWatermark, + forgetHostNotificationSession +} from '../notifications/notification-reconnect-catchup' import { removeHost } from './host-store' export async function removeHostAndCloseClient( @@ -8,4 +12,9 @@ export async function removeHostAndCloseClient( // storage failure; closing immediately after success prevents socket leaks. await removeHost(hostId) closeHostClient(hostId) + // Why: the notification session outlives the socket by design (it must survive + // reconnects), so removal is the only thing that can retire it. Left behind, a + // re-pair of the same host would inherit a watermark for a counter it never saw. + forgetHostNotificationSession(hostId) + void clearWatermark(hostId) } diff --git a/package.json b/package.json index 1df03908168..afbdf38bff4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.160-rc.0", + "version": "1.4.160-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/cli/args.ts b/src/cli/args.ts index 7181ebbf1c5..90a42ac813b 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,4 +1,4 @@ -import { RuntimeClientError } from './runtime-client' +import { RuntimeClientError } from './runtime/types' import { unknownCommandData, unknownFlagData } from './command-suggestion' export type ParsedArgs = { diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index f4e30166d72..d74de65119e 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -1,5 +1,5 @@ import type { RuntimeClient } from './runtime-client' -import { RuntimeClientError } from './runtime-client' +import { RuntimeClientError } from './runtime/types' import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest' export type HandlerContext = { diff --git a/src/cli/flags.ts b/src/cli/flags.ts index dca27f455ee..4e56471e38b 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -1,4 +1,4 @@ -import { RuntimeClientError } from './runtime-client' +import { RuntimeClientError } from './runtime/types' import { REPEATED_FLAG_SEPARATOR } from './args' export function getRequiredStringFlag(flags: Map, name: string): string { diff --git a/src/cli/format.ts b/src/cli/format.ts index 9d608a5b0f4..2bc8de3aa64 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -2,7 +2,7 @@ import type { CliStatusResult } from '../shared/runtime-types' import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery' import { prepareComputerCliJsonResult } from './computer-format' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' -import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' +import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types' export { formatBrowserProfileList, diff --git a/src/cli/handlers/computer-action-validation.test.ts b/src/cli/handlers/computer-action-validation.test.ts index e9d5d720c65..d313e66fd0a 100644 --- a/src/cli/handlers/computer-action-validation.test.ts +++ b/src/cli/handlers/computer-action-validation.test.ts @@ -2,36 +2,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() -vi.mock('../runtime-client', () => { +vi.mock('../runtime-client', async () => { class RuntimeClient { call = callMock getCliStatus = vi.fn() openOrca = vi.fn() } - class RuntimeClientError extends Error { - readonly code: string - - constructor(code: string, message: string) { - super(message) - this.code = code - } - } - - class RuntimeRpcFailureError extends RuntimeClientError { - readonly response: unknown - - constructor(response: unknown) { - super('runtime_error', 'runtime_error') - this.response = response - } - } - - return { - RuntimeClient, - RuntimeClientError, - RuntimeRpcFailureError - } + // Why: re-export the REAL error classes rather than redefining them. format.ts + // narrows with `instanceof` against ./runtime/types, so a look-alike class + // here would make every CLI error fall through to the generic `runtime_error` + // shape — mirroring the barrel keeps the mock faithful to production. + const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js') + return { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError } }) import { main } from '../index' diff --git a/src/cli/handlers/computer.test.ts b/src/cli/handlers/computer.test.ts index f80f70c21f7..5f04b94044c 100644 --- a/src/cli/handlers/computer.test.ts +++ b/src/cli/handlers/computer.test.ts @@ -2,36 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() -vi.mock('../runtime-client', () => { +vi.mock('../runtime-client', async () => { class RuntimeClient { call = callMock getCliStatus = vi.fn() openOrca = vi.fn() } - class RuntimeClientError extends Error { - readonly code: string - - constructor(code: string, message: string) { - super(message) - this.code = code - } - } - - class RuntimeRpcFailureError extends RuntimeClientError { - readonly response: unknown - - constructor(response: unknown) { - super('runtime_error', 'runtime_error') - this.response = response - } - } - - return { - RuntimeClient, - RuntimeClientError, - RuntimeRpcFailureError - } + // Why: re-export the REAL error classes; format.ts narrows with `instanceof` + // against ./runtime/types, so a look-alike would collapse every CLI error + // code into the generic `runtime_error` shape. + const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js') + return { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError } }) import { main } from '../index' diff --git a/src/cli/handlers/emulator.test.ts b/src/cli/handlers/emulator.test.ts index 3a9711595b5..07bdd51e2f5 100644 --- a/src/cli/handlers/emulator.test.ts +++ b/src/cli/handlers/emulator.test.ts @@ -6,7 +6,7 @@ const { callMock, remoteMock } = vi.hoisted(() => ({ remoteMock: vi.fn(() => false) })) -vi.mock('../runtime-client', () => { +vi.mock('../runtime-client', async () => { class RuntimeClient { readonly isRemote: boolean call = callMock @@ -18,29 +18,11 @@ vi.mock('../runtime-client', () => { } } - class RuntimeClientError extends Error { - readonly code: string - - constructor(code: string, message: string) { - super(message) - this.code = code - } - } - - class RuntimeRpcFailureError extends RuntimeClientError { - readonly response: unknown - - constructor(response: unknown) { - super('runtime_error', 'runtime_error') - this.response = response - } - } - - return { - RuntimeClient, - RuntimeClientError, - RuntimeRpcFailureError - } + // Why: re-export the REAL error classes; format.ts narrows with `instanceof` + // against ./runtime/types, so a look-alike would collapse every CLI error + // code into the generic `runtime_error` shape. + const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js') + return { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError } }) import { main } from '../index' diff --git a/src/cli/handlers/linear.test.ts b/src/cli/handlers/linear.test.ts index 61bc5082dc0..91042c49d99 100644 --- a/src/cli/handlers/linear.test.ts +++ b/src/cli/handlers/linear.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() -vi.mock('../runtime-client', () => { +vi.mock('../runtime-client', async () => { class RuntimeClient { readonly isRemote: boolean call = callMock @@ -19,23 +19,10 @@ vi.mock('../runtime-client', () => { } } - class RuntimeClientError extends Error { - readonly code: string - - constructor(code: string, message: string) { - super(message) - this.code = code - } - } - - class RuntimeRpcFailureError extends RuntimeClientError { - readonly response: unknown - - constructor(response: unknown) { - super('runtime_error', 'runtime_error') - this.response = response - } - } + // Why: re-export the REAL error classes; format.ts narrows with `instanceof` + // against ./runtime/types, so a look-alike would collapse every CLI error + // code into the generic `runtime_error` shape. + const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js') return { RuntimeClient, diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 0725fcff419..46b437ef385 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -22,7 +22,13 @@ const { spawnMock: vi.fn() })) -vi.mock('./runtime-client', () => { +vi.mock('./runtime-client', async () => { + // Why: re-export the REAL error classes rather than redefining them. format.ts + // narrows with `instanceof` against ./runtime/types, so a look-alike class + // here would make every CLI error fall through to the generic `runtime_error` + // shape — mirroring the barrel keeps the mock faithful to production. + const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js') + class RuntimeClient { readonly isRemote: boolean call = callMock @@ -52,26 +58,6 @@ vi.mock('./runtime-client', () => { } } - class RuntimeClientError extends Error { - readonly code: string - readonly data?: unknown - - constructor(code: string, message: string, data?: unknown) { - super(message) - this.code = code - this.data = data - } - } - - class RuntimeRpcFailureError extends RuntimeClientError { - readonly response: unknown - - constructor(response: unknown) { - super('runtime_error', 'runtime_error') - this.response = response - } - } - return { RuntimeClient, RuntimeClientError, diff --git a/src/cli/index.ts b/src/cli/index.ts index c35559123d1..1ffe55a4702 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,7 +11,7 @@ import { import { dispatch } from './dispatch' import { reportCliError } from './format' import { printHelp } from './help' -import { RuntimeClient } from './runtime-client' +import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' export { COMMAND_SPECS } from './specs' @@ -29,6 +29,15 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { ) } +// Why: the RuntimeClient graph is 153 of the CLI's 199 eager modules (zod via +// shared/pairing, ws + tweetnacl via websocket-transport). Loading it here +// rather than at module scope means --help, `help `, and command/flag +// errors — which all return before this call — never pay for it. Awaited +// before dispatch so `ctx.client` stays a synchronous getter. +async function loadRuntimeClientClass(): Promise { + return (await import('./runtime-client.js')).RuntimeClient +} + // Why: the SSH relay bridge executes this CLI on the Orca host while the // caller's shell cwd lives on the remote machine (which cannot be chdir'd // into). ORCA_CLI_CWD carries that remote cwd so cwd-based selectors like @@ -74,6 +83,7 @@ export async function main( // lookup so users do not get misleading "Orca is not running" failures for // simple command typos or unsupported flags. validateCommandAndFlags(COMMAND_SPECS, parsed) + const RuntimeClientClass = await loadRuntimeClientClass() const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment') @@ -86,7 +96,7 @@ export async function main( flags: parsed.flags, // Why: local-only handlers must not resolve runtime metadata just to dispatch. get client() { - client ??= new RuntimeClient( + client ??= new RuntimeClientClass( undefined, undefined, typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined, @@ -111,7 +121,7 @@ async function runClaudeTeams(argv: string[], cwd: string): Promise { try { // Why: everything after `orca claude-teams` belongs to Claude Code, not // Orca's own flag parser, so new Claude flags work without Orca changes. - const client = new RuntimeClient(undefined, undefined, null, null) + const client = new (await loadRuntimeClientClass())(undefined, undefined, null, null) await dispatch(['claude-teams'], { flags: new Map(), client, @@ -127,7 +137,7 @@ async function runClaudeTeams(argv: string[], cwd: string): Promise { async function runAgentTeamsTmuxShim(argv: string[]): Promise { try { - const client = new RuntimeClient(undefined, 10_000) + const client = new (await loadRuntimeClientClass())(undefined, 10_000) const response = await client.call<{ tmux: { stdout: string; stderr: string; exitCode: number } }>( diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts new file mode 100644 index 00000000000..396bc6656ec --- /dev/null +++ b/src/cli/runtime-client-deferral.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const { constructorArgsMock, callMock, getCliStatusMock } = vi.hoisted(() => ({ + constructorArgsMock: vi.fn(), + callMock: vi.fn(), + getCliStatusMock: vi.fn() +})) + +// Why: `main` reaches RuntimeClient through `await import('./runtime-client.js')` +// now. Mocking the same specifier the eager import used proves the dynamic +// import still resolves to the module the 10 existing vi.mock suites target. +vi.mock('./runtime-client', () => { + class RuntimeClient { + call = callMock + getCliStatus = getCliStatusMock + openOrca = vi.fn() + + constructor(...args: unknown[]) { + constructorArgsMock(...args) + } + } + return { RuntimeClient } +}) + +import { main } from './index' +import * as dispatchModule from './dispatch' + +const CLI_DIR = __dirname + +describe('RuntimeClient module-graph deferral', () => { + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + constructorArgsMock.mockClear() + callMock.mockReset() + getCliStatusMock.mockReset() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + vi.unstubAllEnvs() + process.exitCode = 0 + }) + + // Why: the whole point of the change. These five modules load on EVERY + // invocation, so a value-import of the barrel from any of them drags the + // RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path. + it.each(['args.ts', 'flags.ts', 'dispatch.ts', 'format.ts', 'selectors.ts'])( + '%s imports error classes from ./runtime/types, not the barrel', + (file) => { + const source = readFileSync(join(CLI_DIR, file), 'utf8') + const valueImports = source + .split('\n') + .filter((line) => line.startsWith('import ') && line.includes("'./runtime-client'")) + for (const line of valueImports) { + expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /) + } + expect(source).toContain("} from './runtime/types'") + } + ) + + it('index.ts has no eager value-import of the runtime client', () => { + const source = readFileSync(join(CLI_DIR, 'index.ts'), 'utf8') + expect(source).toContain("import type { RuntimeClient } from './runtime-client'") + expect(source).not.toMatch(/^import \{[^}]*RuntimeClient[^}]*\} from '\.\/runtime-client'/m) + expect(source).toContain("await import('./runtime-client.js')") + }) + + it('constructs no client for --help', async () => { + await main(['--help'], '/tmp/repo') + expect(constructorArgsMock).not.toHaveBeenCalled() + }) + + it('constructs no client for an unknown flag', async () => { + await main(['worktree', 'list', '--nope'], '/tmp/repo') + expect(process.exitCode).toBe(1) + expect(constructorArgsMock).not.toHaveBeenCalled() + }) + + // Why: `agent hooks on|off` are the only commands that both sit in a + // suppressed group and touch ctx.client, and they rewrite the real ~/.claude + // hook config — so the byte-for-byte equivalence script cannot invoke them. + // Assert the constructor arguments directly instead: `null` (not `undefined`) + // is what stops the ORCA_* env fallback re-activating for local-only groups. + // + // `constructs` is declared per case and asserted BEFORE the args, because + // only `agent hooks off` reads ctx.client. Looping over `mock.calls` alone + // would pass vacuously for the other four, and would keep passing if the one + // case that carries the null-vs-undefined coverage stopped constructing at + // all. The zero rows are not filler: they assert the deferral itself — a + // local-only group must reach its handler without building a client. + const SUPPRESSED_GROUPS: [name: string, argv: string[], constructs: number][] = [ + ['agent', ['agent', 'hooks', 'off'], 1], + ['environment', ['environment', 'list'], 0], + ['serve', ['serve'], 0], + ['vm', ['vm', 'recipe', 'doctor'], 0], + ['agent-context', ['agent-context'], 0] + ] + + it.each(SUPPRESSED_GROUPS)( + 'constructs exactly %s expected clients, with null remote selection', + async (_name, argv, constructs) => { + vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code') + vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment') + getCliStatusMock.mockResolvedValue({ result: { runtime: { reachable: false } } }) + + await main(argv, '/tmp/repo') + + const calls = constructorArgsMock.mock.calls + expect(calls.length, `${argv.join(' ')} client constructions`).toBe(constructs) + for (const call of calls) { + expect(call[2], `${argv.join(' ')} pairing code`).toBeNull() + expect(call[3], `${argv.join(' ')} environment`).toBeNull() + } + } + ) + + // Why: four of the five groups above never read ctx.client, so they can only + // assert that no client is built — not that suppression forwards `null`. + // Stub dispatch and read the getter directly so every group asserts the + // constructor arguments unconditionally, exactly once. + it.each(SUPPRESSED_GROUPS.map(([name, argv]) => [name, argv] as const))( + 'forwards null remote selection to the client %s would build', + async (_name, argv) => { + vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code') + vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment') + const dispatchSpy = vi.spyOn(dispatchModule, 'dispatch').mockResolvedValue(undefined) + try { + await main(argv, '/tmp/repo') + + const ctx = dispatchSpy.mock.calls.at(-1)?.[1] + expect(dispatchSpy, `${argv.join(' ')} reached dispatch`).toHaveBeenCalledTimes(1) + void ctx?.client + expect(constructorArgsMock, `${argv.join(' ')} constructions`).toHaveBeenCalledTimes(1) + const [, , pairingCode, environment] = constructorArgsMock.mock.calls[0] + expect(pairingCode, `${argv.join(' ')} pairing code`).toBeNull() + expect(environment, `${argv.join(' ')} environment`).toBeNull() + } finally { + dispatchSpy.mockRestore() + } + } + ) + + // Why: the mirror — the same stubbed-dispatch probe must show `undefined` + // (not `null`) for a non-suppressed group, or the assertion above would pass + // for a build that suppressed EVERY command's env fallback. + it('forwards undefined remote selection for a non-suppressed group', async () => { + vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code') + const dispatchSpy = vi.spyOn(dispatchModule, 'dispatch').mockResolvedValue(undefined) + try { + await main(['worktree', 'list'], '/tmp/repo') + + void dispatchSpy.mock.calls.at(-1)?.[1]?.client + expect(constructorArgsMock).toHaveBeenCalledTimes(1) + const [, , pairingCode, environment] = constructorArgsMock.mock.calls[0] + expect(pairingCode).toBeUndefined() + expect(environment).toBeUndefined() + } finally { + dispatchSpy.mockRestore() + } + }) + + // Why: the mirror of the above — for every other command the env fallback + // MUST stay live, which the RuntimeClient default parameters implement. That + // only works if `undefined` is forwarded. + it('forwards undefined for non-suppressed commands so the env fallback applies', async () => { + callMock.mockResolvedValue({ result: { worktrees: [] } }) + + await main(['worktree', 'list', '--json'], '/tmp/repo') + + expect(constructorArgsMock).toHaveBeenCalled() + const [, , pairingCode, environment] = constructorArgsMock.mock.calls[0] + expect(pairingCode).toBeUndefined() + expect(environment).toBeUndefined() + }) + + it('forwards explicit --pairing-code and --environment values verbatim', async () => { + callMock.mockResolvedValue({ result: { worktrees: [] } }) + + await main(['worktree', 'list', '--pairing-code', 'code-1', '--json'], '/tmp/repo') + expect(constructorArgsMock.mock.calls[0][2]).toBe('code-1') + expect(constructorArgsMock.mock.calls[0][3]).toBeUndefined() + + constructorArgsMock.mockClear() + await main(['worktree', 'list', '--environment', 'env-1', '--json'], '/tmp/repo') + expect(constructorArgsMock.mock.calls[0][2]).toBeUndefined() + expect(constructorArgsMock.mock.calls[0][3]).toBe('env-1') + }) + + // Why: the getter is memoised; a dynamic import inside it would have made it + // async and changed every handler signature. + it('reuses one client instance across repeated ctx.client reads', async () => { + callMock.mockResolvedValue({ result: { worktrees: [] } }) + + await main(['worktree', 'list', '--json'], '/tmp/repo') + + expect(constructorArgsMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index bb3cb905701..2b7df570098 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -11,7 +11,7 @@ import { getDefaultUserDataPath, readMetadata } from './metadata' import { getCliStatus, resolveDesktopWindowStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' -import { sendWebSocketRequest } from './websocket-transport' +import type { sendWebSocketRequest } from './websocket-transport' import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments' import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat' import { @@ -29,6 +29,13 @@ import { // keepalive window. See design doc §3.1. const LONG_POLL_CLIENT_GRACE_MS = 10_000 +// Why: ws + tweetnacl + the remote-runtime frame stack only matter once a +// request actually goes over a pairing offer, which local CLI calls never do. +// Both call sites already await this, so deferring the load changes no ordering. +async function loadSendWebSocketRequest(): Promise { + return (await import('./websocket-transport.js')).sendWebSocketRequest +} + export class RuntimeClient { private readonly userDataPath: string private readonly requestTimeoutMs: number @@ -80,6 +87,7 @@ export class RuntimeClient { if (method !== 'status.get') { await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs) } + const sendWebSocketRequest = await loadSendWebSocketRequest() let response try { response = await sendWebSocketRequest( @@ -179,6 +187,7 @@ export class RuntimeClient { if (!this.remotePairing || this.remoteCompatChecked) { return } + const sendWebSocketRequest = await loadSendWebSocketRequest() const response = await sendWebSocketRequest( this.remotePairing, 'status.get', diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 008ab2eb764..2b53c8fbc0a 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -6,7 +6,7 @@ import type { } from '../shared/runtime-types' import { isPathInsideOrEqual } from '../shared/cross-platform-path' import type { RuntimeClient } from './runtime-client' -import { RuntimeClientError } from './runtime-client' +import { RuntimeClientError } from './runtime/types' import { getOptionalStringFlag, getRequiredStringFlag } from './flags' export type BrowserCliTarget = { diff --git a/src/main/daemon/daemon-background-transient-facts.test.ts b/src/main/daemon/daemon-background-transient-facts.test.ts index acfcdb959f4..0d1aa12a94c 100644 --- a/src/main/daemon/daemon-background-transient-facts.test.ts +++ b/src/main/daemon/daemon-background-transient-facts.test.ts @@ -39,6 +39,29 @@ describe('BackgroundTransientFactRelay', () => { expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'command-finished', exitCode: 0 } }]) }) + it('preserves a provisional 2031 subscribe when scan authority moves to the daemon', () => { + const { relay, emitted } = createRelay() + relay.onSessionData('s1', '\x1b[?2031h\x1b[?') + relay.setSessionBackground('s1', true) + relay.seedSessionScanState('s1', '\x1b[?') + relay.onSessionData('s1', '25h') + + expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: '2031-subscribe' } }]) + }) + + it('exposes a provisional 2031 subscribe when scan authority returns to main', () => { + const { relay } = createRelay() + relay.setSessionBackground('s1', true) + relay.seedSessionScanState('s1', '') + relay.onSessionData('s1', '\x1b[?2031h\x1b[?') + relay.setSessionBackground('s1', false) + + expect(relay.getMode2031ReplyScanState('s1')).toEqual({ + tail: '\x1b[?', + pendingSubscribe: true + }) + }) + it('stops emitting after un-background and reports the toggle as a state change', () => { const { relay, emitted } = createRelay() expect(relay.setSessionBackground('s1', true)).toBe(true) diff --git a/src/main/daemon/daemon-background-transient-facts.ts b/src/main/daemon/daemon-background-transient-facts.ts index 3867ab0eddd..3e1c31c1369 100644 --- a/src/main/daemon/daemon-background-transient-facts.ts +++ b/src/main/daemon/daemon-background-transient-facts.ts @@ -19,6 +19,11 @@ import { createTerminalTitleTracker, type TerminalTitleTracker } from '../../shared/terminal-output-side-effects' +import { + INITIAL_MODE_2031_REPLY_SCAN_STATE, + scanMode2031ReplyDecision, + type Mode2031ReplyScanState +} from '../../shared/terminal-color-scheme-protocol' import type { DaemonTransientFact } from './types' // Kill switch for the whole background keep-tail mechanism (thinning + @@ -27,6 +32,8 @@ export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND export class BackgroundTransientFactRelay { private trackersBySessionId = new Map() + // Why: shadow foreground bytes so a provisional subscribe survives either scan-authority handoff. + private mode2031ReplyScanStateBySessionId = new Map() private emitFact: (sessionId: string, fact: DaemonTransientFact) => void constructor(emitFact: (sessionId: string, fact: DaemonTransientFact) => void) { @@ -59,7 +66,8 @@ export class BackgroundTransientFactRelay { // PR-link dedup memory, so a link re-printed across toggles can // re-fire — consumers treat pr-link as a latest-association update. onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }), - onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }) + onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }), + onMode2031Unsubscribe: () => this.emitFact(sessionId, { kind: '2031-unsubscribe' }) }) ) } else { @@ -73,30 +81,63 @@ export class BackgroundTransientFactRelay { * background toggle neither mints a phantom bell nor loses its fact. A * partial tail contains no complete sequence, so this can never fire. */ seedSessionScanState(sessionId: string, partialEscapeTailAnsi: string): void { - if (partialEscapeTailAnsi.length > 0) { - this.trackersBySessionId - .get(sessionId) - ?.handleChunk(partialEscapeTailAnsi, { titleScanData: '' }) + let mode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId) + if (!mode2031State && partialEscapeTailAnsi.length > 0) { + mode2031State = scanMode2031ReplyDecision( + INITIAL_MODE_2031_REPLY_SCAN_STATE, + partialEscapeTailAnsi + ).state + if (mode2031State.tail.length > 0) { + this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031State) + } + } + mode2031State ??= INITIAL_MODE_2031_REPLY_SCAN_STATE + const scanSeedAnsi = mode2031State.tail || partialEscapeTailAnsi + if (scanSeedAnsi.length > 0) { + this.trackersBySessionId.get(sessionId)?.handleChunk(scanSeedAnsi, { + titleScanData: '', + mode2031PendingSubscribe: mode2031State.pendingSubscribe + }) } } /** Feed one raw chunk, in byte order, BEFORE it is enqueued for delivery — * facts must be captured even when the chunk is later keep-tail dropped. */ onSessionData(sessionId: string, data: string): void { + const previousMode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId) + if (previousMode2031State || data.includes('\x1b') || data.includes('\x9b')) { + const mode2031Result = scanMode2031ReplyDecision( + previousMode2031State ?? INITIAL_MODE_2031_REPLY_SCAN_STATE, + data + ) + if (mode2031Result.state.tail.length > 0 || mode2031Result.state.pendingSubscribe) { + this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031Result.state) + } else { + this.mode2031ReplyScanStateBySessionId.delete(sessionId) + } + } // titleScanData:'' skips title extraction (titles stay main-authoritative) // and keeps the stale-working-title timer permanently unarmed — only the // four transient scanners consume the chunk. this.trackersBySessionId.get(sessionId)?.handleChunk(data, { titleScanData: '' }) } + getMode2031ReplyScanState(sessionId: string): Mode2031ReplyScanState { + return ( + this.mode2031ReplyScanStateBySessionId.get(sessionId) ?? INITIAL_MODE_2031_REPLY_SCAN_STATE + ) + } + onSessionExit(sessionId: string): void { this.disposeTracker(sessionId) + this.mode2031ReplyScanStateBySessionId.delete(sessionId) } dispose(): void { for (const sessionId of Array.from(this.trackersBySessionId.keys())) { this.disposeTracker(sessionId) } + this.mode2031ReplyScanStateBySessionId.clear() } private disposeTracker(sessionId: string): void { diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index edefd8fa02b..790042c840a 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,12 +3,14 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(28) + expect(PROTOCOL_VERSION).toBe(29) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(25) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(26) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(27) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28) }) }) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 025d7838e7b..e01a4f58053 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -4,19 +4,33 @@ import { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, - PROTOCOL_VERSION + PROTOCOL_VERSION, + supportsMode2031UnsubscribeFact } from './daemon-protocol-version' describe('daemon protocol version', () => { - it('ships preflight-cache replacement after completion inspection', () => { - expect(PROTOCOL_VERSION).toBe(28) + it('ships the 2031-unsubscribe fact after preflight-cache replacement', () => { + expect(PROTOCOL_VERSION).toBe(29) + expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29) expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27) expect(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(11) expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 27 }, (_, index) => index + 1) + Array.from({ length: 28 }, (_, index) => index + 1) ) }) + + it('withholds 2031-unsubscribe support from every preserved older daemon', () => { + // Why (#9993): v28 is what ships today, so a v28 daemon preserved across an app + // update is the live hazard — it emits '2031-subscribe' with no way to retract it. + // The boundary must sit at 29, not merely "recent enough". + expect(supportsMode2031UnsubscribeFact(PROTOCOL_VERSION)).toBe(true) + expect(supportsMode2031UnsubscribeFact(28)).toBe(false) + for (const version of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) { + expect(supportsMode2031UnsubscribeFact(version)).toBe(false) + } + }) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index f2a890aac09..f999b6c22b4 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,6 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v28 replaces v26/v27 daemons that can retain permanent macOS preflight rejections (#9756). -export const PROTOCOL_VERSION = 28 +// v29 emits '2031-unsubscribe' transient facts; v20-28 emit only '2031-subscribe' (#9993). +export const PROTOCOL_VERSION = 29 export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27 export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11 export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25 @@ -8,10 +8,26 @@ export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26 export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26 export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 +// Why (#9993): a gate-managed pane's bytes never reach the renderer, so main's +// transient facts are the only thing that can retire a 2031 subscription for it. +// Daemons before this version emit '2031-subscribe' but have no unsubscribe fact +// at all, so a TUI exiting while hidden would leave the subscription registered +// forever and the next theme flip would inject CSI 997 into whatever replaced it. +// Scan authority moves to the daemon only while a session is backgrounded, so the +// gate lives on backgrounding itself (setPtyBackgrounded): a pre-v29 daemon is +// never asked to thin, and main's scanner — which emits BOTH facts — stays +// authoritative over the whole stream. Filtering the subscribe fact alone would +// not help, because the visible-era subscription is registered by main. +export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, + 28 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION } + +export function supportsMode2031UnsubscribeFact(protocolVersion: number): boolean { + return protocolVersion >= MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index cec5bd880aa..b37feba878e 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -765,6 +765,177 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + describe('mode 2031 fact compatibility (#9993)', () => { + let onEventSpy: ReturnType + // Why these tests exist: daemons survive app updates, so a NEW desktop can be + // driving a PRESERVED older daemon. Those daemons emit '2031-subscribe' but have + // no unsubscribe fact at all. For a gate-managed pane the renderer never sees the + // bytes, so main's facts are the only thing that can retire the subscription — + // trusting a subscribe that can never be retracted leaves it live forever, and the + // next theme flip injects CSI 997 into whatever shell replaced the exited TUI. + function captureForwardedFacts(target: DaemonPtyAdapter): { + kinds: () => string[] + emit: (fact: { kind: string }) => void + } { + const forwarded: string[] = [] + target.onBackgroundStreamEvent((payload) => { + if (payload.kind === 'transientFact') { + forwarded.push((payload.fact as { kind: string }).kind) + } + }) + const listeners: ((event: unknown) => void)[] = [] + onEventSpy = vi.spyOn(DaemonClient.prototype, 'onEvent').mockImplementation((listener) => { + listeners.push(listener) + return () => {} + }) + return { + kinds: () => forwarded, + emit: (fact) => { + expect(listeners.length).toBeGreaterThan(0) + for (const listener of listeners) { + // `type: 'event'` is the envelope the routing switch requires. + listener({ + type: 'event', + event: 'transientFact', + sessionId: 'session-1', + payload: fact + }) + } + } + } + } + + it('drops a pre-v29 daemon 2031-subscribe it could never retract', () => { + // v28 is the version shipping today, so this is the live upgrade hazard: a v28 + // daemon preserved across an app update, still holding real sessions. + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 }) + try { + const captured = captureForwardedFacts(legacy) + // Force a fresh wire-up so the spy above is the listener the adapter installs. + legacy['removeEventListener'] = null + legacy['setupEventRouting']() + captured.emit({ kind: '2031-subscribe' }) + captured.emit({ kind: 'bell' }) + + // The unretractable subscribe is withheld; unrelated facts still flow, so the + // gate is narrow rather than "ignore this daemon's facts". + expect(captured.kinds()).toEqual(['bell']) + } finally { + legacy.dispose() + onEventSpy.mockRestore() + } + }) + + it('never delegates scan authority to a v28 daemon, so a hidden withdrawal is still seen', () => { + // The scenario the fact filter alone does NOT cover, and the reason the gate sits + // on backgrounding: while the pane is VISIBLE, main's own scanner registers the + // 2031 subscribe (bytes transit main either way). Only backgrounding hands scan + // authority to the daemon. If a v28 daemon were allowed to take it, the TUI could + // exit while hidden with no party able to emit the withdrawal — #9993 via upgrade. + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 }) + try { + legacy.setPtyBackgrounded('v28-session', true) + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'v28-session', + background: false + }) + } finally { + legacy.dispose() + notifySpy.mockRestore() + } + }) + + it('delegates scan authority to a v29 daemon, which can retract', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + try { + current.setPtyBackgrounded('v29-session', true) + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'v29-session', + background: true + }) + } finally { + current.dispose() + notifySpy.mockRestore() + } + }) + + it('forwards a provisional subscribe with the foreground handoff marker', () => { + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + const forwarded: unknown[] = [] + current.onBackgroundStreamEvent((payload) => forwarded.push(payload)) + const listeners: ((event: unknown) => void)[] = [] + onEventSpy = vi.spyOn(DaemonClient.prototype, 'onEvent').mockImplementation((listener) => { + listeners.push(listener) + return () => {} + }) + try { + current['removeEventListener'] = null + current['setupEventRouting']() + for (const listener of listeners) { + listener({ + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: 'session-1', + payload: { + background: false, + scanSeedAnsi: '\x1b[?', + mode2031PendingSubscribe: true + } + }) + } + + expect(forwarded).toEqual([ + { + id: 'session-1', + kind: 'backgroundMarker', + background: false, + scanSeedAnsi: '\x1b[?', + mode2031PendingSubscribe: true + } + ]) + } finally { + current.dispose() + onEventSpy.mockRestore() + } + }) + + it('forwards a v28 unsubscribe, which can only retire state main registered', () => { + // Asymmetric on purpose: an unretractable subscribe is the hazard, a withdrawal + // never is. A stale relay tracker on a preserved daemon must still be able to + // clear a subscription rather than be silenced into stranding it. + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 }) + try { + const captured = captureForwardedFacts(legacy) + legacy['removeEventListener'] = null + legacy['setupEventRouting']() + captured.emit({ kind: '2031-unsubscribe' }) + + expect(captured.kinds()).toEqual(['2031-unsubscribe']) + } finally { + legacy.dispose() + onEventSpy.mockRestore() + } + }) + + it('forwards 2031 facts from a v29 daemon that can retract them', () => { + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + try { + const captured = captureForwardedFacts(current) + current['removeEventListener'] = null + current['setupEventRouting']() + captured.emit({ kind: '2031-subscribe' }) + captured.emit({ kind: '2031-unsubscribe' }) + + expect(captured.kinds()).toEqual(['2031-subscribe', '2031-unsubscribe']) + } finally { + current.dispose() + onEventSpy.mockRestore() + } + }) + }) + describe('background stream thinning compatibility', () => { it('reports authoritative snapshot support only for protocol v20 and newer', () => { const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) @@ -834,6 +1005,111 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it.each([ + { protocolVersion: 28, clearsHint: true }, + { protocolVersion: 29, clearsHint: false } + ])( + 'uses protocol v$protocolVersion background authority before spawn', + async ({ protocolVersion, clearsHint }) => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: true, + pid: null, + shellState: 'unsupported', + snapshot: null + } as never) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const target = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion }) + const sessionId = `spawn-v${protocolVersion}-session` + try { + await target.spawn({ sessionId, cols: 80, rows: 24 }) + + if (clearsHint) { + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId, + background: false + }) + expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan( + requestSpy.mock.invocationCallOrder[0] + ) + } else { + expect(notifySpy).not.toHaveBeenCalledWith( + 'setSessionBackground', + expect.objectContaining({ sessionId }) + ) + } + } finally { + target.dispose() + notifySpy.mockRestore() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + } + ) + + it('clears a preserved v28 background hint before attaching, so scan authority comes home', async () => { + // The gate on setPtyBackgrounded only binds THIS process. Daemons outlive the desktop: + // a v28 that a previous desktop backgrounded is still scanning when a new desktop + // attaches, and this process never called setPtyBackgrounded for it — so without the + // pre-attach clear the daemon keeps authority it can never retract (#9993). + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: false, + pid: 4242, + shellState: 'unsupported', + snapshot: null + } as never) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 }) + try { + await legacy.attach('preserved-v28-session') + + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'preserved-v28-session', + background: false + }) + expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan( + requestSpy.mock.invocationCallOrder[0] + ) + } finally { + legacy.dispose() + notifySpy.mockRestore() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + + it('leaves a v29 background hint alone on attach, because it can retract on its own', async () => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: false, + pid: 4242, + shellState: 'unsupported', + snapshot: null + } as never) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 }) + try { + await current.attach('preserved-v29-session') + + expect(notifySpy).not.toHaveBeenCalledWith( + 'setSessionBackground', + expect.objectContaining({ sessionId: 'preserved-v29-session' }) + ) + } finally { + current.dispose() + notifySpy.mockRestore() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + it('still returns the v19 attach snapshot for a desktop renderer replay', async () => { const ensureConnectedSpy = vi .spyOn(DaemonClient.prototype, 'ensureConnected') diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 02c2c9aa775..57cc9d55e54 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -16,6 +16,7 @@ import { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PROTOCOL_VERSION, + supportsMode2031UnsubscribeFact, supportsPtyStartupIngress, type CreateOrAttachResult, type DaemonEvent, @@ -200,6 +201,16 @@ export class DaemonPtyAdapter implements IPtyProvider { return this.supportsAuthoritativeBufferSnapshots } + // Why one predicate (#9993): the attach-time clear and setPtyBackgrounded must agree on + // which daemons may hold a background hint. Daemons outlive the desktop that set it, so + // if these two drift a preserved daemon keeps a hint this process would never grant. + private get canDelegateBackgroundToDaemon(): boolean { + return ( + this.supportsAuthoritativeBufferSnapshots && + supportsMode2031UnsubscribeFact(this.protocolVersion) + ) + } + constructor(opts: DaemonPtyAdapterOptions) { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION this.socketPath = opts.socketPath @@ -360,8 +371,10 @@ export class DaemonPtyAdapter implements IPtyProvider { } await this.ensureConnected() - // Why before createOrAttach: a preserved v19 daemon may still think this session is backgrounded; clear it before attached bytes get thinned without a recoverable seq. - if (!this.supportsAuthoritativeBufferSnapshots) { + // Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from + // a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop + // handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached. + if (!this.canDelegateBackgroundToDaemon) { this.setPtyBackgrounded(sessionId, false) } @@ -717,7 +730,7 @@ export class DaemonPtyAdapter implements IPtyProvider { async attach(id: string): Promise { await this.ensureConnected() - if (!this.supportsAuthoritativeBufferSnapshots) { + if (!this.canDelegateBackgroundToDaemon) { this.setPtyBackgrounded(id, false) } @@ -785,7 +798,12 @@ export class DaemonPtyAdapter implements IPtyProvider { return } // Why: preserved v19 daemons can thin but can't return the absolute snapshot sequence to recover a gap; clear their stale hint too. - const safeBackground = this.supportsAuthoritativeBufferSnapshots && background + // Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan + // authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never + // retract it, so a TUI exiting while hidden would strand the subscription and the + // next theme flip would inject CSI 997 into its replacement shell. Declining to + // background keeps main's scanner — which emits both facts — authoritative. + const safeBackground = this.canDelegateBackgroundToDaemon && background if (safeBackground) { this.backgroundedSessionIds.add(id) } else { @@ -1913,6 +1931,9 @@ export class DaemonPtyAdapter implements IPtyProvider { background: event.payload.background, ...(event.payload.scanSeedAnsi !== undefined ? { scanSeedAnsi: event.payload.scanSeedAnsi } + : {}), + ...(event.payload.mode2031PendingSubscribe + ? { mode2031PendingSubscribe: true as const } : {}) }) } else if (event.event === 'dataGap') { @@ -1925,6 +1946,18 @@ export class DaemonPtyAdapter implements IPtyProvider { : { sequenceChars: event.payload.sequenceChars }) }) } else if (event.event === 'transientFact') { + // Why (#9993): belt-and-braces behind the setPtyBackgrounded gate. A pre-v29 + // daemon is never asked to background, so it should emit no transient facts at + // all — but one preserved across a reconnect could still have a stale relay + // tracker. An unretractable subscribe is the harmful direction, so drop it. + // An unsubscribe is always forwarded: retiring a subscription main registered + // can only ever help, never strand one. + if ( + event.payload.kind === '2031-subscribe' && + !supportsMode2031UnsubscribeFact(this.protocolVersion) + ) { + return + } this.emitBackgroundStreamEvent({ id: event.sessionId, kind: 'transientFact', diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index f55809a131a..0ac8d158b27 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -868,14 +868,20 @@ export class DaemonServer { return {} } // Reveal intentionally keeps the queued tail: main needs those bytes, and the normal flush/drain delivers them in order ahead of the marker. - const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId) + const mode2031State = this.transientFactRelay.getMode2031ReplyScanState(sessionId) + const scanSeedAnsi = background + ? '' + : mode2031State.pendingSubscribe + ? mode2031State.tail + : this.host.getPartialEscapeTailAnsi(sessionId) this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { type: 'event', event: 'sessionBackgroundMarker', sessionId, payload: { background, - ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}) + ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}), + ...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {}) } }) return {} diff --git a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts index 76cdc72a990..b41876604d5 100644 --- a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts +++ b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts @@ -175,6 +175,37 @@ describe('daemon stream droppability lifecycle', () => { expect(lifecycle).toEqual(['refresh:false', 'marker:false']) }) + it('returns a provisional 2031 subscribe with the foreground handoff marker', async () => { + const harness = createServerHarness() + server = harness.server + const { daemon } = harness + addClient(daemon) + daemon.streamClientIdBySessionId.set('session-toggle', 'client-1') + daemon.transientFactRelay.setSessionBackground('session-toggle', true) + daemon.transientFactRelay.onSessionData('session-toggle', '\x1b[?2031h\x1b[?') + vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('\x1b[?') + const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent') + + await daemon.routeRequest('client-1', { + id: 'foreground', + type: 'setSessionBackground', + payload: { sessionId: 'session-toggle', background: false } + }) + + expect(enqueue).toHaveBeenCalledWith( + 'client-1', + 'session-toggle', + expect.objectContaining({ + event: 'sessionBackgroundMarker', + payload: { + background: false, + scanSeedAnsi: '\x1b[?', + mode2031PendingSubscribe: true + } + }) + ) + }) + it('routes an attached session before refresh and emits its background marker after', async () => { const harness = createServerHarness() server = harness.server diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts index 1d2caa2c77a..62519016ca9 100644 --- a/src/main/daemon/daemon-stream-events.ts +++ b/src/main/daemon/daemon-stream-events.ts @@ -41,12 +41,17 @@ export type TerminalErrorEvent = { * exactly this position so no fact double-fires or goes missing. * scanSeedAnsi (un-background only) carries the emulator's dangling * incomplete escape so main can prime its fresh scanner carry — a sequence - * split across the handoff must not mint a phantom bell or lose its fact. */ + * split across the handoff must not mint a phantom bell or lose its fact. + * mode2031PendingSubscribe preserves a subscribe deferred behind that tail. */ export type SessionBackgroundMarkerEvent = { type: 'event' event: 'sessionBackgroundMarker' sessionId: string - payload: { background: boolean; scanSeedAnsi?: string } + payload: { + background: boolean + scanSeedAnsi?: string + mode2031PendingSubscribe?: true + } } /** A backgrounded session's oldest undelivered output was dropped at the @@ -69,6 +74,7 @@ export type DaemonTransientFact = | { kind: 'command-finished'; exitCode: number | null } | { kind: 'pr-link'; link: TerminalGitHubPRLink } | { kind: '2031-subscribe' } + | { kind: '2031-unsubscribe' } export type TransientFactEvent = { type: 'event' diff --git a/src/main/daemon/terminal-history-incremental-restore.test.ts b/src/main/daemon/terminal-history-incremental-restore.test.ts index 234f6af8c6a..88d6d71bf94 100644 --- a/src/main/daemon/terminal-history-incremental-restore.test.ts +++ b/src/main/daemon/terminal-history-incremental-restore.test.ts @@ -278,14 +278,16 @@ describe('incremental terminal history restore', () => { const firstReplay = reader.detectColdRestore(SESSION_ID) const secondReplay = reader.detectColdRestore(secondSessionId) try { + // Drain by yield because awaiting the session that loses the replay-slot race deadlocks. await vi.waitFor(() => expect(pendingYields).toHaveLength(1)) - pendingYields.shift()!() - expect((await firstReplay)?.scrollbackAnsi).toContain('😀second') + await vi.waitFor(() => expect(pendingYields).toHaveLength(1)) - pendingYields.shift()!() - expect((await secondReplay)?.scrollbackAnsi).toContain('😀second') + + for (const restore of await Promise.all([firstReplay, secondReplay])) { + expect(restore?.scrollbackAnsi).toContain('😀second') + } expect(pendingYields).toHaveLength(0) } finally { for (const resume of pendingYields.splice(0)) { diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index aaa65028c60..53fc791bb05 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -32,6 +32,8 @@ export { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, PTY_STARTUP_INGRESS_PROTOCOL_VERSION, + MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, + supportsMode2031UnsubscribeFact, supportsPtyStartupIngress } from './daemon-protocol-version' diff --git a/src/main/git/status-branch-compare-real-ref.test.ts b/src/main/git/status-branch-compare-real-ref.test.ts new file mode 100644 index 00000000000..ce8400d511e --- /dev/null +++ b/src/main/git/status-branch-compare-real-ref.test.ts @@ -0,0 +1,55 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getBranchCompare } from './status' + +const tempRoots: string[] = [] + +function git(repo: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('getBranchCompare real refs', () => { + it('preserves the raw oid of a remote-tracking ref that stores an annotated tag', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-compare-ref-')) + tempRoots.push(root) + const source = path.join(root, 'source') + const client = path.join(root, 'client') + + execFileSync('git', ['init', '-q', source]) + git(source, ['config', 'user.email', 'test@example.com']) + git(source, ['config', 'user.name', 'Test User']) + git(source, ['config', 'commit.gpgSign', 'false']) + git(source, ['config', 'tag.gpgSign', 'false']) + git(source, ['commit', '--allow-empty', '-m', 'initial']) + git(source, ['tag', '-a', 'annotated', '-m', 'annotated base']) + execFileSync('git', ['clone', '-q', source, client]) + git(client, ['fetch', source, 'refs/tags/annotated:refs/remotes/origin/tagbase']) + + expect(git(client, ['branch', '-r', '--format=%(refname:short)']).split(/\r?\n/)).toContain( + 'origin/tagbase' + ) + const rawOid = git(client, ['rev-parse', '--verify', 'refs/remotes/origin/tagbase']) + const peeledOid = git(client, [ + 'rev-parse', + '--verify', + '--quiet', + 'refs/remotes/origin/tagbase^{commit}' + ]) + expect(rawOid).not.toBe(peeledOid) + + const result = await getBranchCompare(client, 'origin/tagbase') + + expect(result.summary).toMatchObject({ baseOid: rawOid, status: 'ready' }) + }) +}) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index c39a4097825..abe618a327a 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -1929,21 +1929,72 @@ describe('getBranchCompare', () => { readFileMock.mockReset() }) + // Why dispatch on args instead of mockResolvedValueOnce chains: getBranchCompare now + // issues its head-of-chain reads concurrently, so a positional mock would encode call + // order rather than behaviour and break on any safe reordering. + type BranchCompareGitResponses = { + branch?: string | Error + probe?: Record + headOid?: string | Error + baseOid?: string | Error + mergeBase?: string | Error + nameStatus?: string | Error + numstat?: string | Error + revList?: string | Error + } + + function mockBranchCompareGit(responses: BranchCompareGitResponses): void { + const reply = ( + value: string | Error | undefined, + label: string + ): Promise<{ stdout: string }> => { + if (value === undefined) { + throw new Error(`unexpected git call: ${label}`) + } + return value instanceof Error ? Promise.reject(value) : Promise.resolve({ stdout: value }) + } + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'branch') { + return reply(responses.branch, 'branch --show-current') + } + if (args[0] === 'rev-parse' && args.includes('--quiet')) { + const probed = args.find((arg) => arg.endsWith('^{commit}')) ?? '' + return reply(responses.probe?.[probed], `probe ${probed}`) + } + if (args[0] === 'rev-parse' && args.includes('HEAD')) { + return reply(responses.headOid, 'rev-parse HEAD') + } + if (args[0] === 'rev-parse') { + return reply(responses.baseOid, `rev-parse ${args.at(-1)}`) + } + if (args[0] === 'merge-base') { + return reply(responses.mergeBase, 'merge-base') + } + if (args.includes('--name-status')) { + return reply(responses.nameStatus, 'diff --name-status') + } + if (args.includes('--numstat')) { + return reply(responses.numstat, 'diff --numstat') + } + if (args[0] === 'rev-list') { + return reply(responses.revList, 'rev-list') + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) + } + it('returns a pinned branch compare snapshot and parsed branch entries', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'main\n' }) - .mockResolvedValueOnce({ stdout: 'remote-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'head-oid\n' }) - .mockResolvedValueOnce({ stdout: 'base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ - stdout: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n' - }) - .mockResolvedValueOnce({ - stdout: - '10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n' - }) - .mockResolvedValueOnce({ stdout: '7\n' }) + mockBranchCompareGit({ + branch: 'main\n', + probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' }, + headOid: 'head-oid\n', + baseOid: 'base-oid\n', + mergeBase: 'merge-base-oid\n', + nameStatus: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n', + numstat: + '10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n', + revList: '7\n' + }) const result = await getBranchCompare('/repo', 'origin/main') @@ -1965,12 +2016,15 @@ describe('getBranchCompare', () => { }) it('returns invalid-base when the compare ref does not resolve', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'main\n' }) - .mockRejectedValueOnce(new Error('missing remote base')) - .mockRejectedValueOnce(new Error('missing local base')) - .mockResolvedValueOnce({ stdout: 'head-oid\n' }) - .mockRejectedValueOnce(new Error('missing base')) + mockBranchCompareGit({ + branch: 'main\n', + probe: { + 'refs/remotes/origin/missing^{commit}': new Error('missing remote base'), + 'refs/heads/origin/missing^{commit}': new Error('missing local base') + }, + headOid: 'head-oid\n', + baseOid: new Error('missing base') + }) const result = await getBranchCompare('/repo', 'origin/missing') @@ -1980,11 +2034,13 @@ describe('getBranchCompare', () => { }) it('returns unborn-head when HEAD cannot be resolved', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'main\n' }) - .mockResolvedValueOnce({ stdout: 'remote-base-oid\n' }) - .mockRejectedValueOnce(new Error('unborn')) - .mockRejectedValueOnce(new Error('missing base')) + mockBranchCompareGit({ + branch: 'main\n', + // Why the probe fails here: a proven base ref would resolve, giving 'ready'. + probe: { 'refs/remotes/origin/main^{commit}': new Error('missing base') }, + headOid: new Error('unborn'), + baseOid: new Error('missing base') + }) const result = await getBranchCompare('/repo', 'origin/main') @@ -1994,11 +2050,12 @@ describe('getBranchCompare', () => { }) it('treats an unborn branch with a resolvable base as having no committed branch changes', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'feature\n' }) - .mockResolvedValueOnce({ stdout: 'remote-base-oid\n' }) - .mockRejectedValueOnce(new Error('unborn')) - .mockResolvedValueOnce({ stdout: 'base-oid\n' }) + mockBranchCompareGit({ + branch: 'feature\n', + probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' }, + headOid: new Error('unborn'), + baseOid: 'base-oid\n' + }) const result = await getBranchCompare('/repo', 'origin/main') @@ -2016,12 +2073,13 @@ describe('getBranchCompare', () => { }) it('returns no-merge-base when histories do not intersect', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'main\n' }) - .mockResolvedValueOnce({ stdout: 'remote-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'head-oid\n' }) - .mockResolvedValueOnce({ stdout: 'base-oid\n' }) - .mockRejectedValueOnce(new Error('no merge base')) + mockBranchCompareGit({ + branch: 'main\n', + probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' }, + headOid: 'head-oid\n', + baseOid: 'base-oid\n', + mergeBase: new Error('no merge base') + }) const result = await getBranchCompare('/repo', 'origin/main') @@ -2031,20 +2089,20 @@ describe('getBranchCompare', () => { }) it('passes core.quotePath=false to diff --name-status and parses UTF-8 paths', async () => { - gitExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'main\n' }) - .mockResolvedValueOnce({ stdout: 'remote-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'head-oid\n' }) - .mockResolvedValueOnce({ stdout: 'base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) - .mockResolvedValueOnce({ stdout: 'M\tdocs/日本語/sample.md\n' }) - .mockResolvedValueOnce({ stdout: '2\t1\tdocs/日本語/sample.md\n' }) - .mockResolvedValueOnce({ stdout: '1\n' }) + mockBranchCompareGit({ + branch: 'main\n', + probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' }, + headOid: 'head-oid\n', + baseOid: 'base-oid\n', + mergeBase: 'merge-base-oid\n', + nameStatus: 'M\tdocs/日本語/sample.md\n', + numstat: '2\t1\tdocs/日本語/sample.md\n', + revList: '1\n' + }) const result = await getBranchCompare('/repo', 'origin/main') - expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( - 6, + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( [ '-c', 'core.quotePath=false', @@ -2062,7 +2120,70 @@ describe('getBranchCompare', () => { ]) }) - it('compares short remote labels through fully qualified remote-tracking refs', async () => { + // Why: the base oid now comes from the probe, so the probe must never report a ref it + // did not actually resolve -- an empty rev-parse --quiet stdout means "not found". + it('treats an empty probe result as an unresolved base ref', async () => { + mockBranchCompareGit({ + branch: 'main\n', + probe: { + 'refs/remotes/origin/main^{commit}': '\n', + 'refs/heads/origin/main^{commit}': '\n' + }, + headOid: 'head-oid\n', + baseOid: new Error('missing base') + }) + + const result = await getBranchCompare('/repo', 'origin/main') + + expect(result.summary.status).toBe('invalid-base') + }) + + // Why: the probe tries refs/remotes first, then refs/heads. Reusing "the last probed + // oid" rather than the oid for the ref that won would return the wrong commit. + it('reuses only the oid of the ref the probe actually resolved', async () => { + mockBranchCompareGit({ + branch: 'feature\n', + probe: { + 'refs/remotes/origin/main^{commit}': new Error('no remote-tracking ref'), + 'refs/heads/origin/main^{commit}': 'local-branch-oid\n' + }, + headOid: 'head-oid\n', + mergeBase: 'merge-base-oid\n', + nameStatus: '', + numstat: '', + revList: '0\n' + }) + + const result = await getBranchCompare('/repo', 'origin/main') + + expect(result.summary).toMatchObject({ + baseOid: 'local-branch-oid', + status: 'ready' + }) + }) + + // Why: an already-qualified base ref skips the probe entirely, so its oid must still + // come from rev-parse rather than from a stale or absent probe entry. + it('resolves an already-qualified base ref without a probe', async () => { + mockBranchCompareGit({ + branch: 'main\n', + headOid: 'head-oid\n', + baseOid: 'qualified-base-oid\n', + mergeBase: 'merge-base-oid\n', + nameStatus: '', + numstat: '', + revList: '0\n' + }) + + const result = await getBranchCompare('/repo', 'refs/remotes/origin/main') + + expect(result.summary).toMatchObject({ + baseOid: 'qualified-base-oid', + status: 'ready' + }) + }) + + it('resolves remote-tracking refs separately after probing their commit target', async () => { gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args[0] === 'branch') { return Promise.resolve({ stdout: 'feature\n' }) @@ -2072,13 +2193,13 @@ describe('getBranchCompare', () => { args.includes('--quiet') && args.includes('refs/remotes/origin/main^{commit}') ) { - return Promise.resolve({ stdout: 'remote-base-oid\n' }) + return Promise.resolve({ stdout: 'peeled-base-oid\n' }) } if (args[0] === 'rev-parse' && args.includes('HEAD')) { return Promise.resolve({ stdout: 'head-oid\n' }) } if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) { - return Promise.resolve({ stdout: 'base-oid\n' }) + return Promise.resolve({ stdout: 'raw-base-oid\n' }) } if (args[0] === 'merge-base') { return Promise.resolve({ stdout: 'merge-base-oid\n' }) @@ -2099,9 +2220,13 @@ describe('getBranchCompare', () => { expect(result.summary).toMatchObject({ baseRef: 'origin/main', - baseOid: 'base-oid', + baseOid: 'raw-base-oid', status: 'ready' }) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], + { cwd: '/repo' } + ) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['rev-parse', '--verify', '--end-of-options', 'refs/remotes/origin/main'], { cwd: '/repo' } diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 1375e4dca87..ece3c53b2cf 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -44,8 +44,9 @@ import { removeSafeUntrackedDiscardTarget, removeSafeUntrackedDiscardTargets } from '../../shared/git-discard-path-safety' +import { readBranchCompareHead } from '../../shared/git-branch-compare-head' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' -import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe' import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit' import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import type { GitRuntimeOptions } from './git-runtime-options' @@ -1304,29 +1305,44 @@ export async function getBranchCompare( status: 'loading' } - const compareRef = await resolveCompareRef(worktreePath, options) + // The base-ref probe peels to a commit. Only branch refs are guaranteed to store + // commits; remote-tracking refs may store annotated tags whose raw oid must be preserved. + const reusableProbedOidByRef = new Map() + const { compareRef, headOidResult, baseOidResult } = await readBranchCompareHead({ + readCompareRef: () => resolveCompareRef(worktreePath, options), + resolveBaseRef: () => + // Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref. + resolveWorktreeAddBaseRef(baseRef, async (qualifiedRef) => { + const oid = await resolveWorktreeBaseCommitOid(worktreePath, qualifiedRef, options) + if (oid !== null && qualifiedRef.startsWith('refs/heads/')) { + reusableProbedOidByRef.set(qualifiedRef, oid) + } + return oid !== null + }), + readHeadOid: () => resolveRefOid(worktreePath, 'HEAD', options), + readBaseOid: (ref) => { + const reusableOid = reusableProbedOidByRef.get(ref) + return reusableOid === undefined + ? resolveRefOid(worktreePath, ref, options) + : Promise.resolve(reusableOid) + } + }) summary.compareRef = compareRef - // Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref. - const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) => - hasWorktreeBaseCommitRef(worktreePath, qualifiedRef, options) - ) let headOid = '' let baseOid = '' - try { - headOid = await resolveRefOid(worktreePath, 'HEAD', options) + if (headOidResult.ok) { + headOid = headOidResult.oid summary.headOid = headOid - } catch { - try { - baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options) + } else { + if (baseOidResult.ok) { + baseOid = baseOidResult.oid summary.baseOid = baseOid // Why: an unborn branch (new remote worktree) has no changes yet; a compare error would look broken. summary.changedFiles = 0 summary.commitsAhead = 0 summary.status = 'ready' return { summary, entries: [] } - } catch { - // Preserve the unborn-head message when even the base is unresolvable. } summary.status = 'unborn-head' summary.errorMessage = @@ -1334,10 +1350,10 @@ export async function getBranchCompare( return { summary, entries: [] } } - try { - baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options) + if (baseOidResult.ok) { + baseOid = baseOidResult.oid summary.baseOid = baseOid - } catch { + } else { summary.status = 'invalid-base' summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.` return { summary, entries: [] } diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index c7144159dfe..44b8d4d85e9 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -4,11 +4,17 @@ type GitExecOptions = { wslDistro?: string } -export async function hasWorktreeBaseCommitRef( +/** + * Returns the probed commit oid, or null when the ref does not resolve. + * + * Why expose the oid: the probe already prints it, and callers that then need the + * same ref's oid were re-spawning `rev-parse` for a value this call threw away. + */ +export async function resolveWorktreeBaseCommitOid( repoPath: string, qualifiedRef: string, options: GitExecOptions = {} -): Promise { +): Promise { try { const { stdout } = await gitExecFileAsync( ['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], @@ -17,8 +23,17 @@ export async function hasWorktreeBaseCommitRef( ...options } ) - return stdout.trim().length > 0 + const oid = stdout.trim() + return oid.length > 0 ? oid : null } catch { - return false + return null } } + +export async function hasWorktreeBaseCommitRef( + repoPath: string, + qualifiedRef: string, + options: GitExecOptions = {} +): Promise { + return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null +} diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 3ae3e85fdf1..afdf4b3661c 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -50,6 +50,56 @@ describe('dashboard payload validation', () => { ).toBe(false) }) + it('accepts repo icons a pop-out can safely render, and rejects the rest', () => { + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + repoIconsByRepoId: { + 'repo-1': { type: 'lucide', name: 'Rocket' }, + 'repo-2': null, + 'repo-3': { + type: 'image', + src: 'https://github.com/anthropics.png?size=64', + source: 'github' + } + } + }) + ).toBe(true) + // Absent entirely: a pop-out on older code still gets its snapshot. + expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: undefined })).toBe(true) + + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + repoIconsByRepoId: { + 'repo-1': { type: 'image', src: 'javascript:alert(1)', source: 'file' } + } + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + repoIconsByRepoId: { 'repo-1': { type: 'nonsense' } } + }) + ).toBe(false) + expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: [] })).toBe(false) + }) + + it('bounds the conversation name', () => { + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], conversationName: 'Sparse-checkout parser' }] + }) + ).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], conversationName: 'x'.repeat(1_025) }] + }) + ).toBe(false) + }) + it('requires complete bounded reveal routing', () => { expect( isDashboardRevealAgentArgs({ diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 54168d1904a..69abed2c930 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -1,4 +1,5 @@ import type { DashboardRevealAgentArgs, DashboardSnapshot } from '../../shared/dashboard-snapshot' +import { sanitizeRepoIcon } from '../../shared/repo-icon' import { AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, @@ -7,6 +8,7 @@ import { } from '../../shared/agent-status-types' const MAX_DASHBOARD_CARDS = 1_000 +const MAX_DASHBOARD_REPO_ICONS = 500 const MAX_ID_LENGTH = 4_096 const MAX_LABEL_LENGTH = 1_024 const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'idle']) @@ -50,7 +52,28 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot isFiniteNumber(snapshot.generatedAt) && Array.isArray(snapshot.cards) && snapshot.cards.length <= MAX_DASHBOARD_CARDS && - snapshot.cards.every(isDashboardCard) + snapshot.cards.every(isDashboardCard) && + isDashboardRepoIcons(snapshot.repoIconsByRepoId) + ) +} + +/** Repo icons reach the pop-out's ``, so each one must survive the + * same sanitizer the settings picker writes through. */ +function isDashboardRepoIcons(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const entries = Object.entries(value as Record) + return ( + entries.length <= MAX_DASHBOARD_REPO_ICONS && + entries.every( + ([repoId, icon]) => + isBoundedString(repoId, MAX_ID_LENGTH) && + (icon === null || sanitizeRepoIcon(icon) !== undefined) + ) ) } @@ -80,6 +103,7 @@ function isDashboardCard(value: unknown): boolean { (card.finishedAt === null || isFiniteNumber(card.finishedAt)) && isFiniteNumber(card.stateChangedAt) && typeof card.unseen === 'boolean' && - isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) + isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) && + isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH) ) } diff --git a/src/main/ipc/pty-pending-data-drain-contract.ts b/src/main/ipc/pty-pending-data-drain-contract.ts index 1768fb8c9d4..05fbd2ff77e 100644 --- a/src/main/ipc/pty-pending-data-drain-contract.ts +++ b/src/main/ipc/pty-pending-data-drain-contract.ts @@ -1,3 +1,5 @@ +import type { Mode2031ReplyScanState } from '../../shared/terminal-color-scheme-protocol' + export type PendingPtyData = { data: string startSeq?: number @@ -5,6 +7,8 @@ export type PendingPtyData = { transformed?: true containsBackgroundOutput?: boolean droppedOutput?: true + droppedMode2031Data?: string + droppedMode2031ScanState?: Mode2031ReplyScanState } export type PtyPendingDataDrainDisposition = 'active' | 'background' | 'blocked' diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 41a7ffac2eb..2cc43ac24b5 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -11006,17 +11006,19 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) } - // Flood past the pending cap WITH an embedded DSR probe — the writing program blocks on the reply (bench DSR timeout). - mockProc.emitData(`${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}`) - // While latched, a later probe must also be carved out (bounded). - mockProc.emitData(`${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`) + // Flood past the cap with a DSR probe and a mode-2031 withdrawal split at the chunk edge. + mockProc.emitData( + `${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}\x1b[?2031h prompt \x1b[?20` + ) + // While latched, later queries and the withdrawal continuation must still be carved out. + mockProc.emitData(`31l${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`) mainWindow.webContents.send.mockClear() ackData(null, { id: spawn.id, charCount: 512 * 1024 }) vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawn.id, - data: '\x1b[6n\x1b[0c', + data: '\x1b[6n\x1b[0c\x1b[?2031l', droppedOutput: true }) } finally { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 25ccd87b52c..43432c3cd85 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -24,6 +24,11 @@ import type { PtyRendererDeliveryStateReport } from '../../shared/pty-renderer-delivery-health' import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' +import { + INITIAL_MODE_2031_REPLY_SCAN_STATE, + scanMode2031ReplyDecision, + type Mode2031ReplyScanState +} from '../../shared/terminal-color-scheme-protocol' import { type PtyMainDeliveryDiagnostics, type PtyPerPtyDeliveryDiagnostics, @@ -2462,6 +2467,29 @@ export function registerPtyHandlers( return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData } + function scanDroppedMode2031Data( + data: string, + previous: Mode2031ReplyScanState + ): { data: string; state: Mode2031ReplyScanState } { + const result = scanMode2031ReplyDecision(previous, data) + const decisionData = + result.decision === 'subscribed' + ? '\x1b[?2031h' + : result.decision === 'unsubscribed' + ? '\x1b[?2031l' + : '' + return { data: decisionData, state: result.state } + } + + function getDroppedMode2031RendererData(pending: PendingPtyData): string { + const state = pending.droppedMode2031ScanState + if (!state) { + return pending.droppedMode2031Data ?? '' + } + const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : '' + return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail + } + function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData { const capChars = pendingDataCapChars() if (pending.droppedOutput === true || pending.data.length <= capChars) { @@ -2482,10 +2510,13 @@ export function registerPtyHandlers( pendingOverflowMarkedPtys.add(id) } pendingDroppedChars += pending.data.length + const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). return { data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), - droppedOutput: true + droppedOutput: true, + droppedMode2031Data: mode2031.data, + droppedMode2031ScanState: mode2031.state } } @@ -2501,11 +2532,21 @@ export function registerPtyHandlers( ): PendingPtyData { // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. if (existing?.droppedOutput === true) { - if (existing.data.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) { - return existing + const mode2031 = scanDroppedMode2031Data( + data, + existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE + ) + const remainingQueryCapacity = Math.max( + 0, + DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length + ) + const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity) + return { + ...existing, + data: existing.data + salvaged, + droppedMode2031Data: mode2031.data || existing.droppedMode2031Data, + droppedMode2031ScanState: mode2031.state } - const salvaged = extractDroppedPtyQueryBytes(data) - return salvaged ? { ...existing, data: existing.data + salvaged } : existing } const nextContainsBackgroundOutput = existing?.containsBackgroundOutput === true || containsBackgroundOutput @@ -2623,7 +2664,13 @@ export function registerPtyHandlers( pendingData.remove(selection) updateProducerFlowControl(id) // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). - if (!sendPtyDataToRenderer(id, { id, data: pending.data, droppedOutput: true })) { + if ( + !sendPtyDataToRenderer(id, { + id, + data: pending.data + getDroppedMode2031RendererData(pending), + droppedOutput: true + }) + ) { sendFailed = true break } @@ -2849,7 +2896,8 @@ export function registerPtyHandlers( runtime?.setPtyTransientFactDelegation( payload.id, payload.background, - payload.scanSeedAnsi + payload.scanSeedAnsi, + payload.mode2031PendingSubscribe ) return } @@ -2930,7 +2978,7 @@ export function registerPtyHandlers( pending.droppedOutput === true && !overflowMarkedBeforeAppend && pendingOverflowMarkedPtys.has(payload.id) - const nextData = pending.data + const nextData = pending.data + getDroppedMode2031RendererData(pending) const isInteractiveOutput = shouldSendInteractiveOutputNow( payload.id, nextData, diff --git a/src/main/providers/pty-provider-events.ts b/src/main/providers/pty-provider-events.ts index 6de435cb23f..643aecac655 100644 --- a/src/main/providers/pty-provider-events.ts +++ b/src/main/providers/pty-provider-events.ts @@ -15,8 +15,15 @@ export type PtyTransientFact = | { kind: 'command-finished'; exitCode: number | null } | { kind: 'pr-link'; link: TerminalGitHubPRLink } | { kind: '2031-subscribe' } + | { kind: '2031-unsubscribe' } export type PtyBackgroundStreamEvent = - | { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string } + | { + id: string + kind: 'backgroundMarker' + background: boolean + scanSeedAnsi?: string + mode2031PendingSubscribe?: true + } | { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number } | { id: string; kind: 'transientFact'; fact: PtyTransientFact } diff --git a/src/main/runtime/mobile-notification-replay.test.ts b/src/main/runtime/mobile-notification-replay.test.ts index cfee0f199f8..a13c0f75915 100644 --- a/src/main/runtime/mobile-notification-replay.test.ts +++ b/src/main/runtime/mobile-notification-replay.test.ts @@ -102,6 +102,51 @@ describe('MobileNotificationReplayBuffer', () => { ]) }) + it('returns the retained buffer when the watermark came from a previous desktop run', () => { + // The #8591 defect: `seq` is per-process and restarts at 0 every launch, but the + // client's watermark is persisted. A client holding seq 57 meets a counter at 2, + // `57 >= 2` cuts everything, and catch-up stays dead until the new process + // dispatches 57 notifications. The epoch is what makes the two distinguishable. + const previousRun = new MobileNotificationReplayBuffer() + for (let i = 0; i < 57; i++) { + dispatch(previousRun, { notificationId: `old:${i}` }) + } + const staleWatermark = 57 + const staleEpoch = previousRun.epoch + + const afterRestart = new MobileNotificationReplayBuffer() + dispatch(afterRestart, { notificationId: 'agent:one' }) + dispatch(afterRestart, { notificationId: 'agent:two' }) + + // Seq-only (the old behaviour) still silently drops everything... + expect(afterRestart.getMissedSince(staleWatermark)).toEqual([]) + // ...but a watermark tagged with the old epoch is recognised as void. + expect( + afterRestart.getMissedSince(staleWatermark, staleEpoch).map((e) => e.notificationId) + ).toEqual(['agent:one', 'agent:two']) + }) + + it('keeps the exact seq cut when the epoch matches the live counter', () => { + const buffer = new MobileNotificationReplayBuffer() + const first = dispatch(buffer, { notificationId: 'agent:one' }) + dispatch(buffer, { notificationId: 'agent:two' }) + + // Same counter: the epoch must not widen the cut into a re-push of delivered events. + expect( + buffer.getMissedSince(first.notificationSeq, buffer.epoch).map((e) => e.notificationId) + ).toEqual(['agent:two']) + expect(buffer.getMissedSince(2, buffer.epoch)).toEqual([]) + }) + + it('stamps every recorded event with the buffer epoch, and epochs differ per process', () => { + const buffer = new MobileNotificationReplayBuffer() + const event = dispatch(buffer, { notificationId: 'agent:one' }) + + expect(event.notificationEpoch).toBe(buffer.epoch) + // A restart must produce a different epoch or the watermark stays ambiguous. + expect(new MobileNotificationReplayBuffer().epoch).not.toBe(buffer.epoch) + }) + it('evicts oldest entries once the capacity is exceeded', () => { const buffer = new MobileNotificationReplayBuffer(2) dispatch(buffer, { notificationId: 'agent:one' }) diff --git a/src/main/runtime/mobile-notification-replay.ts b/src/main/runtime/mobile-notification-replay.ts index d71b4d83125..56bcac4abb4 100644 --- a/src/main/runtime/mobile-notification-replay.ts +++ b/src/main/runtime/mobile-notification-replay.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import type { MobileNotificationEvent } from './orca-runtime' // Why: when a mobile client's socket is reaped (background/sleep, or a warm @@ -22,6 +23,9 @@ import type { MobileNotificationEvent } from './orca-runtime' // single field name end-to-end is what makes live and replay interchangeable. export type ReplayableMobileNotification = MobileNotificationEvent & { notificationSeq: number + // The counter lifetime `notificationSeq` belongs to. Lets a client tell "seq 3 + // is older than my watermark" from "seq 3 came from a counter I've never seen". + notificationEpoch: string } // Why: bound the buffer. 256 completes a few minutes of real agent activity and @@ -35,17 +39,28 @@ export class MobileNotificationReplayBuffer { private readonly capacity: number private seq = 0 private readonly buffer: ReplayableMobileNotification[] = [] + // Why: `seq` restarts at 0 every desktop launch, but the client's watermark is + // persisted and monotonic. After a desktop restart a client holding seq 57 meets + // a counter at 3, `57 >= 3` cuts everything, and catch-up stays silently dead + // until the new process dispatches 57 notifications. The epoch names the counter's + // lifetime so both sides can tell "nothing missed" from "different counter". + private readonly epochId: string = randomUUID() constructor(capacity: number = DEFAULT_CAPACITY) { this.capacity = capacity } + // Identifies this counter's lifetime. Changes on every desktop restart. + get epoch(): string { + return this.epochId + } + // Records a dispatched event and returns the monotonic seq assigned to it. // Callers surface the seq so clients can watermark their last-seen position // (both on the live fan-out and on explicit catch-up requests). record(event: MobileNotificationEvent): number { const seq = ++this.seq - this.buffer.push({ ...event, notificationSeq: seq }) + this.buffer.push({ ...event, notificationSeq: seq, notificationEpoch: this.epochId }) if (this.buffer.length > this.capacity) { // Why: insertion-order array; oldest entries sit at the front. this.buffer.splice(0, this.buffer.length - this.capacity) @@ -56,7 +71,16 @@ export class MobileNotificationReplayBuffer { // Returns every recorded event with seq strictly greater than lastSeenSeq. // Because seq is monotonic and global, this is an exact, idempotent cut: // the same lastSeenSeq always yields the same result. - getMissedSince(lastSeenSeq: number): ReplayableMobileNotification[] { + // + // `epoch` is the counter lifetime the caller's watermark came from. A mismatch + // means the watermark indexes a counter this process no longer has, so it is + // meaningless here and the whole retained buffer is returned instead — every + // entry in it postdates the restart, so none can have reached that client. + // Omitting `epoch` keeps the seq-only cut, for clients that predate the field. + getMissedSince(lastSeenSeq: number, epoch?: string): ReplayableMobileNotification[] { + if (epoch !== undefined && epoch !== this.epochId) { + return [...this.buffer] + } if (lastSeenSeq >= this.seq) { return [] } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 8486af362eb..e61b64df170 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8357,6 +8357,17 @@ describe('OrcaRuntimeService', () => { expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }]) }) + it('restores a provisional 2031 subscribe when daemon scan authority returns', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.setPtyTransientFactDelegation('pty-1', true) + runtime.setPtyTransientFactDelegation('pty-1', false, '\x1b[?', true) + runtime.onPtyData('pty-1', '25h', 100) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }]) + }) + it('prefers the tracked title over the renderer snapshot lastTitle', async () => { const { runtime } = createSideEffectRuntime() const serializeBuffer = vi.fn().mockResolvedValue({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 03df14bebf6..124ed6dc4da 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2387,6 +2387,7 @@ export type MobileNotificationDispatchEvent = { worktreeId?: string notificationId?: string notificationSeq?: number + notificationEpoch?: string } export type RuntimeWorktreeLifecycleEvent = @@ -2397,6 +2398,7 @@ export type MobileNotificationDismissEvent = { type: 'dismiss' notificationId: string notificationSeq?: number + notificationEpoch?: string } export type MobileNotificationEvent = @@ -8075,7 +8077,12 @@ export class OrcaRuntimeService { * facts itself and the delivered bytes may be gapped — feeding them to * main's transient scanners would mint phantom or duplicate facts. Title * processing stays main-side either way. */ - setPtyTransientFactDelegation(ptyId: string, delegated: boolean, scanSeedAnsi?: string): void { + setPtyTransientFactDelegation( + ptyId: string, + delegated: boolean, + scanSeedAnsi?: string, + mode2031PendingSubscribe?: true + ): void { const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) entry.tracker.setTransientFactScanningSuppressed(delegated) if (!delegated && scanSeedAnsi) { @@ -8083,7 +8090,10 @@ export class OrcaRuntimeService { // incomplete escape at the handoff position — a sequence split across // the un-background toggle must not mint a phantom bell or lose its // fact. titleScanData:'' keeps titles out (they were never suppressed). - entry.tracker.handleChunk(scanSeedAnsi, { titleScanData: '' }) + entry.tracker.handleChunk(scanSeedAnsi, { + titleScanData: '', + mode2031PendingSubscribe + }) } } @@ -8106,6 +8116,9 @@ export class OrcaRuntimeService { return case '2031-subscribe': this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + return + case '2031-unsubscribe': + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) } } @@ -8353,6 +8366,12 @@ export class OrcaRuntimeService { // still sent by the renderer (query authority stays with the view). onMode2031Subscribe: () => { this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + }, + // Why: the gated view never sees the withdrawal bytes either, so the + // subscription registry it keeps for theme flips needs this fact to + // stay truthful. + onMode2031Unsubscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) } } : {}) @@ -10203,7 +10222,12 @@ export class OrcaRuntimeService { // delivered and feed it back to getMissedSince on reconnect (idempotent catch-up, no dupes). notifyRuntimeListeners( this.notificationListeners, - (listener) => listener({ ...event, notificationSeq: seq }), + (listener) => + listener({ + ...event, + notificationSeq: seq, + notificationEpoch: this.mobileNotificationReplay.epoch + }), 'mobile-notification' ) } @@ -10211,8 +10235,15 @@ export class OrcaRuntimeService { // Returns notifications dispatched after lastSeenSeq. Idempotent: the same // watermark always yields the same set, so a client cannot be re-pushed an // already-delivered event (the adversarial-review gate for #8129). - getMissedNotificationsSince(lastSeenSeq: number): ReplayableMobileNotification[] { - return this.mobileNotificationReplay.getMissedSince(lastSeenSeq) + getMissedNotificationsSince(lastSeenSeq: number, epoch?: string): ReplayableMobileNotification[] { + return this.mobileNotificationReplay.getMissedSince(lastSeenSeq, epoch) + } + + // Why (#8591): the seq counter is per-process and restarts at 0 on every desktop + // launch, but the client's watermark is persisted. Clients need the epoch to tell + // a stale watermark from a valid one — see MobileNotificationReplayBuffer. + getMobileNotificationEpoch(): string { + return this.mobileNotificationReplay.epoch } dismissMobileNotification(notificationId: string): void { diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts index 017c75bf328..80c6af7caec 100644 --- a/src/main/runtime/rpc/methods/notifications.ts +++ b/src/main/runtime/rpc/methods/notifications.ts @@ -20,8 +20,13 @@ const NotificationUnsubscribeParams = z.object({ // exact and idempotent — re-requesting with the same watermark can never // return an already-delivered event, so reconnects never duplicate local // pushes (the adversarial-review gate for #8129). +// `epoch` names the counter lifetime lastSeenSeq came from (#8591). The desktop's +// seq restarts at 0 on every launch while the client's watermark is persisted, so +// without it a post-restart watermark silently cuts away everything. Optional: a +// client that predates the field keeps the seq-only cut. const NotificationGetMissedSinceParams = z.object({ - lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer') + lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'), + epoch: z.string().optional() }) // Why: notifications.subscribe streams desktop notification events to mobile @@ -52,7 +57,9 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ connectionId ) - emit({ type: 'ready', subscriptionId }) + // Why: the epoch rides the ready frame so a reconnecting client learns the + // counter lifetime BEFORE it sends its watermark to getMissedSince (#8591). + emit({ type: 'ready', subscriptionId, epoch: runtime.getMobileNotificationEpoch() }) }) } }), @@ -71,8 +78,8 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [ // the monotonic seq, so this is the single source of truth for what the // client missed while its socket was reaped. handler: async (params, { runtime }) => { - const missed = runtime.getMissedNotificationsSince(params.lastSeenSeq) - return { notifications: missed } + const missed = runtime.getMissedNotificationsSince(params.lastSeenSeq, params.epoch) + return { notifications: missed, epoch: runtime.getMobileNotificationEpoch() } } }) ] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 1261a2de4cf..4e4801c9faa 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -17,6 +17,11 @@ import { encodeTerminalStreamText, type TerminalStreamFrame } from '../../../../shared/terminal-stream-protocol' +import { + iterateTerminalOutputFrameChunks, + type TerminalOutputFrameChunk, + type TerminalOutputMeta +} from '../terminal-output-frame-chunks' import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry' import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' import { @@ -24,7 +29,11 @@ import { TERMINAL_INPUT_TOO_LARGE_ERROR, isTerminalInputTooLargeWithYield } from '../../../../shared/terminal-input' -import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text' +import { + measureTerminalStreamByteLength, + terminalStreamByteLength, + terminalStreamByteLengthExceeds +} from '../terminal-stream-byte-length' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' import { @@ -49,8 +58,7 @@ import { TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, TERMINAL_MULTIPLEX_MAX_STREAMS_PER_CONNECTION, TERMINAL_MULTIPLEX_PENDING_MAX_BYTES, - TERMINAL_OUTPUT_BATCH_MAX_BYTES, - TERMINAL_STREAM_CHUNK_BYTES + TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../../shared/terminal-multiplex-flow-control' import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin' @@ -139,19 +147,6 @@ type TerminalOutputChunk = { meta?: TerminalOutputMeta } -type TerminalOutputMeta = { - seq?: number - rawLength?: number - transformed?: boolean - cwd?: string -} - -type TerminalOutputFrameChunk = { - bytes: Uint8Array - seq?: number - opcode?: TerminalStreamOpcode -} - function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): { push: (data: string, meta?: TerminalOutputMeta) => void flush: () => void @@ -240,82 +235,6 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp } } -function* iterateTerminalOutputFrameChunks( - data: string, - meta?: TerminalOutputMeta -): Generator { - const rawLength = meta?.rawLength ?? data.length - if (meta?.transformed || rawLength !== data.length) { - yield { - opcode: TerminalStreamOpcode.OutputSpan, - bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), - seq: meta?.seq - } - return - } - if (!terminalStreamByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) { - yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } - return - } - const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length - const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' - const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined - let chunk = '' - let chunkBytes = 0 - let chunkStartOffset = 0 - let offset = 0 - let delayedChunk: { text: string; seq?: number } | null = null - - const takeChunk = (): { text: string; seq?: number } | null => { - if (!chunk) { - return null - } - const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined - const current = { text: chunk, seq: chunkSeq } - chunk = '' - chunkBytes = 0 - chunkStartOffset = offset - return current - } - - for (const part of data) { - const partBytes = terminalStreamByteLength(part) - if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { - const nextChunk = takeChunk() - if (nextChunk) { - if (shouldDelayFinalSeq) { - if (delayedChunk) { - yield { bytes: encodeTerminalStreamText(delayedChunk.text) } - } - delayedChunk = nextChunk - } else { - yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } - } - } - } - chunk += part - chunkBytes += partBytes - offset += part.length - } - const finalChunk = takeChunk() - if (shouldDelayFinalSeq) { - // Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets. - if (finalChunk) { - if (delayedChunk) { - yield { bytes: encodeTerminalStreamText(delayedChunk.text) } - } - delayedChunk = finalChunk - } - if (delayedChunk) { - yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq } - } - return - } - if (finalChunk) { - yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } - } -} - function isTerminalInputLockedForClient( runtime: OrcaRuntimeService, ptyId: string, @@ -541,13 +460,6 @@ function trimPendingOutputToBudget( return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 } } -function measureTerminalStreamByteLength( - data: string, - options: { stopAfterBytes?: number } = {} -): { byteLength: number; exceededLimit: boolean } { - return measureClipboardTextByteLength(data, options) -} - function trimPendingOutputCoveredBySnapshot( pendingOutput: TerminalOutputChunk[], snapshotSeq: number | undefined @@ -585,14 +497,6 @@ function trimPendingOutputCoveredBySnapshot( return { chunks, bytes } } -function terminalStreamByteLength(data: string): number { - return measureTerminalStreamByteLength(data).byteLength -} - -function terminalStreamByteLengthExceeds(data: string, maxBytes: number): boolean { - return measureTerminalStreamByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit -} - function* iterateTerminalStreamTextPayloads(data: string): Generator> { if (!data) { return diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts new file mode 100644 index 00000000000..f43f7cb93fa --- /dev/null +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, it } from 'vitest' +import { + TerminalStreamOpcode, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' +import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' +import { + iterateTerminalOutputFrameChunks, + type TerminalOutputFrameChunk, + type TerminalOutputMeta +} from './terminal-output-frame-chunks' + +// Byte-for-byte reference: the pre-optimization implementation, copied verbatim. +// It accumulates `chunk += part` over `for (const part of data)` and measures each +// code point through the shared clipboard measurer. +function legacyByteLength(data: string): number { + return measureClipboardTextByteLength(data).byteLength +} + +function legacyByteLengthExceeds(data: string, maxBytes: number): boolean { + return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit +} + +function* legacyIterateTerminalOutputFrameChunks( + data: string, + meta?: TerminalOutputMeta +): Generator { + const rawLength = meta?.rawLength ?? data.length + if (meta?.transformed || rawLength !== data.length) { + yield { + opcode: TerminalStreamOpcode.OutputSpan, + bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), + seq: meta?.seq + } + return + } + if (!legacyByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) { + yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } + return + } + const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' + const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined + let chunk = '' + let chunkBytes = 0 + let chunkStartOffset = 0 + let offset = 0 + let delayedChunk: { text: string; seq?: number } | null = null + + const takeChunk = (): { text: string; seq?: number } | null => { + if (!chunk) { + return null + } + const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined + const current = { text: chunk, seq: chunkSeq } + chunk = '' + chunkBytes = 0 + chunkStartOffset = offset + return current + } + + for (const part of data) { + const partBytes = legacyByteLength(part) + if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { + const nextChunk = takeChunk() + if (nextChunk) { + if (shouldDelayFinalSeq) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = nextChunk + } else { + yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } + } + } + } + chunk += part + chunkBytes += partBytes + offset += part.length + } + const finalChunk = takeChunk() + if (shouldDelayFinalSeq) { + if (finalChunk) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = finalChunk + } + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq } + } + return + } + if (finalChunk) { + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } + } +} + +type FrameShape = { base64: string; seq: number | 'undefined'; opcode: number | 'undefined' } + +function describeFrames(frames: Iterable): FrameShape[] { + const out: FrameShape[] = [] + for (const frame of frames) { + out.push({ + base64: Buffer.from(frame.bytes).toString('base64'), + seq: frame.seq ?? 'undefined', + opcode: frame.opcode ?? 'undefined' + }) + } + return out +} + +function expectEquivalent(data: string, meta: TerminalOutputMeta | undefined, label: string): void { + const legacy = describeFrames(legacyIterateTerminalOutputFrameChunks(data, meta)) + const next = describeFrames(iterateTerminalOutputFrameChunks(data, meta)) + expect(next, label).toEqual(legacy) +} + +const SURROGATE_PAIR = '\u{1f600}' +const LONE_HIGH = '\ud83d' +const LONE_LOW = '\ude00' +// Extremes of both surrogate ranges: U+10000 (D800 DC00) and U+10FFFF (DBFF DFFF). +const FIRST_ASTRAL = '\u{10000}' +const LAST_ASTRAL = '\u{10ffff}' +const SURROGATE_EDGES = [ + '\ud800', + '\udbff', + '\udc00', + '\udfff', + FIRST_ASTRAL, + LAST_ASTRAL, + '\udbff\udbff', + '\ud800\udbff', + '\udfff\udc00' +] + +// Meta shapes exercised against every fixture: no meta, seq-preserved (rawLength === +// data.length), the delayed-final-seq path (rawLength !== data.length -> OutputSpan), +// transformed, and cwd-only. +function metaShapesFor(data: string): { label: string; meta: TerminalOutputMeta | undefined }[] { + return [ + { label: 'no-meta', meta: undefined }, + { label: 'seq-only', meta: { seq: 5_000_000 } }, + { label: 'seq+rawLength=len', meta: { seq: 9_000, rawLength: data.length } }, + { label: 'seq+rawLength!=len', meta: { seq: 9_000, rawLength: data.length + 7 } }, + { label: 'rawLength!=len only', meta: { rawLength: data.length + 3 } }, + { label: 'transformed', meta: { seq: 42, rawLength: data.length, transformed: true } }, + { label: 'cwd-only', meta: { cwd: '/home/dev/orca' } }, + { label: 'seq=0', meta: { seq: 0, rawLength: data.length } } + ] +} + +function sweepAll(data: string, label: string): void { + for (const shape of metaShapesFor(data)) { + expectEquivalent(data, shape.meta, `${label} [${shape.label}]`) + } +} + +// A fixture whose seq path is only observable when rawLength maps 1:1 to UTF-16 +// offsets; forcing that shape is what makes the algebraic collapse testable. +function seqPreservingMeta(data: string, seq: number): TerminalOutputMeta { + return { seq, rawLength: data.length } +} + +function escapeUnits(value: string): string { + const units: string[] = [] + for (let index = 0; index < value.length; index += 1) { + units.push(`U+${value.charCodeAt(index).toString(16).toUpperCase()}`) + } + return units.join(' ') +} + +function repeatToLength(unit: string, codeUnits: number): string { + let out = '' + while (out.length < codeUnits) { + out += unit + } + return out.slice(0, out.length - (out.length % unit.length)) +} + +// Deterministic PRNG so a fuzz failure is reproducible from the seed alone. +function makeRandom(seed: number): () => number { + let state = seed >>> 0 || 1 + return () => { + state ^= state << 13 + state >>>= 0 + state ^= state >>> 17 + state ^= state << 5 + state >>>= 0 + return state / 0x1_0000_0000 + } +} + +const FUZZ_ALPHABET = [ + 'a', + 'z', + '\r', + '\n', + '\u00e9', + '\u20ac', + SURROGATE_PAIR, + LONE_HIGH, + LONE_LOW, + '\u0000', + '\u001b' +] + +function randomText(random: () => number, parts: number): string { + let out = '' + for (let index = 0; index < parts; index += 1) { + out += FUZZ_ALPHABET[Math.floor(random() * FUZZ_ALPHABET.length)] + } + return out +} + +describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization loop', () => { + it('matches on the small/no-chunking sizes', () => { + for (const size of [0, 1, 2, 3, 7, 64, 1024, TERMINAL_STREAM_CHUNK_BYTES - 1]) { + sweepAll('a'.repeat(size), `ascii ${size}`) + } + }) + + it('matches across 1B..200KiB ASCII payloads', () => { + for (const size of [ + 1, + 100, + TERMINAL_STREAM_CHUNK_BYTES, + TERMINAL_STREAM_CHUNK_BYTES + 1, + TERMINAL_STREAM_CHUNK_BYTES * 2, + TERMINAL_STREAM_CHUNK_BYTES * 3 + 17, + 100 * 1024, + 200 * 1024 + ]) { + sweepAll('x'.repeat(size), `ascii ${size}`) + } + }) + + it('preserves legacy addition rounding for unsafe sequence values', () => { + const data = 'x'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 1) + const seq = 9_007_199_254_740_994 + const meta = seqPreservingMeta(data, seq) + + expectEquivalent(data, meta, 'unsafe seq rounding') + const frames = [...iterateTerminalOutputFrameChunks(data, meta)] + expect(frames.at(-1)?.seq).toBe(9_007_199_254_740_992) + }) + + it('matches on multi-byte-only payloads at every UTF-8 width', () => { + // UTF-8 width boundaries: 1|2 at U+0080, 2|3 at U+0800, 3|4 at U+10000. + for (const unit of [ + '\u00e9', + '\u20ac', + SURROGATE_PAIR, + '\u0080', + '\u07ff', + '\u0800', + '\uffff' + ]) { + for (const codeUnits of [ + TERMINAL_STREAM_CHUNK_BYTES / 2, + TERMINAL_STREAM_CHUNK_BYTES, + TERMINAL_STREAM_CHUNK_BYTES + 64, + 90 * 1024 + ]) { + const data = repeatToLength(unit, codeUnits) + sweepAll(data, `unit=${JSON.stringify(unit)} codeUnits=${codeUnits}`) + } + } + }) + + it('matches with lone surrogates, including a trailing lone high surrogate', () => { + const filler = 'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 5) + for (const data of [ + LONE_HIGH, + LONE_LOW, + LONE_HIGH + LONE_HIGH, + LONE_LOW + LONE_HIGH, + filler + LONE_HIGH, + filler + LONE_LOW, + LONE_HIGH + filler, + LONE_LOW + filler, + filler + LONE_HIGH + filler, + // Reversed pair: never a valid pair, so both halves must stay 3-byte replacements. + filler + LONE_LOW + LONE_HIGH + filler, + repeatToLength(LONE_HIGH, 60 * 1024), + repeatToLength(LONE_LOW + LONE_HIGH, 60 * 1024) + ]) { + sweepAll(data, `lone-surrogate len=${data.length}`) + } + }) + + it('matches at both ends of both surrogate ranges (U+D800..U+DBFF, U+DC00..U+DFFF)', () => { + const filler = 'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 5) + for (const edge of SURROGATE_EDGES) { + for (const data of [ + edge, + filler + edge, + edge + filler, + filler + edge + filler, + repeatToLength(edge, 40 * 1024) + ]) { + sweepAll(data, `surrogate-edge ${escapeUnits(edge)} len=${data.length}`) + } + // Land the split inside the edge sequence itself. + for (let offset = -4; offset <= 4; offset += 1) { + const data = `${'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + offset)}${edge}${'b'.repeat(8)}` + expectEquivalent(data, undefined, `surrogate-edge ${escapeUnits(edge)} offset=${offset}`) + expectEquivalent( + data, + seqPreservingMeta(data, 500_000 + data.length), + `surrogate-edge ${escapeUnits(edge)} offset=${offset} seq` + ) + } + } + }) + + it('sweeps a chunk boundary across a surrogate pair at CHUNK-4..CHUNK+4', () => { + for (let offset = -4; offset <= 4; offset += 1) { + const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset + const data = `${'a'.repeat(prefixBytes)}${SURROGATE_PAIR}${'b'.repeat(64)}` + sweepAll(data, `pair boundary offset=${offset}`) + expectEquivalent( + data, + seqPreservingMeta(data, 1_000_000 + data.length), + `pair boundary offset=${offset} seq-preserved` + ) + } + }) + + it('sweeps a chunk boundary across a multi-byte run at CHUNK-4..CHUNK+4', () => { + for (let offset = -4; offset <= 4; offset += 1) { + const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset + // 3-byte run straddling the cap: the split point cannot land mid-code-point. + const data = `${'a'.repeat(prefixBytes)}${'\u20ac'.repeat(32)}${'b'.repeat(16)}` + sweepAll(data, `multibyte boundary offset=${offset}`) + } + }) + + it('sweeps a chunk boundary across a lone surrogate at CHUNK-4..CHUNK+4', () => { + for (let offset = -4; offset <= 4; offset += 1) { + const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset + for (const lone of [LONE_HIGH, LONE_LOW]) { + const data = `${'a'.repeat(prefixBytes)}${lone}${'b'.repeat(64)}` + sweepAll(data, `lone ${lone === LONE_HIGH ? 'high' : 'low'} boundary offset=${offset}`) + } + // Lone high surrogate as the very last code unit of the payload. + const trailing = `${'a'.repeat(prefixBytes)}${LONE_HIGH}` + sweepAll(trailing, `trailing lone high offset=${offset}`) + } + }) + + it('sweeps 2-byte and 4-byte code points across a 1-code-unit window at the cap', () => { + for (const unit of ['\u00e9', SURROGATE_PAIR]) { + for ( + let pad = TERMINAL_STREAM_CHUNK_BYTES - 6; + pad <= TERMINAL_STREAM_CHUNK_BYTES + 2; + pad += 1 + ) { + const data = `${'a'.repeat(pad)}${unit.repeat(8)}${'z'.repeat(8)}` + expectEquivalent(data, undefined, `window unit=${unit} pad=${pad}`) + expectEquivalent( + data, + seqPreservingMeta(data, 777 + data.length), + `window unit=${unit} pad=${pad} seq` + ) + expectEquivalent( + data, + { seq: 777, rawLength: data.length + 1 }, + `window unit=${unit} pad=${pad} delayed` + ) + } + } + }) + + it('matches on the delayed-final-seq path across many chunk counts', () => { + // rawLength !== data.length routes to OutputSpan; force the multi-chunk delayed + // path by keeping rawLength === data.length but seq present with transformed=false, + // then separately assert the true delayed shape (canPreserveChunkSeq=false). + for (const chunkCount of [1, 2, 3, 5, 9]) { + const data = `${'m'.repeat(TERMINAL_STREAM_CHUNK_BYTES * chunkCount)}${SURROGATE_PAIR}tail` + expectEquivalent(data, { seq: 4242 }, `delayed chunks=${chunkCount} seq-only`) + expectEquivalent(data, { seq: 4242, rawLength: 1 }, `delayed chunks=${chunkCount} raw=1`) + expectEquivalent(data, undefined, `delayed chunks=${chunkCount} no-meta`) + } + }) + + it('matches on realistic mixed terminal output', () => { + const line = '\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed\r\n' + for (const repeats of [1, 200, 2000, 6000]) { + const data = line.repeat(repeats) + sweepAll(data, `mixed repeats=${repeats}`) + } + }) + + it('fuzzes 4000 short random payloads over the surrogate/control alphabet', () => { + const random = makeRandom(0x5eed_1234) + for (let trial = 0; trial < 4000; trial += 1) { + const data = randomText(random, Math.floor(random() * 40)) + expectEquivalent(data, undefined, `fuzz-small trial=${trial}`) + expectEquivalent( + data, + { seq: 31337, rawLength: data.length }, + `fuzz-small seq trial=${trial}` + ) + } + }) + + it('fuzzes 800 near-cap payloads whose split point lands in the random region', () => { + const random = makeRandom(0x1234_5eed) + for (let trial = 0; trial < 800; trial += 1) { + const fillerLength = TERMINAL_STREAM_CHUNK_BYTES - 6 + Math.floor(random() * 12) + const data = 'q'.repeat(fillerLength) + randomText(random, 1 + Math.floor(random() * 24)) + expectEquivalent(data, undefined, `fuzz-cap trial=${trial}`) + expectEquivalent(data, { seq: 88_888, rawLength: data.length }, `fuzz-cap seq trial=${trial}`) + expectEquivalent(data, { seq: 88_888 }, `fuzz-cap delayed trial=${trial}`) + } + }) + + it('keeps every emitted frame within the wire cap and reassembles to the input', () => { + const data = `${'a'.repeat(200 * 1024)}${SURROGATE_PAIR.repeat(4096)}${LONE_HIGH}` + const frames = [...iterateTerminalOutputFrameChunks(data, seqPreservingMeta(data, 999_999))] + expect(frames.length).toBeGreaterThan(4) + for (const frame of frames) { + expect(frame.bytes.byteLength).toBeLessThanOrEqual(TERMINAL_STREAM_CHUNK_BYTES) + } + expect(Buffer.concat(frames.map((frame) => Buffer.from(frame.bytes))).toString('utf8')).toBe( + Buffer.from(new TextEncoder().encode(data)).toString('utf8') + ) + // Seqs must be strictly increasing and end at the meta high-water mark. + const seqs = frames.map((frame) => frame.seq!) + expect(seqs.every((seq, index) => index === 0 || seq > seqs[index - 1]!)).toBe(true) + expect(seqs.at(-1)).toBe(999_999) + }) + + it('emits exactly one frame when the payload fits the cap in bytes but not naively', () => { + // 3-byte code points: 16384 code units = 49152 bytes = exactly the cap. + const exact = '\u20ac'.repeat(TERMINAL_STREAM_CHUNK_BYTES / 3) + expect(Buffer.byteLength(exact, 'utf8')).toBe(TERMINAL_STREAM_CHUNK_BYTES) + expect([...iterateTerminalOutputFrameChunks(exact)]).toHaveLength(1) + expect([...legacyIterateTerminalOutputFrameChunks(exact)]).toHaveLength(1) + const overByOne = `${exact}a` + expect([...iterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) + expect([...legacyIterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) + }) + + // The chunking loop is only reached when rawLength === data.length and transformed + // is falsy, which makes canPreserveChunkSeq === (typeof meta.seq === 'number') and + // therefore shouldDelayFinalSeq unconditionally false. The branch survives here + // (it also survived in the pre-optimization code) purely as defence in depth. + it('never reaches the delayed-final-seq branch for any meta shape', () => { + for (const data of ['', 'a', 'abc', 'x'.repeat(200)]) { + for (const seq of [undefined, 0, 5] as (number | undefined)[]) { + for (const rawDelta of [undefined, 0, 1, -1] as (number | undefined)[]) { + for (const transformed of [undefined, false, true] as (boolean | undefined)[]) { + const meta: TerminalOutputMeta = {} + if (seq !== undefined) { + meta.seq = seq + } + if (rawDelta !== undefined) { + meta.rawLength = data.length + rawDelta + } + if (transformed !== undefined) { + meta.transformed = transformed + } + const rawLength = meta.rawLength ?? data.length + const reachesChunkLoop = !meta.transformed && rawLength === data.length + const canPreserveChunkSeq = typeof meta.seq === 'number' && rawLength === data.length + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta.seq === 'number' + expect(reachesChunkLoop && shouldDelayFinalSeq, JSON.stringify(meta)).toBe(false) + } + } + } + } + }) +}) diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks.ts b/src/main/runtime/rpc/terminal-output-frame-chunks.ts new file mode 100644 index 00000000000..f040934a565 --- /dev/null +++ b/src/main/runtime/rpc/terminal-output-frame-chunks.ts @@ -0,0 +1,113 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' + +export type TerminalOutputMeta = { + seq?: number + rawLength?: number + transformed?: boolean + cwd?: string +} + +export type TerminalOutputFrameChunk = { + bytes: Uint8Array + seq?: number + opcode?: TerminalStreamOpcode +} + +// Why: UTF-8 length is never below UTF-16 length, so a code-unit count over the cap already proves the byte count is. +function exceedsTerminalStreamChunkBytes(data: string): boolean { + return ( + data.length > TERMINAL_STREAM_CHUNK_BYTES || + Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES + ) +} + +export function* iterateTerminalOutputFrameChunks( + data: string, + meta?: TerminalOutputMeta +): Generator { + const rawLength = meta?.rawLength ?? data.length + if (meta?.transformed || rawLength !== data.length) { + yield { + opcode: TerminalStreamOpcode.OutputSpan, + bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), + seq: meta?.seq + } + return + } + if (!exceedsTerminalStreamChunkBytes(data)) { + yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } + return + } + const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length + // Unreachable past the OutputSpan branch above (rawLength === data.length there), but kept + // as defence in depth: it is the only shape that can carry a seq the chunk offsets can't map. + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' + const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined + let chunkStart = 0 + let chunkBytes = 0 + let delayedChunk: { text: string; seq?: number } | null = null + + // Why two offsets instead of an accumulator: the emitted text is always the contiguous + // substring from chunkStart to end. Keep the legacy addition order for unsafe sequence values. + const takeChunk = (end: number): { text: string; seq?: number } => { + const text = data.slice(chunkStart, end) + const current = { + text, + seq: canPreserveChunkSeq ? startSeq! + chunkStart + text.length : undefined + } + chunkStart = end + chunkBytes = 0 + return current + } + + let index = 0 + while (index < data.length) { + const code = data.charCodeAt(index) + let width = 1 + let partBytes = 3 + if (code < 0x80) { + partBytes = 1 + } else if (code < 0x800) { + partBytes = 2 + } else if ( + code >= 0xd800 && + code <= 0xdbff && + // Past the end charCodeAt is NaN, which masks to 0 — a trailing lone surrogate stays 3 bytes. + (data.charCodeAt(index + 1) & 0xfc00) === 0xdc00 + ) { + partBytes = 4 + width = 2 + } + // Why chunkBytes > 0: keeps a code point wider than the whole cap in its own frame + // instead of splitting an empty one forever. + if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { + const nextChunk = takeChunk(index) + if (shouldDelayFinalSeq) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = nextChunk + } else { + yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } + } + } + chunkBytes += partBytes + index += width + } + // A split always advances past its boundary, so the tail after the loop is never empty. + const finalChunk = takeChunk(data.length) + if (shouldDelayFinalSeq) { + // Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets. + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: meta.seq } + return + } + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } +} diff --git a/src/main/runtime/rpc/terminal-stream-byte-length.test.ts b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts new file mode 100644 index 00000000000..bf07fe38fc8 --- /dev/null +++ b/src/main/runtime/rpc/terminal-stream-byte-length.test.ts @@ -0,0 +1,393 @@ +import { describe, expect, it } from 'vitest' +import { + MIN_NATIVE_BYTE_LENGTH_CODE_UNITS, + measureTerminalStreamByteLength, + terminalStreamByteLength, + terminalStreamByteLengthExceeds +} from './terminal-stream-byte-length' +import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../shared/terminal-multiplex-flow-control' + +// Byte-for-byte copy of the pre-change implementation (shared/clipboard-text.ts +// measureClipboardTextByteLength), kept here so equivalence is checked against the +// ACTUAL old code path rather than a paraphrase of it. +function legacyUtf8ByteLengthForCodePoint(codePoint: number): number { + if (codePoint <= 0x7f) { + return 1 + } + if (codePoint <= 0x7ff) { + return 2 + } + if (codePoint <= 0xffff) { + return 3 + } + return 4 +} + +function legacyMeasure( + text: string, + options: { stopAfterBytes?: number } = {} +): { byteLength: number; exceededLimit: boolean } { + const stopAfterBytes = options.stopAfterBytes + let byteLength = 0 + for (let index = 0; index < text.length; index += 1) { + const codePoint = text.codePointAt(index) ?? 0 + byteLength += legacyUtf8ByteLengthForCodePoint(codePoint) + if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) { + return { byteLength, exceededLimit: true } + } + if (codePoint > 0xffff) { + index += 1 + } + } + return { byteLength, exceededLimit: false } +} + +function legacyByteLength(data: string): number { + return legacyMeasure(data).byteLength +} + +function legacyExceeds(data: string, maxBytes: number): boolean { + return legacyMeasure(data, { stopAfterBytes: maxBytes }).exceededLimit +} + +function mulberry32(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Mixed generator: ASCII, 2-byte, 3-byte, astral pairs, and LONE surrogates, so the +// fixtures exercise every branch of the legacy code-point scan. +function randomUnit(random: () => number): string { + const roll = random() + if (roll < 0.4) { + return String.fromCharCode(Math.floor(random() * 0x80)) + } + if (roll < 0.55) { + return String.fromCharCode(0x80 + Math.floor(random() * 0x780)) + } + if (roll < 0.72) { + return String.fromCharCode(0x800 + Math.floor(random() * 0xd000)) + } + if (roll < 0.88) { + return String.fromCodePoint(0x10000 + Math.floor(random() * 0x100000)) + } + return String.fromCharCode(0xd800 + Math.floor(random() * 0x800)) +} + +function randomString(random: () => number, maxUnits: number): string { + const count = Math.floor(random() * maxUnits) + let text = '' + for (let index = 0; index < count; index += 1) { + text += randomUnit(random) + } + return text +} + +// Raw UTF-16 with no code-point discipline at all: catches anything that assumes +// well-formedness (unpaired high after high, low before high, etc). +function rawUtf16(random: () => number, maxUnits: number): string { + const count = Math.floor(random() * maxUnits) + let text = '' + for (let index = 0; index < count; index += 1) { + text += String.fromCharCode(Math.floor(random() * 0x11000)) + } + return text +} + +const EDGE_STRINGS = [ + '', + 'a', + '\u0000', + '\u007f', + '€', + '߿', + 'ࠀ', + '￿', + '�', + '\u{10000}', + '\u{10ffff}', + '\ud800', + '\udfff', + '\ud800\ud800', + '\udc00\ud800', + '😀', + '😀\ud800', + '\ud800😀', + 'a\ud800b', + '\r\n\u001b[0m', + 'é́', + '\u{1f469}‍\u{1f4bb}', + 'x'.repeat(1000), + '\u{1f600}'.repeat(300), + `${'é'.repeat(500)}\ud800` +] + +describe('terminal stream byte length equivalence with the legacy code-point scan', () => { + it('matches the legacy total byte length on edge strings', () => { + for (const text of EDGE_STRINGS) { + expect(terminalStreamByteLength(text)).toBe(legacyByteLength(text)) + } + }) + + it('matches the legacy total byte length over every Unicode code point', () => { + for (let codePoint = 0; codePoint <= 0x10ffff; codePoint += 1) { + const text = String.fromCodePoint(codePoint) + if (terminalStreamByteLength(text) !== legacyByteLength(text)) { + throw new Error(`byte length diverged at code point U+${codePoint.toString(16)}`) + } + } + expect(terminalStreamByteLength('\u{10ffff}')).toBe(4) + }) + + it('matches the legacy total byte length over every lone surrogate', () => { + for (let unit = 0xd800; unit <= 0xdfff; unit += 1) { + const text = `a${String.fromCharCode(unit)}b` + expect(terminalStreamByteLength(text)).toBe(legacyByteLength(text)) + } + }) + + it('matches the legacy total byte length over fuzzed mixed and raw UTF-16 input', () => { + const random = mulberry32(0x5eed01) + for (let iteration = 0; iteration < 20000; iteration += 1) { + const text = iteration % 2 === 0 ? randomString(random, 24) : rawUtf16(random, 24) + if (terminalStreamByteLength(text) !== legacyByteLength(text)) { + throw new Error(`byte length diverged for ${JSON.stringify(text)}`) + } + } + expect(true).toBe(true) + }) + + it('matches the legacy exceededLimit decision across a dense (string, limit) sweep', () => { + const random = mulberry32(0xc0ffee) + let compared = 0 + for (let iteration = 0; iteration < 10000; iteration += 1) { + const text = iteration % 2 === 0 ? randomString(random, 16) : rawUtf16(random, 16) + const total = legacyByteLength(text) + // Sweep every limit from below zero to past the true total so the boundary is hit exactly. + for (let limit = -2; limit <= total + 2; limit += 1) { + if (terminalStreamByteLengthExceeds(text, limit) !== legacyExceeds(text, limit)) { + throw new Error(`exceededLimit diverged for ${JSON.stringify(text)} at limit ${limit}`) + } + compared += 1 + } + } + expect(compared).toBeGreaterThan(100000) + }) + + it('matches the legacy exceededLimit decision for non-finite and fractional limits', () => { + const random = mulberry32(0xfeed42) + for (const limit of [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + 0.5, + 2.5, + -0.5, + Number.MAX_SAFE_INTEGER + ]) { + for (let iteration = 0; iteration < 400; iteration += 1) { + const text = rawUtf16(random, 12) + expect(terminalStreamByteLengthExceeds(text, limit)).toBe(legacyExceeds(text, limit)) + } + for (const text of EDGE_STRINGS) { + expect(terminalStreamByteLengthExceeds(text, limit)).toBe(legacyExceeds(text, limit)) + } + } + }) + + it('matches the legacy measurement pair, byteLength included, across a stopAfterBytes sweep', () => { + const random = mulberry32(0xa11ce) + for (let iteration = 0; iteration < 3000; iteration += 1) { + const text = iteration % 2 === 0 ? randomString(random, 20) : rawUtf16(random, 20) + const total = legacyByteLength(text) + for (let stopAfterBytes = -1; stopAfterBytes <= total + 2; stopAfterBytes += 1) { + const actual = measureTerminalStreamByteLength(text, { stopAfterBytes }) + const expected = legacyMeasure(text, { stopAfterBytes }) + if ( + actual.byteLength !== expected.byteLength || + actual.exceededLimit !== expected.exceededLimit + ) { + throw new Error( + `measurement diverged for ${JSON.stringify(text)} at stopAfterBytes ${stopAfterBytes}` + ) + } + } + } + expect( + measureTerminalStreamByteLength('\u{1f600}\u{1f600}\u{1f600}', { stopAfterBytes: 5 }) + ).toEqual(legacyMeasure('\u{1f600}\u{1f600}\u{1f600}', { stopAfterBytes: 5 })) + }) + + it('keeps the partial byteLength the legacy scan returned when the limit is exceeded', () => { + // A drop-in Buffer.byteLength would report 12 here; the legacy scan stops at 8. + const measurement = measureTerminalStreamByteLength('\u{1f600}\u{1f600}\u{1f600}', { + stopAfterBytes: 5 + }) + expect(measurement).toEqual({ byteLength: 8, exceededLimit: true }) + }) + + it('matches the legacy measurement with no stopAfterBytes and with an undefined option bag', () => { + const random = mulberry32(0xb0b) + for (let iteration = 0; iteration < 2000; iteration += 1) { + const text = rawUtf16(random, 32) + expect(measureTerminalStreamByteLength(text)).toEqual(legacyMeasure(text)) + expect(measureTerminalStreamByteLength(text, {})).toEqual(legacyMeasure(text, {})) + expect(measureTerminalStreamByteLength(text, { stopAfterBytes: undefined })).toEqual( + legacyMeasure(text, { stopAfterBytes: undefined }) + ) + } + }) + + // The code-unit floor routes short inputs back through the scan. Both sides of that + // boundary must stay legacy-identical, so sweep it exhaustively rather than by sampling. + it('matches the legacy result on both sides of the native-call floor', () => { + const random = mulberry32(0xf100a) + for (let units = 0; units <= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS * 2; units += 1) { + for (let iteration = 0; iteration < 60; iteration += 1) { + let text = '' + while (text.length < units) { + text += + iteration % 2 === 0 + ? randomUnit(random) + : String.fromCharCode(Math.floor(random() * 0x11000)) + } + text = text.slice(0, units) + const total = legacyByteLength(text) + expect(terminalStreamByteLength(text)).toBe(total) + for (let limit = -1; limit <= total + 2; limit += 1) { + if (terminalStreamByteLengthExceeds(text, limit) !== legacyExceeds(text, limit)) { + throw new Error(`exceeds diverged at ${units} units, limit ${limit}`) + } + const actual = measureTerminalStreamByteLength(text, { stopAfterBytes: limit }) + const expected = legacyMeasure(text, { stopAfterBytes: limit }) + if ( + actual.byteLength !== expected.byteLength || + actual.exceededLimit !== expected.exceededLimit + ) { + throw new Error(`measurement diverged at ${units} units, limit ${limit}`) + } + } + } + } + expect(MIN_NATIVE_BYTE_LENGTH_CODE_UNITS).toBeGreaterThan(0) + }) +}) + +// Reproduces createTerminalOutputBatcher's byte accounting exactly, under both the +// legacy scan and the new measurement, and asserts IDENTICAL flush boundaries. This is +// what makes the partial-count behaviour provably unobservable at that call site. +function simulateBatcherFlushes( + chunks: string[], + measure: ( + data: string, + options: { stopAfterBytes?: number } + ) => { byteLength: number; exceededLimit: boolean } +): string[] { + const flushes: string[] = [] + let pending: string[] = [] + let bytes = 0 + const flush = (): void => { + if (pending.length === 0) { + return + } + flushes.push(pending.join('')) + pending = [] + bytes = 0 + } + for (const data of chunks) { + if (!data) { + continue + } + pending.push(data) + const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) + const measurement = measure(data, { stopAfterBytes: remainingBudget }) + bytes += measurement.byteLength + if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { + flush() + } + } + flush() + return flushes +} + +describe('terminal output batcher flush boundaries are unchanged', () => { + it( + 'produces identical flush boundaries over randomized multi-chunk runs', + { timeout: 60000 }, + () => { + const random = mulberry32(0x1337) + for (let run = 0; run < 300; run += 1) { + const chunks: string[] = [] + const chunkCount = 1 + Math.floor(random() * 40) + for (let index = 0; index < chunkCount; index += 1) { + // Sizes straddle the 64KiB batch cap so single chunks both fit and blow the budget. + // Sizes straddle the native-call floor too, so runs mix scan-branch and + // native-branch measurements inside one batcher's byte accounting. + const scale = random() + const maxUnits = + scale < 0.25 + ? MIN_NATIVE_BYTE_LENGTH_CODE_UNITS * 2 + : scale < 0.5 + ? 64 + : scale < 0.85 + ? 20000 + : 90000 + chunks.push(random() < 0.5 ? randomString(random, maxUnits) : rawUtf16(random, maxUnits)) + } + const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure) + const actualFlushes = simulateBatcherFlushes(chunks, measureTerminalStreamByteLength) + if (legacyFlushes.length !== actualFlushes.length) { + throw new Error(`flush count diverged on run ${run}`) + } + for (let index = 0; index < legacyFlushes.length; index += 1) { + if (legacyFlushes[index] !== actualFlushes[index]) { + throw new Error(`flush ${index} diverged on run ${run}`) + } + } + } + expect(true).toBe(true) + } + ) + + it('exercises runs that actually cross the batch budget', () => { + const oversized = '\u{1f600}'.repeat(TERMINAL_OUTPUT_BATCH_MAX_BYTES) + const chunks = ['a'.repeat(10), oversized, 'b'.repeat(10), oversized, 'c'] + const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure) + expect(legacyFlushes.length).toBeGreaterThan(1) + expect(simulateBatcherFlushes(chunks, measureTerminalStreamByteLength)).toEqual(legacyFlushes) + }) +}) + +// trimPendingOutputCoveredBySnapshot re-measures a sliced chunk with terminalStreamByteLength. +// The RPC suites never reach that slice branch (a `data.length` mutant there survives on +// unmodified HEAD too), so pin the byte accounting the branch depends on here. +describe('resync trim byte accounting for a snapshot-sliced chunk', () => { + it('re-measures a sliced chunk in UTF-8 bytes, not UTF-16 code units', () => { + const data = '\u{1f600}é走a' + const sliced = data.slice(2) + expect(terminalStreamByteLength(sliced)).toBe(legacyByteLength(sliced)) + // Guards the mutant: code-unit length would be 4 here, UTF-8 is 6. + expect(terminalStreamByteLength(sliced)).toBe(6) + expect(terminalStreamByteLength(sliced)).not.toBe(sliced.length) + }) + + it('matches the legacy byte length for every suffix slice of multi-byte terminal text', () => { + const random = mulberry32(0x51ced) + for (let iteration = 0; iteration < 2000; iteration += 1) { + const data = iteration % 2 === 0 ? randomString(random, 24) : rawUtf16(random, 24) + for (let offset = 0; offset <= data.length; offset += 1) { + const sliced = data.slice(offset) + if (terminalStreamByteLength(sliced) !== legacyByteLength(sliced)) { + throw new Error(`sliced byte length diverged for ${JSON.stringify(data)} at ${offset}`) + } + } + } + expect(true).toBe(true) + }) +}) diff --git a/src/main/runtime/rpc/terminal-stream-byte-length.ts b/src/main/runtime/rpc/terminal-stream-byte-length.ts new file mode 100644 index 00000000000..a99bf6c4199 --- /dev/null +++ b/src/main/runtime/rpc/terminal-stream-byte-length.ts @@ -0,0 +1,56 @@ +import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' + +export type TerminalStreamByteLengthMeasurement = { + byteLength: number + exceededLimit: boolean +} + +// UTF-8 encodes a UTF-16 code unit in at most 3 bytes (BMP scalar, or U+FFFD for a lone +// surrogate; a surrogate pair is 4 bytes across 2 units), and never in fewer than 1. So +// `length <= byteLength <= 3 * length` bounds the byte count without touching the string. +const MAX_UTF8_BYTES_PER_CODE_UNIT = 3 + +// Buffer.byteLength's fixed call cost (~14ns) buys nothing until it replaces enough scan +// iterations (~1.5ns each) to pay for itself; measured crossover is 8-12 code units. Below +// this the native call is a REGRESSION on interactive keystroke-echo chunks, so keep the scan. +export const MIN_NATIVE_BYTE_LENGTH_CODE_UNITS = 16 + +export function terminalStreamByteLength(data: string): number { + if (data.length < MIN_NATIVE_BYTE_LENGTH_CODE_UNITS) { + return measureClipboardTextByteLength(data).byteLength + } + return Buffer.byteLength(data, 'utf8') +} + +export function terminalStreamByteLengthExceeds(data: string, maxBytes: number): boolean { + if (data.length === 0 || !Number.isFinite(maxBytes)) { + return false + } + // Sound: UTF-8 is never shorter than UTF-16 code-unit count, so this needs no scan at all. + if (data.length > maxBytes) { + return true + } + if (data.length < MIN_NATIVE_BYTE_LENGTH_CODE_UNITS) { + return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit + } + return Buffer.byteLength(data, 'utf8') > maxBytes +} + +export function measureTerminalStreamByteLength( + data: string, + options: { stopAfterBytes?: number } = {} +): TerminalStreamByteLengthMeasurement { + const stopAfterBytes = options.stopAfterBytes + if (!Number.isFinite(stopAfterBytes)) { + return { byteLength: terminalStreamByteLength(data), exceededLimit: false } + } + // Why: over the limit the callers keep the scan's TRUNCATED running total, so only take the + // native count when the upper bound proves it can't trip the limit — otherwise both would run. + if ( + data.length >= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS && + data.length * MAX_UTF8_BYTES_PER_CODE_UNIT <= (stopAfterBytes as number) + ) { + return { byteLength: Buffer.byteLength(data, 'utf8'), exceededLimit: false } + } + return measureClipboardTextByteLength(data, options) +} diff --git a/src/main/runtime/runtime-metadata-ownership-watch.test.ts b/src/main/runtime/runtime-metadata-ownership-watch.test.ts new file mode 100644 index 00000000000..d8eb4196534 --- /dev/null +++ b/src/main/runtime/runtime-metadata-ownership-watch.test.ts @@ -0,0 +1,218 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { clearRuntimeMetadata, readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata' +import { + shouldReclaimRuntimeMetadata, + watchRuntimeMetadataOwnership, + type RuntimeMetadataOwnershipWatch +} from './runtime-metadata-ownership-watch' + +const OWNED_PID = 4242 +const OWNED_RUNTIME_ID = 'rt_owner' +const FOREIGN_LIVE_PID = 5151 +const FOREIGN_DEAD_PID = 5252 + +function record(overrides: Partial = {}): RuntimeMetadata { + return { + runtimeId: OWNED_RUNTIME_ID, + pid: OWNED_PID, + transports: [{ kind: 'unix', endpoint: '/tmp/orca-owner.sock' }], + authToken: 'secret', + startedAt: 100, + ...overrides + } +} + +const isProcessRunning = (pid: number): boolean => pid === OWNED_PID || pid === FOREIGN_LIVE_PID + +describe('shouldReclaimRuntimeMetadata', () => { + it('leaves the record alone while it still describes this runtime', () => { + expect( + shouldReclaimRuntimeMetadata(record(), OWNED_PID, OWNED_RUNTIME_ID, isProcessRunning) + ).toBe(false) + }) + + it('reclaims a missing record', () => { + expect(shouldReclaimRuntimeMetadata(null, OWNED_PID, OWNED_RUNTIME_ID, isProcessRunning)).toBe( + true + ) + }) + + it('reclaims a record left behind by a dead runtime', () => { + expect( + shouldReclaimRuntimeMetadata( + record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' }), + OWNED_PID, + OWNED_RUNTIME_ID, + isProcessRunning + ) + ).toBe(true) + }) + + it('yields to another live runtime so two instances cannot ping-pong the record', () => { + expect( + shouldReclaimRuntimeMetadata( + record({ pid: FOREIGN_LIVE_PID, runtimeId: 'rt_second_instance' }), + OWNED_PID, + OWNED_RUNTIME_ID, + isProcessRunning + ) + ).toBe(false) + }) + + it('reclaims a foreign runtimeId stamped on this pid', () => { + // Why: only this process can be this pid, so the record is a recycled-pid leftover. + expect( + shouldReclaimRuntimeMetadata( + record({ runtimeId: 'rt_previous_process' }), + OWNED_PID, + OWNED_RUNTIME_ID, + isProcessRunning + ) + ).toBe(true) + }) +}) + +describe('watchRuntimeMetadataOwnership', () => { + const watches: RuntimeMetadataOwnershipWatch[] = [] + const userDataPaths: string[] = [] + + afterEach(() => { + for (const watch of watches.splice(0)) { + watch.stop() + } + for (const dir of userDataPaths.splice(0)) { + clearRuntimeMetadata(dir) + } + vi.useRealTimers() + }) + + function armWatch(userDataPath: string, pollIntervalMs = 10): RuntimeMetadataOwnershipWatch { + const watch = watchRuntimeMetadataOwnership({ + userDataPath, + ownedPid: OWNED_PID, + ownedRuntimeId: OWNED_RUNTIME_ID, + pollIntervalMs, + isProcessRunning, + republish: () => writeRuntimeMetadata(userDataPath, record()) + }) + watches.push(watch) + return watch + } + + function makeUserDataPath(): string { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-ownership-')) + userDataPaths.push(userDataPath) + return userDataPath + } + + it('republishes after a second instance clobbers the record and exits', () => { + const userDataPath = makeUserDataPath() + writeRuntimeMetadata(userDataPath, record()) + const watch = armWatch(userDataPath) + + writeRuntimeMetadata( + userDataPath, + record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' }) + ) + watch.check() + + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ + pid: OWNED_PID, + runtimeId: OWNED_RUNTIME_ID + }) + }) + + it('republishes a record that was deleted underneath the runtime', () => { + const userDataPath = makeUserDataPath() + writeRuntimeMetadata(userDataPath, record()) + const watch = armWatch(userDataPath) + + clearRuntimeMetadata(userDataPath) + watch.check() + + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID }) + }) + + it('replaces an unreadable record', () => { + const userDataPath = makeUserDataPath() + const watch = armWatch(userDataPath) + writeFileSync(getRuntimeMetadataPath(userDataPath), '{ truncated') + + watch.check() + + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID }) + }) + + it('leaves a live sibling runtime in place', () => { + const userDataPath = makeUserDataPath() + const watch = armWatch(userDataPath) + writeRuntimeMetadata( + userDataPath, + record({ pid: FOREIGN_LIVE_PID, runtimeId: 'rt_second_instance' }) + ) + + watch.check() + + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: FOREIGN_LIVE_PID }) + }) + + it('reclaims on the poll interval without an explicit check', () => { + vi.useFakeTimers() + const userDataPath = makeUserDataPath() + armWatch(userDataPath, 1_000) + writeRuntimeMetadata( + userDataPath, + record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' }) + ) + + vi.advanceTimersByTime(1_000) + + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID }) + }) + + it('stops reclaiming once the watch is stopped', () => { + vi.useFakeTimers() + const userDataPath = makeUserDataPath() + const watch = armWatch(userDataPath, 1_000) + + watch.stop() + writeRuntimeMetadata( + userDataPath, + record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' }) + ) + vi.advanceTimersByTime(5_000) + + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: FOREIGN_DEAD_PID }) + }) + + it('keeps polling after a republish failure', () => { + vi.useFakeTimers() + const userDataPath = makeUserDataPath() + const republish = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('disk full') + }) + .mockImplementation(() => writeRuntimeMetadata(userDataPath, record())) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const watch = watchRuntimeMetadataOwnership({ + userDataPath, + ownedPid: OWNED_PID, + ownedRuntimeId: OWNED_RUNTIME_ID, + pollIntervalMs: 1_000, + isProcessRunning, + republish + }) + watches.push(watch) + + vi.advanceTimersByTime(2_000) + + expect(republish).toHaveBeenCalledTimes(2) + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID }) + consoleError.mockRestore() + }) +}) diff --git a/src/main/runtime/runtime-metadata-ownership-watch.ts b/src/main/runtime/runtime-metadata-ownership-watch.ts new file mode 100644 index 00000000000..ccdb8bf8f29 --- /dev/null +++ b/src/main/runtime/runtime-metadata-ownership-watch.ts @@ -0,0 +1,114 @@ +import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { readRuntimeMetadata } from './runtime-metadata' + +/** + * Why: `orca-runtime.json` is the CLI's only pointer at a live runtime, and it + * can stop describing this process while this process is still serving RPC — + * a second instance that slipped past the single-instance lock publishes its + * own pid and then exits, leaving the CLI on a dead pid (`stale_bootstrap`) + * against a healthy app (#7848). Chromium's lock is defeated on macOS whenever + * `SingletonSocket`/`SingletonCookie` go missing, and its socket lives under + * `$TMPDIR` (`/var/folders/.../T`), which macOS itself purges after 3 days. + * + * The owner therefore watches its own record and reclaims it once no live + * runtime is described. Reclaiming only a dead pid is deliberate: two live + * runtimes sharing a profile would otherwise ping-pong the file forever. + */ +export const RUNTIME_METADATA_OWNERSHIP_POLL_MS = 10_000 + +export type RuntimeMetadataOwnershipWatch = { + /** Runs one ownership check immediately; exposed for tests and eager repair. */ + check: () => void + stop: () => void +} + +export type RuntimeMetadataOwnershipWatchOptions = { + userDataPath: string + ownedPid: number + ownedRuntimeId: string + republish: () => void + pollIntervalMs?: number + isProcessRunning?: (pid: number) => boolean + onReclaim?: (previous: RuntimeMetadata | null) => void +} + +export function shouldReclaimRuntimeMetadata( + current: RuntimeMetadata | null, + ownedPid: number, + ownedRuntimeId: string, + isProcessRunning: (pid: number) => boolean +): boolean { + if (!current) { + return true + } + if (current.pid === ownedPid && current.runtimeId === ownedRuntimeId) { + return false + } + // Why: only this process can legitimately claim this pid, so a foreign + // runtimeId on it is a leftover from a recycled pid, not a live sibling. + if (current.pid === ownedPid) { + return true + } + return !isProcessRunning(current.pid) +} + +export function watchRuntimeMetadataOwnership( + options: RuntimeMetadataOwnershipWatchOptions +): RuntimeMetadataOwnershipWatch { + const isProcessRunning = options.isProcessRunning ?? isPidRunning + const check = (): void => { + const current = tryReadRuntimeMetadata(options.userDataPath) + if ( + !shouldReclaimRuntimeMetadata( + current, + options.ownedPid, + options.ownedRuntimeId, + isProcessRunning + ) + ) { + return + } + try { + options.republish() + } catch (error) { + // Why: a transient write failure must not kill the watch; the next tick retries. + console.error('[runtime] Failed to reclaim runtime metadata ownership:', error) + return + } + options.onReclaim?.(current) + } + + const timer = setInterval(check, options.pollIntervalMs ?? RUNTIME_METADATA_OWNERSHIP_POLL_MS) + // Why: discovery bookkeeping must never be the reason the process stays alive. + timer.unref?.() + return { + check, + stop: () => clearInterval(timer) + } +} + +function tryReadRuntimeMetadata(userDataPath: string): RuntimeMetadata | null { + try { + return readRuntimeMetadata(userDataPath) + } catch (error) { + // Why: an unparseable record is as useless to the CLI as a missing one, so treat it as reclaimable. + console.warn( + `[runtime] Ignoring unreadable ${getRuntimeMetadataPath(userDataPath)}:`, + error instanceof Error ? error.message : String(error) + ) + return null + } +} + +function isPidRunning(pid: number): boolean { + if (!pid || pid <= 0) { + return false + } + try { + process.kill(pid, 0) + return true + } catch (error) { + // Why: only ESRCH proves the pid is gone; EPERM means a foreign owner holds it (same rule as the socket sweep). + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 2ed908bba4c..87d984b6f02 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -11,7 +11,7 @@ import Database from '../sqlite/sync-database' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' import * as runtimeMetadataModule from './runtime-metadata' -import { readRuntimeMetadata } from './runtime-metadata' +import { readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' import { parsePairingCode } from '../../shared/pairing' import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client' @@ -362,6 +362,81 @@ describe('OrcaRuntimeRpcServer', () => { }) }) + it('reclaims runtime metadata clobbered by a second instance that has since died', async () => { + // Why: #7848 — a launch that slips past the single-instance lock republishes + // orca-runtime.json with its own pid, so the CLI reports stale_bootstrap + // against this still-serving runtime once that instance exits. + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) + await server.start() + const published = readRuntimeMetadata(userDataPath) + + writeRuntimeMetadata(userDataPath, { + runtimeId: 'rt_second_instance', + pid: 99999999, + transports: [{ kind: 'unix', endpoint: join(userDataPath, 'o-99999999-rt2.sock') }], + authToken: 'second-instance-token', + startedAt: 1 + }) + server.checkRuntimeMetadataOwnership() + + expect(readRuntimeMetadata(userDataPath)).toEqual(published) + + await server.stop() + }) + + it('leaves runtime metadata owned by a live sibling runtime untouched', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why: a synthetic owned pid frees the always-alive process.pid to stand in for + // the sibling — Windows never assigns pid 1, so hardcoding it there reads as dead. + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + pid: 4242 + }) + await server.start() + + writeRuntimeMetadata(userDataPath, { + runtimeId: 'rt_live_sibling', + pid: process.pid, + transports: [{ kind: 'unix', endpoint: join(userDataPath, `o-${process.pid}-rt2.sock`) }], + authToken: 'sibling-token', + startedAt: 1 + }) + server.checkRuntimeMetadataOwnership() + + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_live_sibling' }) + + await server.stop() + }) + + it('stops reclaiming runtime metadata after the server is stopped', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ runtime: new OrcaRuntimeService(), userDataPath }) + await server.start() + const watch = server['metadataOwnershipWatch'] + if (!watch) { + throw new Error('start() must arm the metadata ownership watch') + } + // Why: the republish guard alone would keep this test green, so assert the timer teardown itself. + const watchStop = vi.spyOn(watch, 'stop') + await server.stop() + + writeRuntimeMetadata(userDataPath, { + runtimeId: 'rt_second_instance', + pid: 99999999, + transports: [], + authToken: 'second-instance-token', + startedAt: 1 + }) + server.checkRuntimeMetadataOwnership() + + expect(watchStop).toHaveBeenCalledTimes(1) + expect(server['metadataOwnershipWatch']).toBeNull() + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_second_instance' }) + }) + it('creates a pairing offer for the active WebSocket transport', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService() diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index a4ac6470461..54734e693c5 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -6,6 +6,11 @@ import { join } from 'node:path' import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' import type { OrcaRuntimeService } from './orca-runtime' import { writeRuntimeMetadata } from './runtime-metadata' +import { + RUNTIME_METADATA_OWNERSHIP_POLL_MS, + watchRuntimeMetadataOwnership, + type RuntimeMetadataOwnershipWatch +} from './runtime-metadata-ownership-watch' import { RpcDispatcher } from './rpc/dispatcher' import type { RpcRequest, RpcResponse } from './rpc/core' import { errorResponse } from './rpc/errors' @@ -57,6 +62,8 @@ type OrcaRuntimeRpcServerOptions = { // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). keepaliveIntervalMs?: number longPollCap?: number + // Why: test-only override for the ownership reclaim cadence. + metadataOwnershipPollMs?: number } export type PairingOfferUnavailableReason = @@ -450,6 +457,7 @@ export class OrcaRuntimeRpcServer { private readonly authToken = randomBytes(24).toString('hex') private readonly keepaliveIntervalMs: number private readonly longPollCap: number + private readonly metadataOwnershipPollMs: number private readonly askLongPollCap: number private readonly relayRevokeOutbox: RelayRevokeOutbox private deviceRegistry: DeviceRegistry | null = null @@ -458,6 +466,7 @@ export class OrcaRuntimeRpcServer { private tlsFingerprint: string | null = null private activeTransports: RpcTransport[] = [] private transports: RuntimeTransportMetadata[] = [] + private metadataOwnershipWatch: RuntimeMetadataOwnershipWatch | null = null private mobileSocketWiring: MobileSocketWiring | null = null private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null private onUnpairedDeviceAuthFailure: (() => void) | null = null @@ -485,7 +494,8 @@ export class OrcaRuntimeRpcServer { preferPinnedWsPort = false, webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, - longPollCap = LONG_POLL_CAP + longPollCap = LONG_POLL_CAP, + metadataOwnershipPollMs = RUNTIME_METADATA_OWNERSHIP_POLL_MS }: OrcaRuntimeRpcServerOptions) { this.runtime = runtime this.dispatcher = new RpcDispatcher({ runtime }) @@ -498,6 +508,7 @@ export class OrcaRuntimeRpcServer { this.webClientRoot = webClientRoot this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap + this.metadataOwnershipPollMs = metadataOwnershipPollMs // Why: derived, not configurable — the reservation must hold for whatever cap a caller picks. this.askLongPollCap = Math.max(1, Math.floor(longPollCap * ASK_LONG_POLL_SHARE)) this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath) @@ -1003,12 +1014,38 @@ export class OrcaRuntimeRpcServer { await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {}) throw error } + + this.metadataOwnershipWatch = watchRuntimeMetadataOwnership({ + userDataPath: this.userDataPath, + ownedPid: this.pid, + ownedRuntimeId: this.runtime.getRuntimeId(), + pollIntervalMs: this.metadataOwnershipPollMs, + republish: () => { + // Why: never advertise endpoints we already tore down. + if (this.activeTransports.length === 0) { + return + } + this.writeMetadata() + }, + onReclaim: (previous) => { + console.warn( + `[runtime] Reclaimed orca-runtime.json from a dead runtime (pid ${previous?.pid ?? 'none'}); republished pid ${this.pid}.` + ) + } + }) + } + + /** Why: test-only seam — runs one ownership check instead of waiting out the poll interval. */ + checkRuntimeMetadataOwnership(): void { + this.metadataOwnershipWatch?.check() } async stop(): Promise { const transports = this.activeTransports this.activeTransports = [] this.transports = [] + this.metadataOwnershipWatch?.stop() + this.metadataOwnershipWatch = null this.mobileSocketWiring = null if (transports.length === 0) { return diff --git a/src/main/ssh/ssh-connection-sftp-wire.test.ts b/src/main/ssh/ssh-connection-sftp-wire.test.ts index 9f99dc22233..8e107f9a2aa 100644 --- a/src/main/ssh/ssh-connection-sftp-wire.test.ts +++ b/src/main/ssh/ssh-connection-sftp-wire.test.ts @@ -160,7 +160,8 @@ function installSftpHandlers(sftp: SFTPWrapper, backingRoot: string, operations: async function startSftpWireServer(backingRoot: string): Promise { const operations: string[] = [] const connections = new Set() - const hostKey = utils.generateKeyPairSync('ed25519').private + // Ed25519 keygen can produce an invalid 31-byte key; ECDSA points always start with 0x04. + const hostKey = utils.generateKeyPairSync('ecdsa', { bits: 256 }).private const server = new Ssh2Server({ hostKeys: [hostKey] }, (connection) => { connections.add(connection) connection.on('error', () => {}) diff --git a/src/relay/git-handler-branch-compare.test.ts b/src/relay/git-handler-branch-compare.test.ts new file mode 100644 index 00000000000..da557637971 --- /dev/null +++ b/src/relay/git-handler-branch-compare.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import { branchCompare, type GitExec } from './git-handler-ops' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + +describe('relay branchCompare', () => { + it('launches independent Git reads before waiting for any result', async () => { + const branch = deferred<{ stdout: string; stderr: string }>() + const head = deferred<{ stdout: string; stderr: string }>() + const base = deferred<{ stdout: string; stderr: string }>() + const changes = deferred[]>() + const git = vi.fn((args) => { + if (args[0] === 'branch') { + return branch.promise + } + if (args[0] === 'rev-parse' && args.includes('HEAD')) { + return head.promise + } + if (args[0] === 'rev-parse') { + return base.promise + } + if (args[0] === 'merge-base') { + return Promise.resolve({ stdout: 'merge-base\n', stderr: '' }) + } + if (args[0] === 'rev-list') { + return Promise.resolve({ stdout: '1\n', stderr: '' }) + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + const loadBranchChanges = vi.fn(() => changes.promise) + + const pending = branchCompare(git, '/repo', 'origin/main', loadBranchChanges) + await Promise.resolve() + + expect(git.mock.calls.map(([args]) => args.join(' '))).toEqual([ + 'branch --show-current', + 'rev-parse --verify HEAD', + 'rev-parse --verify origin/main' + ]) + + branch.resolve({ stdout: 'feature\n', stderr: '' }) + head.resolve({ stdout: 'head\n', stderr: '' }) + base.resolve({ stdout: 'base\n', stderr: '' }) + await vi.waitFor(() => expect(loadBranchChanges).toHaveBeenCalledOnce()) + expect(git.mock.calls.some(([args]) => args[0] === 'rev-list')).toBe(true) + changes.resolve([{ path: 'file.ts' }]) + + await expect(pending).resolves.toMatchObject({ + summary: { compareRef: 'feature', changedFiles: 1, commitsAhead: 1, status: 'ready' } + }) + }) +}) diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index afe75a2b582..71a678fe042 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -154,27 +154,29 @@ export async function branchCompare( status: 'loading' } - try { - const { stdout: branchOut } = await git(['branch', '--show-current'], worktreePath) - const branch = branchOut.trim() - if (branch) { - summary.compareRef = branch - } - } catch { - /* keep HEAD */ - } - - let headOid: string - let baseOid = '' - try { - const { stdout } = await git(['rev-parse', '--verify', 'HEAD'], worktreePath) - headOid = stdout.trim() - summary.headOid = headOid - } catch { + const readCompareRef = async (): Promise => { try { - const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath) - baseOid = stdout.trim() - summary.baseOid = baseOid + const { stdout } = await git(['branch', '--show-current'], worktreePath) + return stdout.trim() || 'HEAD' + } catch { + return 'HEAD' + } + } + const readOid = (ref: string) => + git(['rev-parse', '--verify', ref], worktreePath).then( + ({ stdout }) => ({ ok: true as const, oid: stdout.trim() }), + (error) => ({ ok: false as const, error }) + ) + const [compareRef, headOidResult, baseOidResult] = await Promise.all([ + readCompareRef(), + readOid('HEAD'), + readOid(baseRef) + ]) + summary.compareRef = compareRef + + if (!headOidResult.ok) { + if (baseOidResult.ok) { + summary.baseOid = baseOidResult.oid // Why: new remote worktrees can be on an unborn branch until the first // commit. There are no committed branch changes yet; surfacing this as a // compare error makes the source-control panel look broken. @@ -182,9 +184,6 @@ export async function branchCompare( summary.commitsAhead = 0 summary.status = 'ready' return { summary, entries: [] } - } catch { - // Preserve the existing unborn-head message when even the base is not - // resolvable; callers cannot compare or present a useful empty state. } summary.status = 'unborn-head' summary.errorMessage = @@ -192,15 +191,15 @@ export async function branchCompare( return { summary, entries: [] } } - try { - const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath) - baseOid = stdout.trim() - summary.baseOid = baseOid - } catch { + const headOid = headOidResult.oid + summary.headOid = headOid + if (!baseOidResult.ok) { summary.status = 'invalid-base' summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.` return { summary, entries: [] } } + const baseOid = baseOidResult.oid + summary.baseOid = baseOid let mergeBase: string try { @@ -214,11 +213,10 @@ export async function branchCompare( } try { - const entries = await loadBranchChanges(mergeBase, headOid) - const { stdout: countOut } = await git( - ['rev-list', '--count', `${baseOid}..${headOid}`], - worktreePath - ) + const [entries, { stdout: countOut }] = await Promise.all([ + loadBranchChanges(mergeBase, headOid), + git(['rev-list', '--count', `${baseOid}..${headOid}`], worktreePath) + ]) summary.changedFiles = entries.length summary.commitsAhead = Number.parseInt(countOut.trim(), 10) || 0 summary.status = 'ready' diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index cdc0717b7a9..d883c742425 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -782,14 +782,16 @@ export class GitHandler { const gitBound = this.git.bind(this) return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => { // Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8; without it parseBranchDiff would get C-style octal-escaped paths. - const { stdout } = await gitBound( - ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], - worktreePath - ) - const { stdout: numstat } = await gitBound( - ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], - worktreePath - ) + const [{ stdout }, { stdout: numstat }] = await Promise.all([ + gitBound( + ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], + worktreePath + ), + gitBound( + ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], + worktreePath + ) + ]) return parseBranchDiff(stdout, parseNumstat(numstat)) }) } diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 0c2982043ff..e730d4328ff 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -137,6 +137,10 @@ import { translate } from '@/i18n/i18n' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' +import { + combineTerminalWorktreeParkIds, + useManualTerminalWorktreeParking +} from './terminal-pane/use-manual-terminal-worktree-parking' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -738,6 +742,15 @@ function Terminal(): React.JSX.Element | null { const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState>( () => new Set() ) + const manuallyParkedTerminalWorktreeIds = useManualTerminalWorktreeParking({ + activeView, + renderedActiveWorktreeId + }) + const effectiveParkedTerminalWorktreeIds = useMemo( + () => + combineTerminalWorktreeParkIds(parkedTerminalWorktreeIds, manuallyParkedTerminalWorktreeIds), + [manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds] + ) // Tab restriction for targeted background mounts (wake/resume); a worktree absent from this map mounts all its tabs. const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) // Why: only cold-activation deferral (not targeted mounts, which share the map above) creates watcher coverage for every unmounted tab. @@ -1055,9 +1068,9 @@ function Terminal(): React.JSX.Element | null { const coldWorktreePresentationTargets = useMemo( () => activeView === 'terminal' && activeTabType === 'terminal' - ? parkedTerminalWorktreeIds + ? effectiveParkedTerminalWorktreeIds : new Set(), - [activeTabType, activeView, parkedTerminalWorktreeIds] + [activeTabType, activeView, effectiveParkedTerminalWorktreeIds] ) const { presentationByScope: worktreePresentationByScope, @@ -1088,7 +1101,7 @@ function Terminal(): React.JSX.Element | null { const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const parked = - !isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id) + !shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id) if (parked) { for (const tab of tabs) { const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { @@ -1135,7 +1148,7 @@ function Terminal(): React.JSX.Element | null { backgroundMountRevision, getEffectiveLayoutForWorktree, groupsByWorktree, - parkedTerminalWorktreeIds, + effectiveParkedTerminalWorktreeIds, pendingStartupByTabId, renderedActiveWorktreeId, tabsByWorktree, @@ -2107,9 +2120,7 @@ function Terminal(): React.JSX.Element | null { const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const shouldColdParkTerminalPanes = - !isVisible && - !shouldMeasureHiddenWorktree && - parkedTerminalWorktreeIds.has(workspace.id) + !shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id) return ( ({ AgentKanbanCard: ({ card, + repoIcon, now, onOpenTerminal }: { card: DashboardCard + repoIcon?: RepoIcon | null now: number onOpenTerminal: (card: DashboardCard) => void }) => ( @@ -23,6 +26,7 @@ vi.mock('./AgentKanbanCard', () => ({ data-bucket={card.bucket} data-unseen={card.unseen} data-now={now} + data-repo-icon={repoIcon === null ? 'none' : JSON.stringify(repoIcon)} onClick={() => onOpenTerminal(card)} > {card.worktreeName} @@ -70,8 +74,11 @@ function card(overrides: Partial): DashboardCard { } } -function renderBoard(cards: DashboardCard[]): void { - const snapshot: DashboardSnapshot = { generatedAt: 1, cards } +function renderBoard( + cards: DashboardCard[], + repoIconsByRepoId?: Record +): void { + const snapshot: DashboardSnapshot = { generatedAt: 1, cards, repoIconsByRepoId } render() } @@ -108,6 +115,30 @@ describe('AgentKanbanBoard', () => { expect(screen.getByText('3 total')).toBeTruthy() }) + it('leaves every column border neutral now that cards carry the state color', () => { + renderBoard([card({ bucket: 'attention' })]) + for (const column of document.querySelectorAll('section')) { + expect(column.className).toContain('border-border/60') + expect(column.className).not.toContain('amber') + } + }) + + it('routes each card its own repo icon', () => { + renderBoard( + [ + card({ repoId: 'r1', worktreeName: 'from-r1' }), + card({ repoId: 'r2', worktreeName: 'from-r2' }), + card({ repoId: 'r3', worktreeName: 'from-r3' }) + ], + { r1: { type: 'lucide', name: 'Rocket' }, r2: null } + ) + + expect(screen.getByText('from-r1').dataset.repoIcon).toBe('{"type":"lucide","name":"Rocket"}') + expect(screen.getByText('from-r2').dataset.repoIcon).toBe('none') + // Unknown repo → the card's own default glyph, never another repo's icon. + expect(screen.getByText('from-r3').dataset.repoIcon).toBe('none') + }) + it('shows "None" for empty columns', () => { renderBoard([card({ bucket: 'working' })]) // attention and idle are empty → two "None" placeholders. diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index 01c46072c9d..b43fc971efb 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -6,7 +6,9 @@ import { type DashboardCard, type DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' import { cn } from '@/lib/utils' +import { TooltipProvider } from '@/components/ui/tooltip' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { AgentKanbanCard } from './AgentKanbanCard' import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog' @@ -58,22 +60,20 @@ function groupByBucket(cards: DashboardCard[]): Record | undefined now: number onOpenTerminal: (card: DashboardCard) => void }): React.JSX.Element { - const highlight = bucket === 'attention' && cards.length > 0 return ( -
+ // Why: attention no longer tints the whole column — the cards inside carry + // their own state color, so a column border would double-signal it. +
{bucketLabel(bucket)} @@ -92,6 +92,7 @@ function KanbanColumn({ @@ -192,54 +193,60 @@ export function AgentKanbanBoard({ }, [dialogCard?.unseen, dialogCard?.paneKey, onAckAgent]) return ( -
-
-

- {translate('dashboardPopout.title', 'Agents')} -

- - {translate('dashboardPopout.total', '{{count}} total', { - count: snapshot.cards.length - })} - - {headerActions || onClose ? ( -
- {headerActions} - {onClose ? ( - - ) : null} -
- ) : null} -
-
- {/* Why: columns share the window width up to a readable cap; mx-auto + // Why: the pop-out is its own React root with no app-level provider, and the + // card's repo tooltip needs one in both hosts. Nesting inside the main + // window's provider is harmless. + +
+
+

+ {translate('dashboardPopout.title', 'Agents')} +

+ + {translate('dashboardPopout.total', '{{count}} total', { + count: snapshot.cards.length + })} + + {headerActions || onClose ? ( +
+ {headerActions} + {onClose ? ( + + ) : null} +
+ ) : null} +
+
+ {/* Why: columns share the window width up to a readable cap; mx-auto centers the capped board so leftover space splits evenly instead of pooling on the right. In overflow the auto margins collapse to 0, keeping the left edge reachable while scrolling. */} -
- {DASHBOARD_BUCKET_ORDER.map((bucket) => ( - - ))} +
+ {DASHBOARD_BUCKET_ORDER.map((bucket) => ( + + ))} +
+
- -
+
) } diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index ebcfee3d26b..16739e15d99 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -4,7 +4,9 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' import { i18n } from '@/i18n/i18n' +import { TooltipProvider } from '@/components/ui/tooltip' import { AgentKanbanCard } from './AgentKanbanCard' const agentIconRender = vi.fn() @@ -42,6 +44,24 @@ function card(overrides: Partial = {}): DashboardCard { } } +function renderCard(props: { + card: DashboardCard + now: number + repoIcon?: RepoIcon | null + onOpenTerminal?: () => void +}): ReturnType { + return render( + + + + ) +} + describe('AgentKanbanCard', () => { beforeEach(async () => { await i18n.changeLanguage('en') @@ -53,9 +73,7 @@ describe('AgentKanbanCard', () => { }) it('does not render an invented age when the start time is unknown', () => { - render( - - ) + renderCard({ card: card({ startedAt: 0 }), now: 2_000_000_000 }) expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument() }) @@ -66,47 +84,160 @@ describe('AgentKanbanCard', () => { dotState: 'waiting', askSummary: 'Approve deploy?' }) - const { container, rerender } = render( - - ) + const { container, rerender } = renderCard({ card: attentionCard, now: 2_000 }) expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument() expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1) rerender( - + + + ) expect(screen.getByTestId('state-dot')).toBeInTheDocument() expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull() }) + it('tints attention amber and done green, leaving every other state neutral', () => { + const { container: attention } = renderCard({ + card: card({ bucket: 'attention', dotState: 'waiting' }), + now: 2_000 + }) + expect(attention.querySelector('button')?.className).toContain('border-amber-500/40') + + cleanup() + const { container: done } = renderCard({ + card: card({ bucket: 'idle', dotState: 'done' }), + now: 2_000 + }) + expect(done.querySelector('button')?.className).toContain('border-emerald-500/40') + + cleanup() + const { container: idle } = renderCard({ + card: card({ bucket: 'idle', dotState: 'idle' }), + now: 2_000 + }) + const idleClassName = idle.querySelector('button')?.className ?? '' + expect(idleClassName).toContain('border-border/60') + expect(idleClassName).not.toContain('emerald') + expect(idleClassName).not.toContain('amber') + }) + + it('heads the card with the conversation name and drops the worktree to the footer', () => { + const { container } = renderCard({ + card: card({ lastUserMessage: 'ship it', conversationName: 'Sparse-checkout parser' }), + now: 2_000 + }) + + const [header, footer] = [ + container.querySelector('button')!.firstElementChild!, + container.querySelector('button')!.lastElementChild! + ] + expect(header).toHaveTextContent('Sparse-checkout parser') + expect(header).not.toHaveTextContent('dashboard-review') + expect(footer).toHaveTextContent('dashboard-review') + // The message line is attributed to the user again — the name moved up. + expect(screen.getByText('You')).toBeInTheDocument() + }) + + it('heads the card with the worktree when no name resolves, without repeating it', () => { + const { container } = renderCard({ card: card({ lastUserMessage: 'ship it' }), now: 2_000 }) + + expect(screen.getAllByText('dashboard-review')).toHaveLength(1) + expect(container.querySelector('button')!.firstElementChild).toHaveTextContent( + 'dashboard-review' + ) + }) + + it('shows the repo as an icon labelled with its name instead of inline text', () => { + renderCard({ + card: card(), + now: 2_000, + repoIcon: { type: 'emoji', emoji: '🐳' } + }) + + expect(screen.getByLabelText('Orca')).toBeInTheDocument() + expect(screen.getByText('🐳')).toBeInTheDocument() + }) + it('skips structured-clone rerenders until visible card data or its age changes', () => { const onOpenTerminal = vi.fn() const initial = card({ startedAt: 1_000 }) + const repoIcon: RepoIcon = { type: 'lucide', name: 'Rocket' } const { rerender } = render( - + + + ) expect(agentIconRender).toHaveBeenCalledTimes(1) expect(screen.getByText('1m')).toBeInTheDocument() - rerender() + // A fresh structured clone of identical data — including the repo icon. + rerender( + + + + ) expect(agentIconRender).toHaveBeenCalledTimes(1) rerender( - + + + ) expect(agentIconRender).toHaveBeenCalledTimes(2) expect(screen.getByText('2m')).toBeInTheDocument() }) - it('updates the relative age when the UI language changes', async () => { - render( - + it('rerenders when the repo icon changes', () => { + const onOpenTerminal = vi.fn() + const initial = card({ startedAt: 1_000 }) + const { rerender } = render( + + + ) + expect(agentIconRender).toHaveBeenCalledTimes(1) + + rerender( + + + + ) + expect(agentIconRender).toHaveBeenCalledTimes(2) + }) + + it('updates the relative age when the UI language changes', async () => { + renderCard({ card: card({ startedAt: 1_000 }), now: 121_500 }) expect(screen.getByText('2m')).toBeInTheDocument() await act(async () => { diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index e8d08b525d6..9262077e17d 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -4,8 +4,11 @@ import { MessageCircleQuestion } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { AgentStateDot } from '@/components/AgentStateDot' +import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' import { translate } from '@/i18n/i18n' /** Compact "started N ago" (the card is glanceable — coarse units are fine). */ @@ -53,12 +56,34 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { a.finishedAt === b.finishedAt && a.stateChangedAt === b.stateChangedAt && a.unseen === b.unseen && - a.askSummary === b.askSummary + a.askSummary === b.askSummary && + a.conversationName === b.conversationName ) } +/** Structural — the icon arrives inside a fresh structured clone each publish, + * so identity alone would re-render every card several times a second. */ +function sameRepoIcon(a: RepoIcon | null | undefined, b: RepoIcon | null | undefined): boolean { + if (a === b) { + return true + } + if (!a || !b || a.type !== b.type) { + return false + } + if (a.type === 'lucide') { + return a.name === (b as typeof a).name + } + if (a.type === 'emoji') { + return a.emoji === (b as typeof a).emoji + } + const image = b as typeof a + return a.src === image.src && a.source === image.source && a.label === image.label +} + type AgentKanbanCardProps = { card: DashboardCard + /** The card repo's icon. null renders the default folder glyph. */ + repoIcon?: RepoIcon | null now: number /** Opens the board-level terminal dialog. The dialog is NOT owned by the * card: bucket moves remount the card, and an embedded dialog would close @@ -68,8 +93,23 @@ type AgentKanbanCardProps = { /** One agent on the kanban board. Clicking opens the board's live terminal dialog. */ export const AgentKanbanCard = memo( - function AgentKanbanCard({ card, now, onOpenTerminal }: AgentKanbanCardProps): React.JSX.Element { + function AgentKanbanCard({ + card, + repoIcon = null, + now, + onOpenTerminal + }: AgentKanbanCardProps): React.JSX.Element { useTranslation() + // Why: the two outcomes worth scanning for get a tinted card — amber for + // "answer me", green for "finished, look at it". Everything else stays + // neutral so the tint keeps meaning something. + const needsYou = card.bucket === 'attention' + const isDone = card.dotState === 'done' + // Why: the session's own name heads the card. Without one the worktree is + // the best heading left — and then the footer drops it rather than say it + // twice. + const heading = card.conversationName ?? card.worktreeName + const worktreeInFooter = card.conversationName !== undefined return (