diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 46edfc54111..e36ec4c65d8 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -39,6 +39,9 @@ runs: with: install: false + # Why both lockfiles: setup-node keys the pnpm store on the root lockfile alone, so + # jobs that also install mobile restored a store with none of the React Native tree + # in it and re-downloaded the lot on every run. - name: Setup Node.js id: default-node if: inputs.node-version == '' @@ -46,6 +49,9 @@ runs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Setup requested Node.js id: requested-node @@ -54,6 +60,9 @@ runs: with: node-version: ${{ inputs.node-version }} cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Validate native runtime shell: bash diff --git a/.github/workflows/cloud-operate-relay-production-rehome-job.yml b/.github/workflows/cloud-operate-relay-production-rehome-job.yml index 1ceadcece12..682953af5e7 100644 --- a/.github/workflows/cloud-operate-relay-production-rehome-job.yml +++ b/.github/workflows/cloud-operate-relay-production-rehome-job.yml @@ -26,6 +26,9 @@ permissions: defaults: run: + # `shell: bash` adds pipefail; without it `node ... | tee` reports tee's exit code and a + # thrown inspect/apply passed green (Aug 28-29 and Sep 3 2026 runs). + shell: bash working-directory: cloud jobs: diff --git a/.github/workflows/cloud-prove-relay-asia-staging.yml b/.github/workflows/cloud-prove-relay-asia-staging.yml index 9a66e967b57..56677a98600 100644 --- a/.github/workflows/cloud-prove-relay-asia-staging.yml +++ b/.github/workflows/cloud-prove-relay-asia-staging.yml @@ -55,9 +55,11 @@ jobs: - name: Validate the exact staging proof request shell: bash + env: + CONFIRMATION: ${{ inputs.confirmation }} run: | set -euo pipefail - test "${{ inputs.confirmation }}" = PROVE_ASIA_STAGING + test "${CONFIRMATION}" = PROVE_ASIA_STAGING [[ "${IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] [[ "${INITIAL_SELECTOR_GENERATION}" =~ ^[1-9][0-9]*$ ]] [[ "${PROMOTE_ATTEMPT_ID}" =~ ^[A-Za-z0-9_-]{8,128}$ ]] diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9d869528f09..a749214e232 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -28,6 +28,7 @@ jobs: outputs: should_run: ${{ steps.filter.outputs.should_run }} native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} + mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} static_analysis: ${{ steps.filter.outputs.static_analysis }} typecheck: ${{ steps.filter.outputs.typecheck }} git_compatibility: ${{ steps.filter.outputs.git_compatibility }} @@ -95,6 +96,25 @@ jobs: - name: Enforce type-aware code-quality baseline run: pnpm run audit:code-quality:type-aware + # Why: the changed-code gate lints mobile files too, and its type-aware pass + # resolves types from mobile/node_modules. Mobile is a separate pnpm project, + # so the root install above leaves it empty and every mobile type degrades to + # an `error` type — reported as phantom findings against the changed lines. + # Why no --ignore-scripts, unlike the root install: mobile's postinstall generates + # the gitignored terminal/mermaid webview engine modules that tracked source imports, + # and skipping it degrades those very types the step exists to resolve. The drift + # guard mirrors the root install so a stale mobile lockfile fails by name — mobile's + # lockfile carries patchedDependencies that a silent rewrite would drop. + - name: Install mobile dependencies + if: needs.code_paths.outputs.mobile_dependencies == 'true' + working-directory: mobile + run: | + pnpm install --frozen-lockfile + if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then + git -C "$GITHUB_WORKSPACE" diff --exit-code -- \ + mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml + fi + - name: Enforce changed-code quality run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" @@ -360,7 +380,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies # Why: the check rebuilds every package in the manifest from a pinned upstream - # commit — @xterm/xterm and the two addons, each built twice (once unmodified to + # commit — @xterm/xterm and its three addons, each built twice (once unmodified to # prove the toolchain still reproduces the published bundles, once patched). Caching # the npm metadata and the shallow clone keeps the repeated cost to the builds # themselves; the key is the manifest, so a commit, package or toolchain bump @@ -797,6 +817,7 @@ jobs: src/main/cli/wsl-cli-powershell-boundary.test.ts src/main/cursor/hook-service.test.ts src/main/orca-profiles/profile-index-store.test.ts + src/main/startup/windows-install-dir-acl-repair.win32.test.ts src/main/runtime/repo-worktree-admin-fingerprint.test.ts src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts src/shared/secure-file-fsync-flags.test.ts diff --git a/.gitignore b/.gitignore index 3fb72a6486a..8be3fc5b6f4 100644 --- a/.gitignore +++ b/.gitignore @@ -158,6 +158,7 @@ src/renderer/src/i18n/locales/.zh-catalog-cache.json src/renderer/src/i18n/locales/.ko-catalog-cache.json src/renderer/src/i18n/locales/.ja-catalog-cache.json src/renderer/src/i18n/locales/.es-catalog-cache.json +src/renderer/src/i18n/locales/.fr-catalog-cache.json # Bench result JSONs are working artifacts tests/tools/benchmarks/results/terminal-pipeline-*.json diff --git a/cloud/.gitleaks.toml b/cloud/.gitleaks.toml index fc5c725c37d..0bb1f966fae 100644 --- a/cloud/.gitleaks.toml +++ b/cloud/.gitleaks.toml @@ -13,3 +13,10 @@ description = "Cloud SQL rollout lease holder keys in the action's unit tests" regexTarget = "secret" paths = ['''\.github/actions/cloud-sql-rollout-lease/[a-z-]+\.test\.mjs$'''] regexes = ['''^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/[0-9]+$'''] + +# RFC 6455 §1.3 example handshake nonce ("the sample nonce" in base64), sent by the raw-socket +# upgrade tests; the generic key rule reads any base64 header value as a secret. +[[allowlists]] +description = "RFC 6455 example Sec-WebSocket-Key in upgrade tests" +regexTarget = "secret" +regexes = ['''^dGhlIHNhbXBsZSBub25jZQ==$'''] diff --git a/cloud/apps/relay-ops/src/incident-monitor.test.ts b/cloud/apps/relay-ops/src/incident-monitor.test.ts index 51153bb63e1..4e1da9fab26 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.test.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.test.ts @@ -111,14 +111,41 @@ describe('incident monitor evaluator', () => { }) }) - it('freezes when postgres retries exceed the recalibrated ceiling', () => { - const sample = healthySample() - sample.sources['relay-logs']!.signals['relay.postgres_retries'] = - signal(INCIDENT_MONITOR_THRESHOLDS.relayPostgresRetries + 1) - expect(evaluateIncidentSample(sample, startedAt)).toMatchObject({ + // Why: the global relay_cells lock made retries a steady-state rate (24 h p99 + // 1320/5min on 2026-09-04); the bar fences only unbounded growth beyond that. + it('tolerates the measured healthy retry rate and freezes above the bar', () => { + const healthy = healthySample() + healthy.sources['relay-logs']!.signals['relay.postgres_retries'] = signal(1504) + expect(evaluateIncidentSample(healthy, startedAt).status).toBe('green') + + const incident = healthySample() + incident.sources['relay-logs']!.signals['relay.postgres_retries'] = signal(2001) + expect(evaluateIncidentSample(incident, startedAt)).toMatchObject({ status: 'freeze', failures: [ - expect.objectContaining({ signal: 'relay.postgres_retries', threshold: 300 }) + expect.objectContaining({ signal: 'relay.postgres_retries', threshold: 2000 }) + ] + }) + }) + + // Why: since #18521 the request path fails fast on the cell-inventory lock, so + // exhaustion is a steady contention rate (post-#18521 p90 147/5min, max 220), + // not an anomaly. The bar bounds it below the 2026-08-23 incident peak of 467. + it('tolerates the measured healthy exhaustion rate and freezes above the bar', () => { + const healthy = healthySample() + healthy.sources['relay-logs']!.signals['relay.postgres_retry_exhausted'] = signal(220) + expect(evaluateIncidentSample(healthy, startedAt).status).toBe('green') + + const atLimit = healthySample() + atLimit.sources['relay-logs']!.signals['relay.postgres_retry_exhausted'] = signal(300) + expect(evaluateIncidentSample(atLimit, startedAt).status).toBe('green') + + const incident = healthySample() + incident.sources['relay-logs']!.signals['relay.postgres_retry_exhausted'] = signal(301) + expect(evaluateIncidentSample(incident, startedAt)).toMatchObject({ + status: 'freeze', + failures: [ + expect.objectContaining({ signal: 'relay.postgres_retry_exhausted', threshold: 300 }) ] }) }) diff --git a/cloud/apps/relay-ops/src/incident-monitor.ts b/cloud/apps/relay-ops/src/incident-monitor.ts index 6073e351511..a121568d918 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.ts @@ -15,8 +15,8 @@ export const INCIDENT_MONITOR_THRESHOLDS = { // Why: healthy latest-sum backends idle near 100 but spike to 216 in 1-minute // bursts (~10 min/day exceeded the old bar of 160 on 2026-08-26, freezing a // pre-drain gate on baseline noise). 250 clears measured healthy peaks while - // firing well before the verified 400-connection ceiling; pool-wait and - // exhausted-retry signals keep their strict thresholds. + // firing well before the verified 400-connection ceiling; the retry signals + // below discriminate incident-class contention. cloudSqlBackends: 250, // Bound the observed recovery load; deadlocks remain zero-tolerance. cloudSqlLockWaits: 20, @@ -32,13 +32,35 @@ export const INCIDENT_MONITOR_THRESHOLDS = { relayPoolWaiting: 800, relayPoolWaitMs: 2_500, // Why: successful lock retries are the contention machinery working, not harm. - // Healthy 2026-08-26 baseline bursts to 234/5min (26% of windows crossed the old - // bar of 20, set unmeasured at the monitor's 2026-07-28 birth); the 2026-08-23 - // incident ran ~2,200-3,000/5min. 300 clears healthy bursts with ~10x incident - // margin; relayPostgresRetryExhausted below stays at zero tolerance, so any - // transaction that terminally fails still freezes the gate. - relayPostgresRetries: 300, - relayPostgresRetryExhausted: 0, + // Recalibrated 2026-09-04 from 300, which was set 2026-08-26 when healthy bursts + // reached 234/5min. The global relay_cells FOR UPDATE lock has since become the + // fleet's steady state: measured fleet-wide (director + cells, summed per five + // minutes) 2026-09-03T05Z..2026-09-04T05Z p50 430 / p90 924 / p99 1320 / max + // 1504, with 55% of windows over 300 and only 22% of 15-minute gates clean, so + // the bar blocked the very cell roll that carries the 500 ms lock wait (#18521) + // and the beginProof crash guard to the cells. The 2026-08-23 lock incident on + // this same metric peaked at 1510 in one window and 646 in the next, so it is + // not separable from today's contention by retries alone; it is caught by + // relayPostgresRetryExhausted (467 at the peak vs a 300 bar), director + // concurrency, and the pool bars. 2000 passes every healthy 15-minute window + // measured in the last 24 h and still fences unbounded growth. Re-tighten once + // the fleet is on the 500 ms lock wait and the baseline is re-measured. + relayPostgresRetries: 2000, + // Why: 300 per five minutes, recalibrated 2026-09-04 from a bar of zero that no + // production window has cleared since #18521 shipped to the director. That + // change cut the request-path cell-inventory wait from the 1 s pool lock_timeout + // to 500 ms, so a contended waiter now fails fast (one /v1/assign 503 with + // Retry-After, which the client retries) instead of succeeding slowly, and the + // exhaustion count became a steady-state contention rate rather than an + // anomaly. Measured fleet-wide (director + cells) per five minutes over + // 2026-09-03T03Z..2026-09-04T02Z: every one of 236 windows was non-zero; + // quiet hours p50 2 / max 36; pre-#18521 daytime p50 10 / p90 25 / max 87; + // post-#18521 p50 42 / p90 147 / max 220. The 2026-08-23 lock incident peaked + // at 467. 300 clears every measured healthy window and still sits below the + // incident shape; retries above fence only unbounded growth. + // User-facing /v1/assign 503 share did not move with #18521 (13.9% old image + // vs 12.3% new, same evening), so exhaustion is not a proxy for user harm. + relayPostgresRetryExhausted: 300, // Why: public admission is a per-instance semaphore, so fleet assignment capacity is // concurrency x instances. A floor of 1 let the 2026-08-04 collapse from five instances // to two pass unnoticed, which is the exact failure this monitor exists to catch. Keep in diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 0b2b1ef72a9..d0517d46746 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -31,7 +31,12 @@ import { } from './assignment-connection-headroom-query.js' import { AssignmentIdentityQueue } from './assignment-identity-queue.js' import type { RelayCellConfig } from './config.js' -import type { RelayDatabase, RelayTransactionOptions, SqlRow } from './database.js' +import type { + RelayDatabase, + RelayLockOptions, + RelayTransactionOptions, + SqlRow +} from './database.js' import type { RegionalRehomeSafetySnapshot } from './relay-observability.js' import { combineRegionalRehomeSafety, @@ -316,6 +321,25 @@ const ACTIVITY_REQUEST_UNITS: Record = { } const ASSIGNMENT_LOCK_RETRY_DEADLINE_MS = 15_000 +// Why: one global FOR UPDATE over a 23-row table serialises every director and +// cell. At the 1s pool lock_timeout each blocked waiter also holds a pooled +// client for a full second, so the queue converts contention into pool +// exhaustion. The lock is held to COMMIT and the assignment path runs many +// statements after taking it, and no hold-time telemetry existed before this +// change, so 500ms is a first value to tune once cellInventoryHoldMsMax lands. +export const CELL_INVENTORY_LOCK_TIMEOUT_MS = 500 + +// The same inventory lock is taken by live requests and by background sweeps, +// and the right failure mode differs per caller. +export type CellInventoryLockMode = + // Bound the wait so a blocked request stops occupying a pooled client. + | 'request' + // Never queue: the caller handles database_lock_unavailable and moves on. + | 'nowait' + // A sweep can enter here, so keep the pool default. Failing sooner would turn + // ordinary contention into a 55P03 the retry wrapper reports as terminal, which + // spends the incident gate's bounded exhausted-retry budget (300 per 5 min). + | 'pool-default' // Why: stranded detection (issue #225) needs a grant old enough that a real // attach would have registered (the 90s activity lease covers dial + // activation), yet recent enough to prove an active retry loop rather than @@ -536,29 +560,35 @@ export class RelayAssignmentStore { async assign( identity: AssignmentIdentity, preferredRegion?: RelayRegion, - placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION + placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION, + // evacuateDeadCells re-enters placement from a sweep; it must not take the + // bounded wait, whose 55P03 would surface as a terminal sweep failure. + lockMode: CellInventoryLockMode = 'request' ): Promise { - const sticky = await this.assignStickyWithLockRetry(identity, preferredRegion) + const sticky = await this.assignStickyWithLockRetry(identity, lockMode, preferredRegion) if (sticky) return sticky // Only placement needs the global inventory critical section; queueing those // attempts locally avoids turning true placement bursts into NOWAIT storms. return await this.serializeAssignment( - async () => await this.assignWithLockRetry(identity, preferredRegion, placementRegion) + async () => + await this.assignWithLockRetry(identity, lockMode, preferredRegion, placementRegion) ) } private async assignStickyWithLockRetry( identity: AssignmentIdentity, + lockMode: CellInventoryLockMode, preferredRegion?: RelayRegion ): Promise { return await this.withAssignmentLockRetry( async (inventoryFirst) => - await this.assignStickyOnce(identity, inventoryFirst, preferredRegion) + await this.assignStickyOnce(identity, inventoryFirst, lockMode, preferredRegion) ) } private async assignWithLockRetry( identity: AssignmentIdentity, + lockMode: CellInventoryLockMode, preferredRegion?: RelayRegion, placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION ): Promise { @@ -566,7 +596,13 @@ export class RelayAssignmentStore { let inventoryScope: AssignmentInventoryScope = 'none' while (true) { try { - return await this.assignOnce(identity, inventoryScope, preferredRegion, placementRegion) + return await this.assignOnce( + identity, + inventoryScope, + lockMode, + preferredRegion, + placementRegion + ) } catch (error) { if (error instanceof AssignmentInventoryScopeChanged) { inventoryScope = 'all' @@ -604,12 +640,13 @@ export class RelayAssignmentStore { private async assignStickyOnce( identity: AssignmentIdentity, inventoryFirst: boolean, + lockMode: CellInventoryLockMode, preferredRegion?: RelayRegion ): Promise { const now = this.now() return await this.database.transaction(async (transaction) => { const lockedCells = inventoryFirst - ? await this.lockCellInventory(transaction) + ? await this.lockCellInventory(transaction, lockMode) : undefined const existing = await this.assignmentRow(transaction, identity, inventoryFirst) if (!existing) return null @@ -751,6 +788,7 @@ export class RelayAssignmentStore { private async assignOnce( identity: AssignmentIdentity, inventoryScope: AssignmentInventoryScope, + lockMode: CellInventoryLockMode, preferredRegion?: RelayRegion, placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION ): Promise { @@ -760,9 +798,9 @@ export class RelayAssignmentStore { return await this.database.transaction(async (transaction) => { let lockedCells = inventoryScope === 'all' - ? await this.lockCellInventory(transaction) + ? await this.lockCellInventory(transaction, lockMode) : inventoryScope === 'general' - ? await this.lockGeneralCellInventory(transaction) + ? await this.lockGeneralCellInventory(transaction, lockMode) : undefined const existing = await this.assignmentRow( transaction, @@ -779,7 +817,7 @@ export class RelayAssignmentStore { let connectionHeadroomReassignment = false let strandedReassignment = false if (existing && !mayNormallyReassign(activity(existing), now)) { - lockedCells ??= await this.lockCellInventory(transaction, true) + lockedCells ??= await this.lockCellInventory(transaction, 'nowait') const admission = await cellAdmissionStates(transaction) const currentRow = lockedCells.find( (row) => text(row, 'cell_id') === text(existing, 'cell_id') @@ -859,8 +897,8 @@ export class RelayAssignmentStore { } lockedCells ??= existing - ? await this.lockCellInventory(transaction, true) - : await this.lockGeneralCellInventory(transaction, true) + ? await this.lockCellInventory(transaction, 'nowait') + : await this.lockGeneralCellInventory(transaction, 'nowait') const target = await this.leastLoadedCell( transaction, lockedCells, @@ -2114,7 +2152,7 @@ export class RelayAssignmentStore { ORDER BY migration.user_id, migration.relay_host_id`, [input.cellId] ) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'request') for (const migrationRow of migrations) { const identity = { userId: text(migrationRow, 'user_id'), @@ -2608,10 +2646,12 @@ export class RelayAssignmentStore { let moved = 0 for (const row of rows) { try { - const assignment = await this.assign({ - userId: text(row, 'user_id'), - relayHostId: text(row, 'relay_host_id') - }) + const assignment = await this.assign( + { userId: text(row, 'user_id'), relayHostId: text(row, 'relay_host_id') }, + undefined, + undefined, + 'pool-default' + ) if (assignment.cellId !== text(row, 'cell_id')) moved++ } catch (error) { if (!(error instanceof Error && error.message === 'relay_capacity_exhausted')) throw error @@ -3162,7 +3202,7 @@ export class RelayAssignmentStore { ) const requestDelta = ACTIVITY_REQUEST_UNITS[kind] * (after - before) if (requestDelta !== 0) { - await this.lockCellInventory(transaction) + await this.lockCellInventory(transaction, 'request') await this.adjustCellReservation(transaction, text(row, 'cell_id'), requestDelta) } }) @@ -3223,7 +3263,7 @@ export class RelayAssignmentStore { } const units = ACTIVITY_REQUEST_UNITS[input.kind] if (existing) { - await this.lockCellInventory(transaction) + await this.lockCellInventory(transaction, 'request') await this.removeActivityLease(transaction, identity, existing, now) await this.adjustCellReservation(transaction, input.cellId, units) } @@ -3540,7 +3580,7 @@ export class RelayAssignmentStore { ) await this.touchAssignment(transaction, identity, expiresAt, now) } else { - await this.lockCellInventory(transaction) + await this.lockCellInventory(transaction, 'request') await this.adjustCellReservation(transaction, input.cellId, 1) await this.adjustActivityCount(transaction, identity, 'control', 1, expiresAt, now) await transaction.query( @@ -3614,7 +3654,7 @@ export class RelayAssignmentStore { } if (sourceCellId === targetCellId) throw new Error('target_matches_source') await this.lockAssignmentActivities(transaction, identity) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'request') const target = cells.find((row) => text(row, 'cell_id') === targetCellId) if (!target || integer(target, 'enabled') !== 1) throw new Error('target_cell_unavailable') if (!(await this.cellIsLive(transaction, targetCellId, now))) { @@ -3822,7 +3862,7 @@ export class RelayAssignmentStore { let lockedCells: SqlRow[] | undefined if (inventoryFirst) { try { - lockedCells = await this.lockCellInventory(transaction) + lockedCells = await this.lockCellInventory(transaction, 'request') } catch (error) { if (isDatabaseLockTimeout(error)) { throw new Error('database_lock_unavailable') @@ -3863,7 +3903,7 @@ export class RelayAssignmentStore { if (activityUnitsForCell(activityLeases, input.sourceCellId) > 0) { throw new Error('migration_source_still_active') } - const cells = lockedCells ?? (await this.lockCellInventory(transaction, true)) + const cells = lockedCells ?? (await this.lockCellInventory(transaction, 'nowait')) const source = cells.find((cell) => text(cell, 'cell_id') === input.sourceCellId) const target = cells.find((cell) => text(cell, 'cell_id') === input.targetCellId) if (!source || integer(source, 'enabled') !== 0) { @@ -3967,7 +4007,7 @@ export class RelayAssignmentStore { const now = this.now() return await this.database.transaction(async (transaction) => { const lockedCells = inventoryFirst - ? await this.lockCellInventory(transaction) + ? await this.lockCellInventory(transaction, 'request') : undefined const assignment = await this.assignmentRow(transaction, identity, inventoryFirst) const existing = ( @@ -4041,7 +4081,7 @@ export class RelayAssignmentStore { ) { throw new Error('migration_activity_topology_mismatch') } - const cells = lockedCells ?? (await this.lockCellInventory(transaction, true)) + const cells = lockedCells ?? (await this.lockCellInventory(transaction, 'nowait')) const source = cells.find((cell) => text(cell, 'cell_id') === input.sourceCellId) const currentTarget = cells.find( (cell) => text(cell, 'cell_id') === input.currentTargetCellId @@ -4458,7 +4498,7 @@ export class RelayAssignmentStore { throw new Error('migration_activity_topology_mismatch') } } - if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction) + if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'request') for (const lease of obsoleteLeases) { await this.removeActivityLease(transaction, identity, lease, now) } @@ -4634,7 +4674,7 @@ export class RelayAssignmentStore { ) { throw new Error('migration_activity_lease_shape_mismatch') } - await this.lockCellInventory(transaction) + await this.lockCellInventory(transaction, 'request') await this.adjustCellReservation( transaction, input.currentTargetCellId, @@ -4723,7 +4763,7 @@ export class RelayAssignmentStore { if (this.requireLiveCells) { let cells: SqlRow[] try { - cells = await this.lockCellInventory(transaction, true) + cells = await this.lockCellInventory(transaction, 'nowait') } catch (error) { if (isDatabaseLockUnavailable(error)) { // Mixed-version workers may still hold a cell-first lock; defer @@ -4779,7 +4819,7 @@ export class RelayAssignmentStore { ) if (!targetIsActive) throw new Error('migration_target_not_active') const lease = activityLeaseById(activityLeases, migrationActivityId(assignmentEpoch)) - if (lease && !cellsLocked) await this.lockCellInventory(transaction) + if (lease && !cellsLocked) await this.lockCellInventory(transaction, 'pool-default') if (lease) await this.removeActivityLease(transaction, identity, lease, now) await transaction.query( `UPDATE relay_assignment_migrations SET completed_at = ?, updated_at = ? @@ -4801,7 +4841,7 @@ export class RelayAssignmentStore { const sourceCellId = text(assignment, 'cell_id') if (sourceCellId === targetCellId) throw new Error('target_matches_source') await this.lockAssignmentActivities(transaction, identity) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'request') const admission = await cellAdmissionStates(transaction) const targetRow = cells.find( (row) => @@ -5051,7 +5091,13 @@ export class RelayAssignmentStore { } this.pendingRegionalRehomeDisableLog = null const candidateSkips: RegionalRehomeCandidateSkip[] = [] + // A Postgres transaction is unusable after a NOWAIT abort, so a contended + // tick abandons the candidate it stopped on plus every one behind it. + let candidatesTotal = 0 + let candidatesFinished = 0 const claimResult = await this.database.transaction(async (transaction) => { + candidatesTotal = 0 + candidatesFinished = 0 candidateSkips.length = 0 await this.initializeRegionalRehomeControl(transaction, now) const control = ( @@ -5122,6 +5168,7 @@ export class RelayAssignmentStore { ) )[0] if (retry) { + candidatesTotal = 1 const fleetSafety = await this.lockedRegionalRehomeFleetSafety(transaction, now) if ( !(await this.regionalRehomeSafetyAllowsClaim( @@ -5190,6 +5237,7 @@ export class RelayAssignmentStore { ) )[0] if (redrain) { + candidatesTotal = 1 const fleetSafety = await this.lockedRegionalRehomeFleetSafety(transaction, now) if ( !(await this.regionalRehomeSafetyAllowsClaim( @@ -5253,6 +5301,7 @@ export class RelayAssignmentStore { LIMIT 10`, [preferenceCutoff, now - this.heartbeatTtlMs, now] ) + candidatesTotal = candidates.length for (const candidate of candidates) { const claimed = await this.startRegionalRehomeCandidate(transaction, { identity: { @@ -5268,6 +5317,7 @@ export class RelayAssignmentStore { now, skips: candidateSkips }) + candidatesFinished++ if (!claimed) continue await this.markRegionalRehomeDispatchClaimed( transaction, @@ -5283,6 +5333,21 @@ export class RelayAssignmentStore { await this.markRegionalRehomeTickSkipped(transaction, now, intervalMs) } return null + }).catch((error: unknown): RegionalRehomeAttempt | null => { + // Only inventory contention is swallowed here; every other failure keeps + // its existing propagation and its dispatch-failure accounting. + if (!isDatabaseLockUnavailable(error)) throw error + // The dispatch tick runs every second; losing one to inventory contention + // costs a second of latency and never loses durable rehome state. The + // rolled-back transaction never disabled anything, so its pending disable + // log would describe a decision that did not happen. + candidateSkips.length = 0 + this.pendingRegionalRehomeDisableLog = null + warnSweepCellInventoryBusy( + 'claim-regional-rehome', + Math.max(1, candidatesTotal - candidatesFinished) + ) + return null }) const pendingDisableLog = this.pendingRegionalRehomeDisableLog this.pendingRegionalRehomeDisableLog = null @@ -5343,7 +5408,7 @@ export class RelayAssignmentStore { } const activityLeases = await this.lockAssignmentActivities(transaction, input.identity) assertAssignmentActivityCounts(assignment, activityLeases, 0) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'nowait') const admission = await cellAdmissionStates(transaction) const regions = new Map( (await transaction.query(`SELECT cell_id, region FROM relay_cell_regions`)).map((row) => [ @@ -5630,7 +5695,7 @@ export class RelayAssignmentStore { transaction: RelayDatabase, now: number ): Promise { - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'nowait') const admission = await cellAdmissionStates(transaction) const regions = new Map( (await transaction.query(`SELECT cell_id, region FROM relay_cell_regions`)).map((row) => [ @@ -5874,6 +5939,7 @@ export class RelayAssignmentStore { [...quarantined, limit] ) let completed = 0 + let inventoryBusy = 0 for (const candidate of candidates) { // One poisoned row must not stall every later candidate: an invariant // throw here blocked fleet completions head-of-line in production. @@ -5890,9 +5956,14 @@ export class RelayAssignmentStore { if (changed) completed++ this.regionalRehomeCandidateQuarantine.delete(attemptId) } catch (error) { + if (isDatabaseLockUnavailable(error)) { + inventoryBusy++ + continue + } this.recordRegionalRehomeCandidateFailure('complete', attemptId, now, error) } } + warnSweepCellInventoryBusy('complete-ready-regional-rehomes', inventoryBusy) return completed } @@ -6112,7 +6183,7 @@ export class RelayAssignmentStore { leases, migration ) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'nowait') const target = cells.find((cell) => text(cell, 'cell_id') === targetCellId) const admission = await cellAdmissionStates(transaction) if ( @@ -6261,6 +6332,7 @@ export class RelayAssignmentStore { [now - REGIONAL_REHOME_MAX_REFRESH_MS, ...quarantined, limit] ) let aborted = 0 + let inventoryBusy = 0 for (const candidate of candidates) { const identity = { userId: text(candidate, 'user_id'), @@ -6318,7 +6390,7 @@ export class RelayAssignmentStore { integer(lease, 'expires_at') > now ) if (targetActive) return false - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'nowait') const source = cells.find((cell) => text(cell, 'cell_id') === sourceCellId) const admission = await cellAdmissionStates(transaction) if ( @@ -6376,10 +6448,12 @@ export class RelayAssignmentStore { }) this.regionalRehomeCandidateQuarantine.delete(attemptId) } catch (error) { - this.recordRegionalRehomeCandidateFailure('abort', attemptId, now, error) + if (isDatabaseLockUnavailable(error)) inventoryBusy++ + else this.recordRegionalRehomeCandidateFailure('abort', attemptId, now, error) } if (changed) aborted++ } + warnSweepCellInventoryBusy('abort-expired-regional-rehomes', inventoryBusy) return aborted } @@ -6396,6 +6470,7 @@ export class RelayAssignmentStore { [now, now, abandonedBefore, abandonedBefore] ) let aborted = 0 + let inventoryBusy = 0 for (const candidate of candidates) { const didAbort = await this.database.transaction(async (transaction) => { const identity = { @@ -6480,7 +6555,7 @@ export class RelayAssignmentStore { ] .map((activityId) => activityLeaseById(activityLeases, activityId)) .filter((lease): lease is SqlRow => lease !== undefined) - if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction) + if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'nowait') for (const lease of obsoleteLeases) { await this.removeActivityLease(transaction, identity, lease, now) } @@ -6498,7 +6573,7 @@ export class RelayAssignmentStore { ) return true } - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'nowait') const sourceCellId = text(row, 'source_cell_id') const admissionRows = await transaction.query( `SELECT cell_id, admission_state, updated_at FROM relay_cell_admission @@ -6595,9 +6670,15 @@ export class RelayAssignmentStore { [now, now, identity.userId, identity.relayHostId, assignmentEpoch] ) return true + }).catch((error: unknown): boolean => { + // Expiry is durable; another director settling this row is not a failure. + if (!isDatabaseLockUnavailable(error)) throw error + inventoryBusy++ + return false }) if (didAbort) aborted++ } + warnSweepCellInventoryBusy('abort-expired-evacuations', inventoryBusy) return aborted } @@ -6665,7 +6746,7 @@ export class RelayAssignmentStore { const activityLeases = await this.lockAssignmentActivities(transaction, identity, true) const lease = activityLeaseById(activityLeases, text(candidate, 'activity_id')) if (!lease || integer(lease, 'expires_at') > now) return false - await this.lockCellInventory(transaction, true) + await this.lockCellInventory(transaction, 'nowait') await this.removeActivityLease(transaction, identity, lease, now) return true }) @@ -6709,7 +6790,7 @@ export class RelayAssignmentStore { [now], { failIfUnavailable: true } ) - if (expired.length > 0) await this.lockCellInventory(transaction, true) + if (expired.length > 0) await this.lockCellInventory(transaction, 'nowait') for (const row of expired) { await this.adjustCellReservation(transaction, text(row, 'cell_id'), -requestUnits(row)) await transaction.query( @@ -6782,7 +6863,7 @@ export class RelayAssignmentStore { targetCellId ] ) - const cells = await this.lockCellInventory(transaction) + const cells = await this.lockCellInventory(transaction, 'pool-default') const assignmentKeys = new Set( assignments.map((row) => assignmentKey(text(row, 'user_id'), text(row, 'relay_host_id')) @@ -6861,30 +6942,32 @@ export class RelayAssignmentStore { private async lockCellInventory( database: RelayDatabase, - failIfUnavailable = false + mode: CellInventoryLockMode ): Promise { // Every capacity-changing assignment takes the tiny cell inventory in one // order; dynamically locking only the selected target allowed cross-cell cycles. - return await database.queryLocked( + const rows = await database.queryLocked( `SELECT * FROM relay_cells ORDER BY cell_id ASC`, [], - { failIfUnavailable } + cellInventoryLockOptions(mode) ) + return rows } private async lockGeneralCellInventory( database: RelayDatabase, - failIfUnavailable = false + mode: CellInventoryLockMode ): Promise { - return await database.queryLocked( + const rows = await database.queryLocked( `SELECT * FROM relay_cells WHERE cell_id IN ( SELECT cell_id FROM relay_cell_admission WHERE admission_state = 'general' ) ORDER BY cell_id ASC`, [], - { failIfUnavailable } + cellInventoryLockOptions(mode) ) + return rows } private async leastLoadedCell( @@ -6892,7 +6975,7 @@ export class RelayAssignmentStore { lockedCells: SqlRow[] | undefined, preferredRegion: RelayRegion ): Promise { - const rows = lockedCells ?? (await this.lockCellInventory(database)) + const rows = lockedCells ?? (await this.lockCellInventory(database, 'pool-default')) const regions = new Map( (await database.query(`SELECT cell_id, region FROM relay_cell_regions`)).map((row) => [ text(row, 'cell_id'), @@ -7507,7 +7590,7 @@ export class RelayAssignmentStore { ) { throw new Error('activity_lease_shape_mismatch') } - const cells = await this.lockCellInventory(database) + const cells = await this.lockCellInventory(database, 'request') await database.query( `DELETE FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? AND activity_kind = 'control' @@ -7886,6 +7969,23 @@ function isDatabaseLockUnavailable(error: unknown): boolean { return error instanceof Error && error.message === 'database_lock_unavailable' } +export function cellInventoryLockOptions(mode: CellInventoryLockMode): RelayLockOptions { + if (mode === 'nowait') return { failIfUnavailable: true, measureHoldMs: true } + if (mode === 'pool-default') return { measureHoldMs: true } + return { lockTimeoutMs: CELL_INVENTORY_LOCK_TIMEOUT_MS, measureHoldMs: true } +} + +// Background sweeps take the cell inventory NOWAIT so they never queue ahead of +// assignment traffic. A skipped candidate is re-derived from durable state on +// the next tick, so it is ordinary contention, not a sweep failure: one summary +// line per tick, never an error and never a quarantine. +function warnSweepCellInventoryBusy(sweep: string, skipped: number): void { + if (skipped === 0) return + console.warn( + JSON.stringify({ event: 'orca_relay_sweep_cell_inventory_busy', sweep, skipped }) + ) +} + function isDatabaseLockTimeout(error: unknown): boolean { return String((error as { code?: unknown }).code) === '55P03' } diff --git a/cloud/apps/relay/src/cell-inventory-hold-samples.test.ts b/cloud/apps/relay/src/cell-inventory-hold-samples.test.ts new file mode 100644 index 00000000000..abd37dcbb29 --- /dev/null +++ b/cloud/apps/relay/src/cell-inventory-hold-samples.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + CellInventoryHoldSamples, + emptyCellInventoryHoldCounts +} from './cell-inventory-hold-samples.js' + +// Nearest rank, computed in integer arithmetic so it cannot inherit the float +// error the implementation's `0.95 * n` could in principle carry. +function nearestRankP95(sorted: number[]): number { + return sorted[Math.ceil((95 * sorted.length) / 100) - 1]! +} + +function samplesOf(values: number[]): CellInventoryHoldSamples { + const samples = new CellInventoryHoldSamples() + for (const value of values) samples.record(value) + return samples +} + +describe('cell inventory hold samples', () => { + it('reports nothing before the first hold', () => { + expect(new CellInventoryHoldSamples().readCounts()).toEqual( + emptyCellInventoryHoldCounts() + ) + }) + + // Why: the 500ms bound will be tuned against this percentile, so an off-by-one + // here reads as a hold the fleet never had. + it('places p95 at the nearest rank for every window size', () => { + for (let size = 1; size <= 400; size++) { + const values = Array.from({ length: size }, (_, index) => index + 1) + const shuffled = [...values].reverse() + + const counts = samplesOf(shuffled).readCounts() + + expect(counts.cellInventoryHoldMsP95).toBe(nearestRankP95(values)) + expect(counts.cellInventoryHoldMsMax).toBe(size) + expect(counts.cellInventoryHolds).toBe(size) + } + }) + + it('never reports a p95 above the max', () => { + for (let size = 1; size <= 200; size++) { + const counts = samplesOf(Array.from({ length: size }, (_, i) => i + 1)).readCounts() + + expect(counts.cellInventoryHoldMsP95).toBeLessThanOrEqual(counts.cellInventoryHoldMsMax) + } + }) + + it('ignores a hold that is not a finite, non-negative duration', () => { + const samples = samplesOf([Number.NaN, Number.POSITIVE_INFINITY, -1]) + + expect(samples.readCounts()).toEqual(emptyCellInventoryHoldCounts()) + }) + + // Why: the reservoir is bounded, so a heavy flush interval keeps the most + // recent holds rather than growing without limit or freezing on the oldest. + it('keeps the most recent holds once the reservoir is full', () => { + const counts = samplesOf(Array.from({ length: 2_100 }, (_, index) => index + 1)).readCounts() + + expect(counts.cellInventoryHolds).toBe(2_048) + expect(counts.cellInventoryHoldMsMax).toBe(2_100) + }) + + it('resets the window on consume so each flush reports its own holds', () => { + const samples = samplesOf([5, 10]) + + expect(samples.consumeCounts().cellInventoryHolds).toBe(2) + expect(samples.consumeCounts()).toEqual(emptyCellInventoryHoldCounts()) + }) +}) diff --git a/cloud/apps/relay/src/cell-inventory-hold-samples.ts b/cloud/apps/relay/src/cell-inventory-hold-samples.ts new file mode 100644 index 00000000000..14941032d80 --- /dev/null +++ b/cloud/apps/relay/src/cell-inventory-hold-samples.ts @@ -0,0 +1,46 @@ +// Why: the cell inventory lock is held to COMMIT, and the assignment path runs +// many statements after taking it. Tuning the request-path wait bound needs the +// hold distribution, and no runtime metric carried it before this change. +export type CellInventoryHoldCounts = { + cellInventoryHoldMsMax: number + cellInventoryHoldMsP95: number + cellInventoryHolds: number +} + +// Bounded so a flush interval with heavy assignment traffic cannot grow the array +// without limit; the reservoir keeps the most recent holds. +const MAX_SAMPLES = 2_048 + +export function emptyCellInventoryHoldCounts(): CellInventoryHoldCounts { + return { cellInventoryHoldMsMax: 0, cellInventoryHoldMsP95: 0, cellInventoryHolds: 0 } +} + +export class CellInventoryHoldSamples { + private samples: number[] = [] + + record(holdMs: number): void { + if (!Number.isFinite(holdMs) || holdMs < 0) return + if (this.samples.length === MAX_SAMPLES) this.samples.shift() + this.samples.push(holdMs) + } + + consumeCounts(): CellInventoryHoldCounts { + const counts = this.readCounts() + this.samples = [] + return counts + } + + readCounts(): CellInventoryHoldCounts { + if (this.samples.length === 0) return emptyCellInventoryHoldCounts() + const sorted = [...this.samples].sort((left, right) => left - right) + return { + cellInventoryHoldMsMax: round(sorted[sorted.length - 1]!), + cellInventoryHoldMsP95: round(sorted[Math.ceil(0.95 * sorted.length) - 1] ?? 0), + cellInventoryHolds: sorted.length + } + } +} + +function round(value: number): number { + return Number(value.toFixed(3)) +} diff --git a/cloud/apps/relay/src/cell-inventory-lock-census.test.ts b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts new file mode 100644 index 00000000000..8ca7cee55f5 --- /dev/null +++ b/cloud/apps/relay/src/cell-inventory-lock-census.test.ts @@ -0,0 +1,205 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { cellInventoryLockOptions, type CellInventoryLockMode } from './assignment-store.js' + +// Which entry points can reach a call site. A site a sweep can enter must never +// take the bounded wait: its 55P03 becomes a terminal transaction failure, and +// the incident monitor freezes on a single one. +type Reachability = 'request' | 'sweep' | 'both' | 'orphan' + +// 'caller' is not a CellInventoryLockMode: those sites take the mode threaded +// from `assign`, which is 'request' for a client and 'pool-default' for the +// evacuateDeadCells sweep. +type CensusMode = CellInventoryLockMode | 'caller' + +type CensusEntry = { method: string; mode: CensusMode; reach: Reachability } + +// Every lockCellInventory / lockGeneralCellInventory call site in +// assignment-store.ts, in source order. A new site fails this test until it is +// classified here, which is the point. +const CENSUS: CensusEntry[] = [ + { method: 'assignStickyOnce', mode: 'caller', reach: 'both' }, + { method: 'assignOnce', mode: 'caller', reach: 'both' }, + { method: 'assignOnce', mode: 'caller', reach: 'both' }, + { method: 'assignOnce', mode: 'nowait', reach: 'both' }, + { method: 'assignOnce', mode: 'nowait', reach: 'both' }, + { method: 'assignOnce', mode: 'nowait', reach: 'both' }, + { method: 'refreshDrainMigrationLeasesOnce', mode: 'request', reach: 'request' }, + // Reachable from neither: changeActivity has no production callers, only tests. + { method: 'changeActivity', mode: 'request', reach: 'orphan' }, + { method: 'acquireActivity', mode: 'request', reach: 'request' }, + { method: 'activateControl', mode: 'request', reach: 'request' }, + { method: 'startEvacuation', mode: 'request', reach: 'request' }, + { method: 'completeEvacuationFromDeadSourceOnce', mode: 'request', reach: 'request' }, + { method: 'completeEvacuationFromDeadSourceOnce', mode: 'nowait', reach: 'request' }, + { method: 'supersedeRegisteredEvacuationOnce', mode: 'request', reach: 'request' }, + { method: 'supersedeRegisteredEvacuationOnce', mode: 'nowait', reach: 'request' }, + { method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' }, + { method: 'prepareRegisteredCellSupersession', mode: 'request', reach: 'request' }, + { method: 'completeEvacuation', mode: 'nowait', reach: 'both' }, + { method: 'completeEvacuation', mode: 'pool-default', reach: 'both' }, + { method: 'rebalanceDormant', mode: 'request', reach: 'request' }, + { method: 'startRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, + { method: 'lockedRegionalRehomeFleetSafety', mode: 'nowait', reach: 'sweep' }, + { method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' }, + { method: 'abortExpiredRegionalRehomes', mode: 'nowait', reach: 'sweep' }, + { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, + { method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' }, + { method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' }, + { method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' }, + { method: 'reconcileReservationAccounting', mode: 'pool-default', reach: 'both' }, + { method: 'leastLoadedCell', mode: 'pool-default', reach: 'both' }, + { method: 'removeSupersededSameCellControls', mode: 'request', reach: 'request' } +] + +// The background sweeps, and nothing else. A method reachable from one of these +// can be entered by a sweep tick, whatever else can also enter it. Both lists are +// read from source, so a new sweep step or a new route widens the derivation here +// instead of silently widening what a bounded wait can be entered from. +const SWEEP_ENTRY_FILES = ['./assignment-cleanup-steps.ts', './regional-rehome-worker.ts'] +const REQUEST_ENTRY_FILES = [ + './app.ts', + './relay-server.ts', + './host-session-registry.ts', + './cell-admission-startup.ts' +] + +const DECLARATION = /^ {2}(?:private |public )?(?:static )?(?:async )?([A-Za-z_][\w]*)[(<]/ + +function storeSource(): string[] { + return readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8').split('\n') +} + +function entryPoints(files: string[]): string[] { + return files.flatMap((file) => + [ + ...readFileSync(new URL(file, import.meta.url), 'utf8').matchAll( + /assignments\.([A-Za-z_][\w]*)\(/g + ) + ].map((call) => call[1]!) + ) +} + +// Same-class call graph: store methods only ever reach each other through `this.`. +function storeCallGraph(lines: string[]): Map> { + const bounds: { name: string; start: number }[] = [] + lines.forEach((line, index) => { + const declaration = DECLARATION.exec(line) + if (declaration) bounds.push({ name: declaration[1]!, start: index }) + }) + const callees = new Map>() + bounds.forEach((method, index) => { + const end = bounds[index + 1]?.start ?? lines.length + const names = callees.get(method.name) ?? new Set() + for (const call of lines.slice(method.start, end).join('\n').matchAll( + /this\.([A-Za-z_][\w]*)\s*\(/g + )) { + names.add(call[1]!) + } + callees.set(method.name, names) + }) + return callees +} + +function closure(callees: Map>, roots: string[]): Set { + const reached = new Set() + const pending = [...roots] + while (pending.length > 0) { + const name = pending.pop()! + if (reached.has(name)) continue + reached.add(name) + for (const callee of callees.get(name) ?? []) if (!reached.has(callee)) pending.push(callee) + } + return reached +} + +// Why: a hand-written reachability column is a claim, not a check. Derive both +// directions, so a new sweep edge into a bounded site fails here instead of in +// production, and so 'sweep' and 'both' stop being asserted by hand. +function derivedReachability(lines: string[]): (method: string) => Reachability { + const callees = storeCallGraph(lines) + const sweep = closure(callees, entryPoints(SWEEP_ENTRY_FILES)) + const request = closure(callees, entryPoints(REQUEST_ENTRY_FILES)) + return (method) => + sweep.has(method) + ? request.has(method) + ? 'both' + : 'sweep' + : request.has(method) + ? 'request' + : 'orphan' +} + +function readCallSites(): { method: string; mode: CensusMode }[] { + const sites: { method: string; mode: CensusMode }[] = [] + let method = '' + for (const line of storeSource()) { + const declaration = DECLARATION.exec(line) + if (declaration) method = declaration[1]! + if (/private async lock(General)?CellInventory\(/.test(line)) continue + const call = /lock(?:General)?CellInventory\(\s*\w+\s*,\s*(?:'([a-z-]+)'|(\w+))\s*\)/.exec(line) + if (!call) continue + sites.push({ method, mode: (call[1] ?? 'caller') as CensusMode }) + } + return sites +} + +describe('cell inventory lock call-site census', () => { + it('classifies every call site exactly as recorded', () => { + expect(readCallSites()).toEqual( + CENSUS.map(({ method, mode }) => ({ method, mode })) + ) + }) + + it('leaves no call site taking the inventory without naming a mode', () => { + const source = readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8') + const unclassified = source + .split('\n') + .filter((line) => /lock(?:General)?CellInventory\(\s*\w+\s*\)/.test(line)) + .filter((line) => !line.includes('private async')) + + expect(unclassified).toEqual([]) + }) + + it('derives the same reachability the census claims', () => { + const reachOf = derivedReachability(storeSource()) + + expect(readCallSites().map(({ method }) => reachOf(method))).toEqual( + CENSUS.map((entry) => entry.reach) + ) + }) + + // Why: this is the whole point of the classification. A shorter wait on a + // sweep-reachable site turns contention into a terminal transaction failure + // that counts against the incident gate's relayPostgresRetryExhausted bar. + // Why: the hold distribution is what the 500ms bound will be tuned against, so + // a mode that stops asking for it goes unmeasured in exactly the lane that + // matters. Nothing else in the suite reads the pool-default branch. + it('measures the hold in every lock mode', () => { + const modes: CellInventoryLockMode[] = ['request', 'nowait', 'pool-default'] + + expect(modes.map((mode) => cellInventoryLockOptions(mode).measureHoldMs)).toEqual([ + true, + true, + true + ]) + }) + + it('never puts a sweep-reachable site on the bounded wait', () => { + const reachOf = derivedReachability(storeSource()) + const bounded = readCallSites().filter( + (site) => site.mode === 'request' && ['sweep', 'both'].includes(reachOf(site.method)) + ) + + expect(bounded).toEqual([]) + }) + + it('routes every sweep-only site to NOWAIT so it can skip the tick', () => { + const reachOf = derivedReachability(storeSource()) + const queueing = readCallSites().filter( + (site) => reachOf(site.method) === 'sweep' && site.mode !== 'nowait' + ) + + expect(queueing).toEqual([]) + }) +}) diff --git a/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts new file mode 100644 index 00000000000..23ec001c573 --- /dev/null +++ b/cloud/apps/relay/src/cell-inventory-lock-contention.test.ts @@ -0,0 +1,541 @@ +import { readFileSync } from 'node:fs' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const fakes = vi.hoisted(() => ({ + statements: [] as string[], + query: vi.fn(async (sql: string) => { + fakes.statements.push(sql) + return { rows: [], rowCount: 0 } + }), + release: vi.fn(), + end: vi.fn(async () => undefined) +})) + +vi.mock('pg', () => ({ + default: { + Pool: class { + totalCount = 1 + idleCount = 1 + waitingCount = 0 + end = fakes.end + on = vi.fn() + connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release })) + } + } +})) + +const { CELL_INVENTORY_LOCK_TIMEOUT_MS, RelayAssignmentStore } = await import( + './assignment-store.js' +) +const { consumeRelayCellInventoryHold, openInMemoryRelayDatabase, openRelayDatabase, POSTGRES_LOCK_TIMEOUT_MS } = + await import('./database.js') +const RESTORE = `SET LOCAL lock_timeout = '${POSTGRES_LOCK_TIMEOUT_MS}ms'` +type RelayDatabase = import('./database.js').RelayDatabase +type RelayLockOptions = import('./database.js').RelayLockOptions +type RelayTransactionOptions = import('./database.js').RelayTransactionOptions +type SqlRow = import('./database.js').SqlRow + +const CELL_INVENTORY_SQL = 'SELECT * FROM relay_cells ORDER BY cell_id ASC' + +// The assignment path locks the general-admission subset; both forms are the +// same ordered scan of the same 23-row table and share its lock queue. +function locksCellInventory(sql: string): boolean { + return sql.trim().startsWith('SELECT * FROM relay_cells') && sql.includes('ORDER BY cell_id ASC') +} +const CELLS = [ + { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }, + { id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 } +] +const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + +async function openFakePostgres(): Promise { + const database = await openRelayDatabase({ + databaseUrl: 'postgresql://relay:secret@127.0.0.1:5432/relay', + dataDir: './unused' + }) + fakes.statements.length = 0 + return database +} + +afterEach(() => { + fakes.statements.length = 0 + fakes.query.mockReset() + fakes.query.mockImplementation(async (sql: string) => { + fakes.statements.push(sql) + return { rows: [], rowCount: 0 } + }) +}) + +describe('bounded cell-inventory lock wait', () => { + // Why: a bound at or above the pool default would fence nothing, and one far + // below the hold time would convert ordinary contention into terminal failures. + it('keeps the request bound strictly inside the pool default', () => { + expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBe(500) + expect(CELL_INVENTORY_LOCK_TIMEOUT_MS).toBeLessThan(POSTGRES_LOCK_TIMEOUT_MS) + }) + + // Why: SET LOCAL lasts to COMMIT. Left in place it would govern every later + // locked statement in the transaction and misattribute their 55P03s. + it('restores the pool default before the next statement in the transaction', async () => { + const database = await openFakePostgres() + + await database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs: 150 }) + await transaction.queryLocked('SELECT * FROM relay_assignments', []) + }) + + expect(fakes.statements).toEqual([ + 'BEGIN', + "SET LOCAL lock_timeout = '150ms'", + `${CELL_INVENTORY_SQL} FOR UPDATE`, + RESTORE, + 'SELECT * FROM relay_assignments FOR UPDATE', + 'COMMIT' + ]) + await database.close() + }) + + it('restores the pool default when the bounded lock itself times out', async () => { + const database = await openFakePostgres() + fakes.query.mockImplementation(async (sql: string) => { + fakes.statements.push(sql) + if (sql.includes('FOR UPDATE')) { + throw Object.assign(new Error('lock timeout'), { code: '55P03' }) + } + return { rows: [], rowCount: 0 } + }) + + await expect( + database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs: 150 }) + }) + ).rejects.toMatchObject({ code: '55P03' }) + + // The retry wrapper makes three attempts; each one must leave the default back. + expect(fakes.statements.filter((sql) => sql.startsWith('SET LOCAL'))).toEqual( + Array.from({ length: 3 }, () => ["SET LOCAL lock_timeout = '150ms'", RESTORE]).flat() + ) + await database.close() + }) + + it('rejects a lock bound that is not a positive whole number of milliseconds', async () => { + const database = await openFakePostgres() + + for (const lockTimeoutMs of [0, -1, 1.5, Number.NaN]) { + await expect( + database.transaction( + async (transaction) => + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs }) + ) + ).rejects.toThrow('invalid_lock_timeout') + } + await database.close() + }) + + it('skips the timeout for a NOWAIT lock, which never queues', async () => { + const database = await openFakePostgres() + + await database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { + failIfUnavailable: true, + lockTimeoutMs: 150 + }) + }) + + expect(fakes.statements.filter((sql) => sql.startsWith('SET LOCAL'))).toEqual([]) + await database.close() + }) + + it('skips the timeout outside a transaction, where SET LOCAL cannot survive', async () => { + const database = await openFakePostgres() + + await database.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs: 150 }) + + expect(fakes.statements).toEqual([`${CELL_INVENTORY_SQL} FOR UPDATE`]) + await database.close() + }) + + it('ignores the timeout on SQLite, which has no SET LOCAL', async () => { + const database = await openInMemoryRelayDatabase() + + const rows = await database.transaction( + async (transaction) => + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs: 150 }) + ) + + expect(rows).toEqual([]) + await database.close() + }) + + // Why: testing the helper alone would pass with the store still queueing for + // the pool's one-second default. + // Why: testing the helper alone would pass with the request path still queueing + // for the pool's full second. + it('never lets a request path take the unbounded wait', async () => { + const database = await openInMemoryRelayDatabase() + const probe = new InventoryLockProbe(database) + const store = new RelayAssignmentStore(probe, () => 1_000) + await store.reconcileCells(CELLS) + probe.inventoryLocks.length = 0 + + // Assignment takes the general-admission subset; evacuation takes them all. + await store.assign(identity) + const generalLocks = probe.inventoryLocks.length + await store.startEvacuation(identity, 'cell-b') + + expect(generalLocks).toBeGreaterThan(0) + expect(probe.inventoryLocks.length).toBeGreaterThan(generalLocks) + for (const options of probe.inventoryLocks) { + const bounded = options?.lockTimeoutMs === CELL_INVENTORY_LOCK_TIMEOUT_MS + expect(bounded || options?.failIfUnavailable === true).toBe(true) + } + await database.close() + }) + + // Why: evacuateDeadCells re-enters placement from a sweep. A 55P03 there would + // be reported as a terminal sweep failure and freeze the incident gate. + it('keeps the pool default when a sweep re-enters placement', async () => { + const requestModes = await recordAssignInventoryModes(async (store) => { + await store.assign(identity) + }) + const sweepModes = await recordAssignInventoryModes(async (store) => { + await store.assign(identity, undefined, undefined, 'pool-default') + }) + + // The inventory-first retry is the lane that carries the caller's mode. + expect(requestModes).toContain(CELL_INVENTORY_LOCK_TIMEOUT_MS) + expect(sweepModes).not.toContain(CELL_INVENTORY_LOCK_TIMEOUT_MS) + expect(sweepModes.filter((mode) => mode === 'nowait').length).toBe( + requestModes.filter((mode) => mode === 'nowait').length + ) + }) + + it('sends the sweep that re-enters placement down the unbounded lane', async () => { + const database = await openInMemoryRelayDatabase() + const probe = new InventoryLockProbe(database) + let now = 1_000 + const store = new RelayAssignmentStore(probe, () => now, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + await store.reconcileCells(CELLS) + for (const cell of CELLS) { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: `1111111${cell.id.slice(-1)}-1111-4111-8111-111111111111`, + startedAt: 50, + ready: true, + observedRequests: 0 + }) + } + await store.assign(identity) + // Let every heartbeat lapse so the sweep sees the assigned cell as dead. + now += 45_001 + probe.inventoryLocks.length = 0 + probe.failActivityLockOnce = true + + await store.evacuateDeadCells() + + expect(probe.inventoryLocks).not.toEqual([]) + for (const options of probe.inventoryLocks) { + expect(options?.lockTimeoutMs).toBeUndefined() + } + await database.close() + }) + + // Why: the SQLite hold test cannot reach PostgresDatabase.transaction, which is + // the only path production ever takes. + it('records the hold on the PostgreSQL transaction path', async () => { + const database = await openFakePostgres() + + await database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { + lockTimeoutMs: 150, + measureHoldMs: true + }) + }) + + expect(consumeRelayCellInventoryHold(database).cellInventoryHolds).toBe(1) + await database.close() + }) + + it('records no hold for a PostgreSQL transaction that took no measured lock', async () => { + const database = await openFakePostgres() + + await database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { lockTimeoutMs: 150 }) + }) + + expect(consumeRelayCellInventoryHold(database).cellInventoryHolds).toBe(0) + await database.close() + }) + + // Why: index.ts boots a server on import, so its wiring can only be read. An + // unspread hold metric is invisible: the flush simply omits the fields. + it('spreads the hold counts into the runtime metrics flush', () => { + const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') + const flush = /observability\.start\(\(\) => \(\{([^}]*)\}\)\)/.exec(source) + + expect(flush?.[1]).toContain('...consumeRelayCellInventoryHold(database)') + }) + + // Why: 500ms is a first value, not a measurement. Tuning it needs the hold + // distribution, which no runtime metric carried. + it('reports how long the inventory lock was held to COMMIT', async () => { + const database = await openInMemoryRelayDatabase() + const store = new RelayAssignmentStore(database, () => 1_000) + await store.reconcileCells(CELLS) + consumeRelayCellInventoryHold(database) + + await store.assign(identity) + + const counts = consumeRelayCellInventoryHold(database) + expect(counts.cellInventoryHolds).toBeGreaterThan(0) + expect(counts.cellInventoryHoldMsMax).toBeGreaterThanOrEqual(counts.cellInventoryHoldMsP95) + expect(counts.cellInventoryHoldMsMax).toBeGreaterThan(0) + // Consuming resets the window so the next flush reports its own holds. + expect(consumeRelayCellInventoryHold(database).cellInventoryHolds).toBe(0) + await database.close() + }) +}) + +// Why: exhausted transactions count against the incident monitor's bounded bar. +// A sweep that steps aside must not spend the retry budget or report a terminal failure. +describe('sweep lock skips stay off the transaction retry counters', () => { + it('reports neither a retry nor an exhaustion when NOWAIT finds the lock held', async () => { + const database = await openFakePostgres() + fakes.query.mockImplementation(async (sql: string) => { + fakes.statements.push(sql) + if (sql.includes('FOR UPDATE NOWAIT')) { + throw Object.assign(new Error('could not obtain lock'), { code: '55P03' }) + } + return { rows: [], rowCount: 0 } + }) + const events: string[] = [] + const warn = vi.spyOn(console, 'warn').mockImplementation((line: unknown) => { + try { + events.push(String((JSON.parse(line as string) as { event?: unknown }).event)) + } catch { + // non-JSON lines are not transaction telemetry + } + }) + + try { + await expect( + database.transaction(async (transaction) => { + await transaction.queryLocked(CELL_INVENTORY_SQL, [], { failIfUnavailable: true }) + }) + ).rejects.toThrow('database_lock_unavailable') + } finally { + warn.mockRestore() + } + + expect(events).not.toContain('orca_relay_postgres_transaction_retry') + expect(events).not.toContain('orca_relay_postgres_transaction_exhausted') + expect(fakes.statements.filter((sql) => sql === 'BEGIN')).toHaveLength(1) + await database.close() + }) +}) + +describe('background sweeps skip a contended cell inventory', () => { + it('takes the inventory NOWAIT and skips the tick instead of queueing', async () => { + const database = await openInMemoryRelayDatabase() + const probe = new InventoryLockProbe(database) + let now = 1_000 + const store = new RelayAssignmentStore(probe, () => now) + await store.reconcileCells(CELLS) + const assignment = await store.assign(identity) + await store.activateControl(identity, { + cellId: assignment.cellId, + assignmentEpoch: assignment.assignmentEpoch, + generation: 1 + }) + await store.startEvacuation(identity, 'cell-b') + now += 24 * 60 * 60_000 + probe.inventoryLocks.length = 0 + probe.failNoWait = true + const warnings = collectWarnings('orca_relay_sweep_cell_inventory_busy') + + let aborted: number + try { + aborted = await store.abortExpiredEvacuations() + } finally { + warnings.restore() + } + + expect(aborted).toBe(0) + expect(probe.inventoryLocks).not.toEqual([]) + expect(probe.inventoryLocks.every((options) => options?.failIfUnavailable === true)).toBe( + true + ) + expect(warnings.entries).toEqual([ + { event: 'orca_relay_sweep_cell_inventory_busy', sweep: 'abort-expired-evacuations', skipped: 1 } + ]) + await database.close() + }) + + // Why: a summary line on every quiet tick would bury the contended ones. + it('says nothing on a tick that skipped no candidate', async () => { + const database = await openInMemoryRelayDatabase() + let now = 1_000 + const store = new RelayAssignmentStore(database, () => now) + await store.reconcileCells(CELLS) + const assignment = await store.assign(identity) + await store.activateControl(identity, { + cellId: assignment.cellId, + assignmentEpoch: assignment.assignmentEpoch, + generation: 1 + }) + await store.startEvacuation(identity, 'cell-b') + now += 24 * 60 * 60_000 + const warnings = collectWarnings('orca_relay_sweep_cell_inventory_busy') + + let aborted: number + try { + aborted = await store.abortExpiredEvacuations() + } finally { + warnings.restore() + } + + expect(aborted).toBe(1) + expect(warnings.entries).toEqual([]) + await database.close() + }) + + it('still aborts the expired evacuation once the inventory is free', async () => { + const database = await openInMemoryRelayDatabase() + const probe = new InventoryLockProbe(database) + let now = 1_000 + const store = new RelayAssignmentStore(probe, () => now) + await store.reconcileCells(CELLS) + const assignment = await store.assign(identity) + await store.activateControl(identity, { + cellId: assignment.cellId, + assignmentEpoch: assignment.assignmentEpoch, + generation: 1 + }) + await store.startEvacuation(identity, 'cell-b') + now += 24 * 60 * 60_000 + + expect(await store.abortExpiredEvacuations()).toBe(1) + await database.close() + }) +}) + +// Returns each inventory lock the run took, as its bound or 'nowait'. +async function recordAssignInventoryModes( + drive: (store: InstanceType) => Promise +): Promise<(number | 'nowait' | 'pool-default')[]> { + const database = await openInMemoryRelayDatabase() + const probe = new InventoryLockProbe(database) + const store = new RelayAssignmentStore(probe, () => 1_000) + await store.reconcileCells(CELLS) + probe.inventoryLocks.length = 0 + probe.failActivityLockOnce = true + await drive(store) + await database.close() + return probe.inventoryLocks.map((options) => + options?.failIfUnavailable ? 'nowait' : (options?.lockTimeoutMs ?? 'pool-default') + ) +} + +function collectWarnings(event: string) { + const entries: Record[] = [] + const original = console.warn + console.warn = (line: unknown, ...rest: unknown[]) => { + try { + const parsed = JSON.parse(line as string) as Record + if (parsed.event === event) return void entries.push(parsed) + } catch { + // fall through to the real console for non-JSON lines + } + original(line, ...rest) + } + return { entries, restore: () => (console.warn = original) } +} + +const ACTIVITY_LEASE_SQL = 'SELECT * FROM relay_assignment_activity_leases' + +class InventoryLockProbe implements RelayDatabase { + readonly inventoryLocks: (RelayLockOptions | undefined)[] = [] + failNoWait = false + // Forces the next assign attempt down its inventory-first retry, the only lane + // that reaches the threaded lock mode. + failActivityLockOnce = false + + constructor(private readonly delegate: RelayDatabase) {} + + async query(sql: string, params?: unknown[]): Promise { + return await this.delegate.query(sql, params) + } + + async queryLocked( + sql: string, + params?: unknown[], + options?: RelayLockOptions + ): Promise { + if (locksCellInventory(sql)) { + this.inventoryLocks.push(options) + if (this.failNoWait && options?.failIfUnavailable) { + throw new Error('database_lock_unavailable') + } + } + if (this.failActivityLockOnce && sql.trim().startsWith(ACTIVITY_LEASE_SQL) && options?.failIfUnavailable) { + this.failActivityLockOnce = false + throw new Error('database_lock_unavailable') + } + return await this.delegate.queryLocked(sql, params, options) + } + + async transaction( + operation: (transaction: RelayDatabase) => Promise, + options?: RelayTransactionOptions + ): Promise { + return await this.delegate.transaction( + async (transaction) => await operation(new InventoryLockProbeTransaction(transaction, this)), + options + ) + } + + async close(): Promise {} +} + +class InventoryLockProbeTransaction implements RelayDatabase { + constructor( + private readonly delegate: RelayDatabase, + private readonly probe: InventoryLockProbe + ) {} + + async query(sql: string, params?: unknown[]): Promise { + return await this.delegate.query(sql, params) + } + + async queryLocked( + sql: string, + params?: unknown[], + options?: RelayLockOptions + ): Promise { + if (locksCellInventory(sql)) { + this.probe.inventoryLocks.push(options) + if (this.probe.failNoWait && options?.failIfUnavailable) { + throw new Error('database_lock_unavailable') + } + } + if ( + this.probe.failActivityLockOnce && + sql.trim().startsWith(ACTIVITY_LEASE_SQL) && + options?.failIfUnavailable + ) { + this.probe.failActivityLockOnce = false + throw new Error('database_lock_unavailable') + } + return await this.delegate.queryLocked(sql, params, options) + } + + async transaction(operation: (transaction: RelayDatabase) => Promise): Promise { + return await operation(this) + } + + async close(): Promise {} +} diff --git a/cloud/apps/relay/src/database-postgres-timeout.test.ts b/cloud/apps/relay/src/database-postgres-timeout.test.ts index a04a9eea042..7aba1234f7f 100644 --- a/cloud/apps/relay/src/database-postgres-timeout.test.ts +++ b/cloud/apps/relay/src/database-postgres-timeout.test.ts @@ -118,6 +118,39 @@ describe('PostgreSQL schema startup', () => { expect(query).toHaveBeenCalledTimes(2) }) + it.each([ + ['42710', 'CREATE TABLE IF NOT EXISTS test'], + ['42P07', 'CREATE TABLE IF NOT EXISTS test'], + ['42P07', 'CREATE INDEX IF NOT EXISTS test_index ON test(id)'], + ['42P07', 'CREATE UNIQUE INDEX IF NOT EXISTS test_index ON test(id)'] + ])('retries the committed-winner %s collision for %s', async (code, statement) => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const collision = Object.assign(new Error('already exists'), { code }) + const query = vi + .fn<(statement: string) => Promise>() + .mockRejectedValueOnce(collision) + .mockResolvedValue(undefined) + + await applyPostgresSchema([statement], query, { wait: async () => undefined }) + + expect(query).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['42710', 'CREATE INDEX IF NOT EXISTS test_index ON test(id)'], + ['42710', 'CREATE TABLE test'], + ['42P07', 'CREATE TABLE test'], + ['42P07', 'CREATE INDEX test_index ON test(id)'] + ])('does not retry %s for %s', async (code, statement) => { + const error = Object.assign(new Error('already exists'), { code }) + const query = vi.fn<(statement: string) => Promise>().mockRejectedValue(error) + const pause = vi.fn(async () => undefined) + + await expect(applyPostgresSchema([statement], query, { wait: pause })).rejects.toBe(error) + + expect(pause).not.toHaveBeenCalled() + }) + it.each([ ['pg_type_typname_nsp_index', 'CREATE TABLE test'], ['pg_class_relname_nsp_index', 'CREATE INDEX test_index ON test(id)'] diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index f7208863f42..326ab010ccb 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -1,4 +1,5 @@ import { mkdirSync } from 'node:fs' +import { performance } from 'node:perf_hooks' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' import pg from 'pg' @@ -8,9 +9,37 @@ import { type PostgresPoolPressureCounts } from './postgres-pool-pressure.js' import { applyPostgresSchema } from './postgres-schema-startup.js' +import { + CellInventoryHoldSamples, + emptyCellInventoryHoldCounts, + type CellInventoryHoldCounts +} from './cell-inventory-hold-samples.js' + +export const POSTGRES_LOCK_TIMEOUT_MS = 1_000 + +function setLocalLockTimeout(milliseconds: number): string { + if (!Number.isInteger(milliseconds) || milliseconds < 1) { + throw new Error('invalid_lock_timeout') + } + return `SET LOCAL lock_timeout = '${milliseconds}ms'` +} export type SqlRow = Record -export type RelayLockOptions = { failIfUnavailable?: boolean } +export type RelayLockOptions = { + failIfUnavailable?: boolean + // Only honoured inside a transaction: SET LOCAL is a no-op in autocommit. + lockTimeoutMs?: number + // Report how long this lock is held to COMMIT. The hold, not the wait, is what + // forms the queue, and nothing measured it before. + measureHoldMs?: boolean +} + +// A transaction that can report how long it held a measured lock before COMMIT. +type HoldMeasuringTransaction = { consumeHoldMs(): number | undefined } + +function measuredHoldMs(transaction: unknown): number | undefined { + return (transaction as HoldMeasuringTransaction).consumeHoldMs?.() +} export type RelayTransactionOptions = { reportRetries?: boolean } export interface RelayDatabase { @@ -612,9 +641,23 @@ function postgresTransactionErrorPhase(error: unknown): string { class SqliteTransaction implements RelayDatabase { readonly dialect = 'sqlite' as const + private heldFromMs: number | undefined constructor(protected readonly database: DatabaseSync) {} + consumeHoldMs(): number | undefined { + if (this.heldFromMs === undefined) return undefined + const holdMs = performance.now() - this.heldFromMs + this.heldFromMs = undefined + return holdMs + } + + protected noteHeld(options: RelayLockOptions): void { + if (options.measureHoldMs && this.heldFromMs === undefined) { + this.heldFromMs = performance.now() + } + } + async query(sql: string, params: unknown[] = []): Promise { const statement = this.database.prepare(sql) const bound = params.map((value) => (value === undefined ? null : value)) as never[] @@ -626,9 +669,11 @@ class SqliteTransaction implements RelayDatabase { async queryLocked( sql: string, params: unknown[] = [], - _options: RelayLockOptions = {} + options: RelayLockOptions = {} ): Promise { - return await this.query(sql, params) + const rows = await this.query(sql, params) + this.noteHeld(options) + return rows } async transaction( @@ -643,6 +688,11 @@ class SqliteTransaction implements RelayDatabase { class SqliteDatabase extends SqliteTransaction { private tail: Promise = Promise.resolve() + private readonly holds = new CellInventoryHoldSamples() + + consumeHoldCounts(): CellInventoryHoldCounts { + return this.holds.consumeCounts() + } override async query(sql: string, params: unknown[] = []): Promise { await this.tail @@ -655,9 +705,11 @@ class SqliteDatabase extends SqliteTransaction { this.tail = new Promise((resolve) => (release = resolve)) await previous this.database.exec('BEGIN IMMEDIATE') + const transaction = new SqliteTransaction(this.database) try { - const result = await operation(new SqliteTransaction(this.database)) + const result = await operation(transaction) this.database.exec('COMMIT') + this.holds.record(measuredHoldMs(transaction) ?? Number.NaN) return result } catch (error) { this.database.exec('ROLLBACK') @@ -675,9 +727,17 @@ class SqliteDatabase extends SqliteTransaction { class PostgresTransaction implements RelayDatabase { readonly dialect = 'postgres' as const + private heldFromMs: number | undefined constructor(protected readonly client: pg.PoolClient) {} + consumeHoldMs(): number | undefined { + if (this.heldFromMs === undefined) return undefined + const holdMs = performance.now() - this.heldFromMs + this.heldFromMs = undefined + return holdMs + } + async query(sql: string, params: unknown[] = []): Promise { try { const result = await this.client.query(postgresSql(sql), params) @@ -693,11 +753,21 @@ class PostgresTransaction implements RelayDatabase { params: unknown[] = [], options: RelayLockOptions = {} ): Promise { + // SET LOCAL lasts to COMMIT, so a bound left in place would silently govern + // every later locked statement in the transaction and misattribute its 55P03s. + const bounded = options.lockTimeoutMs !== undefined && !options.failIfUnavailable try { - return await this.query( + // A blocked waiter holds its pooled client for the whole lock_timeout, so + // hot tiny-table locks bound their own wait well under the pool default. + if (bounded) await this.query(setLocalLockTimeout(options.lockTimeoutMs!)) + const rows = await this.query( `${sql} FOR UPDATE${options.failIfUnavailable ? ' NOWAIT' : ''}`, params ) + if (options.measureHoldMs && this.heldFromMs === undefined) { + this.heldFromMs = performance.now() + } + return rows } catch (error) { if ( options.failIfUnavailable && @@ -706,6 +776,10 @@ class PostgresTransaction implements RelayDatabase { throw new Error('database_lock_unavailable') } throw error + } finally { + // Restore on the error path too: the transaction may still be retried or + // continue with unrelated locks after a caught lock failure. + if (bounded) await this.query(setLocalLockTimeout(POSTGRES_LOCK_TIMEOUT_MS)).catch(() => undefined) } } @@ -723,7 +797,6 @@ const POSTGRES_TRANSACTION_ATTEMPTS = 3 const POSTGRES_RETRY_MAX_DELAY_MS = 25 const POSTGRES_CONNECTION_TIMEOUT_MS = 2_000 const POSTGRES_STATEMENT_TIMEOUT_MS = 5_000 -const POSTGRES_LOCK_TIMEOUT_MS = 1_000 const POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS = 5_000 function retryablePostgresTransactionError(error: unknown): boolean { @@ -749,6 +822,11 @@ async function waitForPostgresRetry(random: () => number = Math.random): Promise class PostgresDatabase implements RelayDatabase { readonly dialect = 'postgres' as const private readonly pressure: PostgresPoolPressure + private readonly holds = new CellInventoryHoldSamples() + + consumeHoldCounts(): CellInventoryHoldCounts { + return this.holds.consumeCounts() + } constructor(private readonly pool: pg.Pool) { this.pressure = new PostgresPoolPressure(pool) @@ -770,6 +848,8 @@ class PostgresDatabase implements RelayDatabase { options: RelayLockOptions = {} ): Promise { try { + // No transaction here, so options.lockTimeoutMs cannot apply: SET LOCAL + // would be discarded at the autocommit boundary before the lock is taken. return await this.query( `${sql} FOR UPDATE${options.failIfUnavailable ? ' NOWAIT' : ''}`, params @@ -791,10 +871,12 @@ class PostgresDatabase implements RelayDatabase { ): Promise { for (let attempt = 1; attempt <= POSTGRES_TRANSACTION_ATTEMPTS; attempt++) { const client = await this.pressure.connect() + const transaction = new PostgresTransaction(client) try { await client.query('BEGIN') - const result = await operation(new PostgresTransaction(client)) + const result = await operation(transaction) await client.query('COMMIT') + this.holds.record(measuredHoldMs(transaction) ?? Number.NaN) return result } catch (error) { await client.query('ROLLBACK').catch(() => undefined) @@ -852,6 +934,13 @@ export function consumeRelayDatabasePoolPressure( : emptyPostgresPoolPressureCounts() } +export function consumeRelayCellInventoryHold( + database: RelayDatabase +): CellInventoryHoldCounts { + const holder = database as { consumeHoldCounts?: () => CellInventoryHoldCounts } + return holder.consumeHoldCounts?.() ?? emptyCellInventoryHoldCounts() +} + export function readRelayDatabasePoolPressure( database: RelayDatabase ): PostgresPoolPressureCounts { diff --git a/cloud/apps/relay/src/index.ts b/cloud/apps/relay/src/index.ts index 635f3ae9b38..541884362c2 100644 --- a/cloud/apps/relay/src/index.ts +++ b/cloud/apps/relay/src/index.ts @@ -10,12 +10,14 @@ import { roleOwnsAssignmentMaintenance } from './cell-admission-startup.js' import { + consumeRelayCellInventoryHold, consumeRelayDatabasePoolPressure, openRelayDatabase, readRelayDatabasePoolPressure } from './database.js' import { runAssignmentCleanup } from './assignment-cleanup-steps.js' import { runRelayBackgroundOperation } from './relay-background-operation.js' +import { jitteredSweepIntervalMs } from './relay-sweep-schedule.js' import { observedRelayRequests } from './relay-observability.js' import { startRegionalRehomeWorker } from './regional-rehome-worker.js' import { createRelayServer } from './relay-server.js' @@ -54,7 +56,7 @@ const cleanupTimer = setInterval( const assignmentCleanupTimer = roleOwnsAssignmentMaintenance(config.role) ? setInterval(() => { void runAssignmentCleanup(assignments) - }, 30_000) + }, jitteredSweepIntervalMs(30_000)) : null const inventorySnapshotTimer = roleOwnsAssignmentMaintenance(config.role) ? setInterval(() => { @@ -78,7 +80,8 @@ inventorySnapshotTimer?.unref() migrationInventoryTimer?.unref() observability.start(() => ({ ...runtimeCounts(), - ...consumeRelayDatabasePoolPressure(database) + ...consumeRelayDatabasePoolPressure(database), + ...consumeRelayCellInventoryHold(database) })) const regionalRehomeWorker = startRegionalRehomeWorker(config, assignments, { safetySnapshot: () => ({ diff --git a/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts b/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts index 5208f137ae8..9ed3cb2f324 100644 --- a/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts +++ b/cloud/apps/relay/src/postgres-schema-concurrency-postgres.test.ts @@ -34,22 +34,28 @@ describePostgres('PostgreSQL schema concurrency', () => { }) it('opens five directors when one new table is absent', async () => { - const initial = await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) - await initial.query(`DROP TABLE relay_cell_legacy_fence_adoptions`) - await initial.close() + // Which catalog step the race loser fails on depends on scheduling, so run several rounds and + // keep the loser's SQLSTATE in the failure instead of a bare boolean. + for (let round = 0; round < 10; round += 1) { + const initial = await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) + await initial.query(`DROP TABLE relay_cell_legacy_fence_adoptions`) + await initial.close() - const results = await Promise.allSettled( - Array.from({ length: 5 }, async (): Promise => - await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) + const results = await Promise.allSettled( + Array.from({ length: 5 }, async (): Promise => + await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' }) + ) + ) + const databases = results.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [] ) - ) - const databases = results.flatMap((result) => - result.status === 'fulfilled' ? [result.value] : [] - ) - try { - expect(results.every((result) => result.status === 'fulfilled')).toBe(true) - } finally { await Promise.all(databases.map(async (database) => await database.close())) + const rejections = results.flatMap((result) => + result.status === 'rejected' + ? [{ round, code: (result.reason as { code?: unknown }).code, message: String(result.reason) }] + : [] + ) + expect(rejections).toEqual([]) } - }) + }, 60_000) }) diff --git a/cloud/apps/relay/src/postgres-schema-startup.ts b/cloud/apps/relay/src/postgres-schema-startup.ts index 5e6260ad2fb..ba9efc6a792 100644 --- a/cloud/apps/relay/src/postgres-schema-startup.ts +++ b/cloud/apps/relay/src/postgres-schema-startup.ts @@ -22,15 +22,37 @@ function wait(delayMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, delayMs)) } +const CREATE_TABLE_IF_NOT_EXISTS = /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i +const CREATE_INDEX_IF_NOT_EXISTS = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i + +// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent +// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by +// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines +// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt. +function concurrentCreateCollision( + value: { code?: unknown; constraint?: unknown }, + statement: string +): boolean { + if (CREATE_TABLE_IF_NOT_EXISTS.test(statement)) { + return ( + (value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') || + value.code === '42710' || + value.code === '42P07' + ) + } + if (CREATE_INDEX_IF_NOT_EXISTS.test(statement)) { + return ( + (value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') || + value.code === '42P07' + ) + } + return false +} + function retryableSchemaError(error: unknown, statement: string): boolean { const value = error as { code?: unknown; constraint?: unknown } return ( - RETRYABLE_SCHEMA_CODES.has(String(value.code)) || - (value.code === '23505' && - ((value.constraint === 'pg_type_typname_nsp_index' && - /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i.test(statement)) || - (value.constraint === 'pg_class_relname_nsp_index' && - /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i.test(statement)))) + RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, statement) ) } diff --git a/cloud/apps/relay/src/regional-rehome-store.test.ts b/cloud/apps/relay/src/regional-rehome-store.test.ts index 43f293b1131..26f711189ee 100644 --- a/cloud/apps/relay/src/regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/regional-rehome-store.test.ts @@ -5,7 +5,12 @@ import { REGIONAL_REHOME_QUARANTINE_MS, REGIONAL_REHOME_REDRAIN_SEND_LIMIT } from './assignment-store.js' -import { openInMemoryRelayDatabase, type RelayDatabase, type SqlRow } from './database.js' +import { + openInMemoryRelayDatabase, + type RelayDatabase, + type RelayLockOptions, + type SqlRow +} from './database.js' import { REGIONAL_REHOME_SQL_FAILURES_LIMIT, REGIONAL_REHOME_SQL_FAILURES_PER_CELL_LIMIT @@ -556,6 +561,341 @@ describe('regional rehome assignment state', () => { await context.database.close() }) + it('skips a rehome dispatch tick on a contended cell inventory', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + await activatePreferredSource(context, identity) + probe.reset() + probe.failNoWait = true + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + let attempt: unknown + try { + attempt = await context.store.claimRegionalRehome() + } finally { + busy.restore() + } + + expect(attempt).toBeNull() + expect(probe.locks).not.toEqual([]) + expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'claim-regional-rehome', + skipped: 1 + } + ]) + + probe.failNoWait = false + expect(await context.store.claimRegionalRehome()).toMatchObject({ + sourceCellId: source.id, + targetCellId: target.id + }) + await context.database.close() + }) + + // Why: the redrain lane reaches the inventory through the fleet-safety read + // rather than through candidate selection, so it needs its own coverage. + // Why: one contended candidate must cost its own tick, not the whole page. The + // sweeps are explicitly per-candidate isolated for exactly this reason. + it('completes the candidates behind a contended one', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identities = [ + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } + ] + for (const identity of identities) { + // Dispatch is rate limited, so each claim needs its own interval. + context.advance(60_000) + await freshHeartbeats(context) + const sourceControl = await activatePreferredSource(context, identity) + const attempt = await context.store.claimRegionalRehome() + await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + await context.store.releaseActivity(identity, sourceControl) + } + probe.reset() + probe.failNoWaitTimes = 1 + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + let completed: number + try { + completed = await context.store.completeReadyRegionalRehomes() + } finally { + busy.restore() + } + + expect(completed).toBe(1) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'complete-ready-regional-rehomes', + skipped: 1 + } + ]) + await context.database.close() + }) + + // Why: with `continue` replaced by `break` a single contended candidate drops + // the rest of the page. Two in a row prove the sweep resumes, not just that it + // survived one, and that the summary counts both. + it('completes a candidate behind two contended ones', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identities = [ + { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, + { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' }, + { userId: 'user-3', relayHostId: 'aaaabbbbccccdddd' } + ] + for (const identity of identities) { + // Dispatch is rate limited, so each claim needs its own interval. + context.advance(60_000) + await freshHeartbeats(context) + const sourceControl = await activatePreferredSource(context, identity) + const attempt = await context.store.claimRegionalRehome() + await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + await context.store.releaseActivity(identity, sourceControl) + } + probe.reset() + probe.failNoWaitTimes = 2 + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + let completed: number + try { + completed = await context.store.completeReadyRegionalRehomes() + } finally { + busy.restore() + } + + expect(completed).toBe(1) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'complete-ready-regional-rehomes', + skipped: 2 + } + ]) + await context.database.close() + }) + + // Why: only inventory contention is ordinary. Every other failure must keep its + // existing propagation and its dispatch-failure accounting. + it('propagates a claim failure that is not inventory contention', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + probe.reset() + probe.failWith = new Error('relay_capacity_exhausted') + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + try { + await expect(context.store.claimRegionalRehome()).rejects.toThrow( + 'relay_capacity_exhausted' + ) + } finally { + busy.restore() + } + + expect(busy.entries).toEqual([]) + await context.database.close() + }) + + // Why: the transaction dies at the first contended candidate, so every + // candidate behind it is abandoned too. Reporting one would understate the tick. + it('reports every candidate the contended tick abandoned', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + await activatePreferredSource(context, { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }) + await activatePreferredSource(context, { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' }) + await activatePreferredSource(context, { userId: 'user-3', relayHostId: 'aaaabbbbccccdddd' }) + probe.reset() + probe.failNoWait = true + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + try { + expect(await context.store.claimRegionalRehome()).toBeNull() + } finally { + busy.restore() + } + + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'claim-regional-rehome', + skipped: 3 + } + ]) + await context.database.close() + }) + + it('skips a redrain tick on a contended cell inventory', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + await activatePreferredSource(context, identity) + const attempt = await context.store.claimRegionalRehome() + await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + context.advance(60 * 60_000 + 1) + await freshHeartbeats(context) + probe.reset() + probe.failNoWait = true + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + + let redrain: unknown + try { + redrain = await context.store.claimRegionalRehome() + } finally { + busy.restore() + } + + expect(redrain).toBeNull() + expect(probe.locks).not.toEqual([]) + expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'claim-regional-rehome', + skipped: 1 + } + ]) + + probe.failNoWait = false + expect(await context.store.claimRegionalRehome()).toMatchObject({ + attemptId: attempt!.attemptId, + sendAttempts: 2 + }) + await context.database.close() + }) + + it('skips a completion tick on a contended cell inventory without quarantining it', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + const attempt = await context.store.claimRegionalRehome() + await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + await context.store.releaseActivity(identity, sourceControl) + probe.reset() + probe.failNoWait = true + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + const failures = collectCandidateFailureWarnings() + + let completed: number + try { + completed = await context.store.completeReadyRegionalRehomes() + } finally { + failures.restore() + busy.restore() + } + + expect(completed).toBe(0) + expect(probe.locks).not.toEqual([]) + expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) + expect(failures.entries).toEqual([]) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'complete-ready-regional-rehomes', + skipped: 1 + } + ]) + + probe.failNoWait = false + expect(await context.store.completeReadyRegionalRehomes()).toBe(1) + await context.database.close() + }) + + // Why: a contended inventory is another director settling the same row, not a + // poisoned candidate. Quarantining on it would exclude a healthy attempt from + // the sweep's LIMIT pages for 15 minutes. + it('skips an abort tick on a contended cell inventory without quarantining it', async () => { + const probe = new CellInventoryLockProbe() + const context = await setup({ wrap: (database) => probe.wrap(database) }) + const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } + const sourceControl = await activatePreferredSource(context, identity) + const attempt = await context.store.claimRegionalRehome() + await context.store.recordRegionalRehomeDrainReceipt(attempt!.attemptId, 'accepted') + const targetControl = await context.store.activateControl(identity, { + cellId: target.id, + assignmentEpoch: 2, + generation: 1 + }) + await context.store.markMigrationTargetRegistered(identity, { + cellId: target.id, + assignmentEpoch: 2 + }) + await context.store.releaseActivity(identity, sourceControl) + await context.store.releaseActivity(identity, targetControl) + context.advance(24 * 60 * 60_000) + await heartbeat(context.store, source, sourceIncarnation, 1, 2) + probe.reset() + probe.failNoWait = true + const busy = collectEventWarnings('orca_relay_sweep_cell_inventory_busy') + const failures = collectCandidateFailureWarnings() + + let aborted: number + try { + aborted = await context.store.abortExpiredRegionalRehomes() + } finally { + failures.restore() + busy.restore() + } + + expect(aborted).toBe(0) + expect(probe.locks).not.toEqual([]) + expect(probe.locks.every((options) => options?.failIfUnavailable === true)).toBe(true) + expect(failures.entries).toEqual([]) + expect(busy.entries).toEqual([ + { + event: 'orca_relay_sweep_cell_inventory_busy', + sweep: 'abort-expired-regional-rehomes', + skipped: 1 + } + ]) + + probe.failNoWait = false + expect(await context.store.abortExpiredRegionalRehomes()).toBe(1) + await context.database.close() + }) + it('rolls back an inactive registered target only after the 24-hour bound', async () => { const context = await setup() const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } @@ -1208,10 +1548,12 @@ function collectDisableWarnings() { } } -async function setup(options: { sourceProtocol?: number } = {}) { +async function setup( + options: { sourceProtocol?: number; wrap?: (database: RelayDatabase) => RelayDatabase } = {} +) { let clock = 1_000_000 const database = await openInMemoryRelayDatabase() - const store = new RelayAssignmentStore(database, () => clock, { + const store = new RelayAssignmentStore(options.wrap?.(database) ?? database, () => clock, { requireLiveCells: true, heartbeatTtlMs: 45_000 }) @@ -1397,3 +1739,43 @@ async function heartbeat( } }) } + +class CellInventoryLockProbe { + readonly locks: (RelayLockOptions | undefined)[] = [] + failNoWait = false + // Contends the first N candidates only, so the sweep must carry on past them. + failNoWaitTimes = 0 + failWith: Error | null = null + + reset(): void { + this.locks.length = 0 + } + + wrap(database: RelayDatabase): RelayDatabase { + const probe = this + const decorate = (delegate: RelayDatabase): RelayDatabase => ({ + query: async (sql, params) => await delegate.query(sql, params), + queryLocked: async (sql, params, options) => { + if (sql.trim() === 'SELECT * FROM relay_cells ORDER BY cell_id ASC') { + probe.locks.push(options) + if (probe.failWith) throw probe.failWith + if (options?.failIfUnavailable && probe.failNoWaitTimes > 0) { + probe.failNoWaitTimes-- + throw new Error('database_lock_unavailable') + } + if (probe.failNoWait && options?.failIfUnavailable) { + throw new Error('database_lock_unavailable') + } + } + return await delegate.queryLocked(sql, params, options) + }, + transaction: async (operation, options) => + await delegate.transaction( + async (transaction) => await operation(decorate(transaction)), + options + ), + close: async () => undefined + }) + return decorate(database) + } +} diff --git a/cloud/apps/relay/src/regional-rehome-worker.ts b/cloud/apps/relay/src/regional-rehome-worker.ts index 97c63a61025..47a2748cff4 100644 --- a/cloud/apps/relay/src/regional-rehome-worker.ts +++ b/cloud/apps/relay/src/regional-rehome-worker.ts @@ -3,6 +3,7 @@ import type { RelayAssignmentStore } from './assignment-store.js' import type { RelayConfig } from './config.js' import { googleMetadataIdentityToken } from './google-metadata-identity-token.js' import type { RegionalRehomeSafetySnapshot } from './relay-observability.js' +import { jitteredSweepIntervalMs } from './relay-sweep-schedule.js' type RegionalRehomeWorkerOptions = { fetch?: typeof fetch @@ -10,6 +11,7 @@ type RegionalRehomeWorkerOptions = { now?: () => number intervalMs?: number requestTimeoutMs?: number + random?: () => number safetySnapshot?: () => RegionalRehomeSafetySnapshot } @@ -109,7 +111,10 @@ export function startRegionalRehomeWorker( inFlight = false } } - const timer = setInterval(() => void run(), options.intervalMs ?? 1_000) + const timer = setInterval( + () => void run(), + options.intervalMs ?? jitteredSweepIntervalMs(1_000, options.random) + ) timer.unref() void run() return { diff --git a/cloud/apps/relay/src/relay-observability.ts b/cloud/apps/relay/src/relay-observability.ts index 6125ede8d1a..2266217d607 100644 --- a/cloud/apps/relay/src/relay-observability.ts +++ b/cloud/apps/relay/src/relay-observability.ts @@ -1,6 +1,7 @@ import { monitorEventLoopDelay, performance } from 'node:perf_hooks' import type { RelayRegion } from '@orca-cloud/relay-contract' import type { ControlRenewalOutcome } from './assignment-store.js' +import type { CellInventoryHoldCounts } from './cell-inventory-hold-samples.js' import type { PostgresPoolPressureCounts } from './postgres-pool-pressure.js' import type { RelayReadinessObservation } from './relay-readiness.js' @@ -20,7 +21,9 @@ export function observedRelayRequests(counts: RelayRuntimeCounts): number { return counts.preAuthConnections + counts.controls + counts.splices + counts.pendingSplices } -export type RelayProcessCounts = RelayRuntimeCounts & PostgresPoolPressureCounts +export type RelayProcessCounts = RelayRuntimeCounts & + PostgresPoolPressureCounts & + Partial export type RegionalRehomeRuntimeSafety = { observedAt: number diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index a14240cfa6a..32a83962d81 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -31,6 +31,16 @@ import { createRelayTokenVerifier, readBearer } from './relay-token-verifier.js' import { closeRelayWebSocket } from './relay-websocket-close.js' import { ProcessQueuedByteBudget } from './splice-forwarder.js' +// A malformed percent-escape in the request target must be a client error, never a URIError +// thrown out of the `upgrade` listener (which is uncaught and kills the process). +function decodePathSegment(value: string): string | null { + try { + return decodeURIComponent(value) + } catch { + return null + } +} + function rejectUpgrade(socket: NodeJS.WritableStream, status: number, message: string): void { socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`) if ('destroy' in socket && typeof socket.destroy === 'function') socket.destroy() @@ -278,8 +288,8 @@ export function createRelayServer( return } if (url.pathname.startsWith('/v1/connect/')) { - const hostId = decodeURIComponent(url.pathname.slice('/v1/connect/'.length)) - if (!/^[A-Za-z0-9_-]{16}$/.test(hostId)) { + const hostId = decodePathSegment(url.pathname.slice('/v1/connect/'.length)) + if (hostId === null || !/^[A-Za-z0-9_-]{16}$/.test(hostId)) { rejectUpgrade(socket, 429, 'Too Many Requests') return } @@ -373,7 +383,7 @@ export function createRelayServer( rejectUpgrade(socket, 404, 'Not Found') return } - const connId = decodeURIComponent(url.pathname.slice('/v1/host/data/'.length)) + const connId = decodePathSegment(url.pathname.slice('/v1/host/data/'.length)) if (!connId || connId.length > 128) { rejectUpgrade(socket, 429, 'Too Many Requests') return diff --git a/cloud/apps/relay/src/relay-sweep-schedule.test.ts b/cloud/apps/relay/src/relay-sweep-schedule.test.ts new file mode 100644 index 00000000000..d5ef450cc43 --- /dev/null +++ b/cloud/apps/relay/src/relay-sweep-schedule.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { startRegionalRehomeWorker } from './regional-rehome-worker.js' +import { jitteredSweepIntervalMs, SWEEP_JITTER_FRACTION } from './relay-sweep-schedule.js' + +describe('sweep schedule jitter', () => { + it('spreads instances across a bounded window above the base period', () => { + expect(jitteredSweepIntervalMs(30_000, () => 0)).toBe(30_000) + expect(jitteredSweepIntervalMs(30_000, () => 0.5)).toBe(33_000) + // Math.random() never returns 1, so the open bound is the real ceiling. + expect(jitteredSweepIntervalMs(30_000, () => 0.999)).toBeLessThan(36_000) + }) + + // Why: a shorter period would raise the very lock traffic the offset spreads. + it('never schedules a sweep sooner than its base period', () => { + for (const random of [0, 0.25, 0.5, 0.75, 0.999]) { + expect(jitteredSweepIntervalMs(1_000, () => random)).toBeGreaterThanOrEqual(1_000) + } + expect(SWEEP_JITTER_FRACTION).toBeGreaterThan(0) + }) + + it('jitters the regional rehome dispatch tick, which every director runs each second', () => { + const timers: number[] = [] + const setIntervalSpy = vi + .spyOn(globalThis, 'setInterval') + .mockImplementation(((_handler: unknown, delayMs?: number) => { + timers.push(delayMs ?? 0) + return { unref: () => undefined, [Symbol.dispose]: () => undefined } as never + }) as never) + + try { + startRegionalRehomeWorker( + { + role: 'director', + rehomeAudience: 'https://rehome.example.test', + rehomeDirectorServiceAccount: 'rehome@example.test' + } as never, + { claimRegionalRehome: async () => null } as never, + { random: () => 0.5, safetySnapshot: () => ({}) as never } + ) + } finally { + setIntervalSpy.mockRestore() + } + + expect(timers).toEqual([1_100]) + }) + + // Why: index.ts boots a server on import, so its wiring can only be read. + it('jitters the director assignment cleanup tick', () => { + const source = readFileSync(new URL('./index.ts', import.meta.url), 'utf8') + const cleanup = /runAssignmentCleanup\(assignments\)\s*\},\s*([^\n]*?)\)\n/.exec(source) + + expect(cleanup?.[1]).toBe('jitteredSweepIntervalMs(30_000)') + }) +}) diff --git a/cloud/apps/relay/src/relay-sweep-schedule.ts b/cloud/apps/relay/src/relay-sweep-schedule.ts new file mode 100644 index 00000000000..f73e6b69ead --- /dev/null +++ b/cloud/apps/relay/src/relay-sweep-schedule.ts @@ -0,0 +1,13 @@ +// Why: every director instance boots from the same rollout, so its periodic +// sweeps land on the same wall-clock second across instances and pile onto the +// one global cell-inventory lock together. A per-process offset spreads the +// arrivals; the sweeps are idempotent, so a slightly longer period is free. +export const SWEEP_JITTER_FRACTION = 0.2 + +export function jitteredSweepIntervalMs( + baseMs: number, + random: () => number = Math.random +): number { + // Only ever longer: a shorter period would raise the very load being spread. + return baseMs + Math.floor(random() * baseMs * SWEEP_JITTER_FRACTION) +} diff --git a/cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts b/cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts new file mode 100644 index 00000000000..24635a68e6f --- /dev/null +++ b/cloud/apps/relay/src/relay-upgrade-malformed-uri.blackbox.test.ts @@ -0,0 +1,122 @@ +import { connect, createServer as createNetServer } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RelayConfig } from './config.js' +import type { RelayDatabase } from './database.js' +import { createRelayServer } from './relay-server.js' + +async function unusedPort(): Promise { + const server = createNetServer() + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('missing test port') + await new Promise((resolve) => server.close(() => resolve())) + return address.port +} + +function rawUpgrade(port: number, target: string): Promise<{ status: string; closed: boolean }> { + return new Promise((resolve, reject) => { + const socket = connect(port, '127.0.0.1') + let data = '' + socket.once('connect', () => { + socket.write( + `GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\n` + + 'Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n' + + // RFC 6455 §1.3 example nonce; allowlisted in cloud/.gitleaks.toml. + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n' + ) + }) + socket.on('data', (chunk) => { + data += chunk.toString() + }) + socket.once('close', () => resolve({ status: data.split('\r\n')[0] ?? '', closed: true })) + socket.once('error', reject) + setTimeout(() => { + socket.destroy() + resolve({ status: data.split('\r\n')[0] ?? '', closed: false }) + }, 1_500).unref() + }) +} + +describe('relay upgrade with a malformed request target', () => { + const cleanup: Array<() => Promise | void> = [] + + afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() + }) + + it('rejects an undecodable /v1/connect path without an uncaught exception', async () => { + const port = await unusedPort() + const relayUrl = `http://127.0.0.1:${port}` + const database: RelayDatabase = { + query: vi.fn(async () => []), + queryLocked: vi.fn(async () => []), + transaction: vi.fn(async (operation) => await operation(database)), + close: vi.fn(async () => undefined) + } + const config = { + port, + publicUrl: relayUrl, + cellUrl: relayUrl, + authIssuer: 'https://auth.example.com', + authAudience: 'orca-relay', + jwksUrl: 'https://auth.example.com/jwks', + assignmentSigningKey: new Uint8Array(32), + role: 'cell', + cellId: 'production-gce-c3', + cells: [{ id: 'production-gce-c3', url: relayUrl, capacityRequests: 4_000 }], + adminAudience: `${relayUrl}/admin`, + deployServiceAccount: 'deploy@example.com', + runtimeServiceAccount: 'runtime@example.com', + connectionHardCap: 600, + connectionUnobservedBound: 60, + adminJwksUrl: 'https://auth.example.com/admin-jwks', + databasePoolMax: 10, + publicAssignmentsEnabled: true, + publicAssignmentConcurrency: 2, + publicAssignmentQueueMax: 128, + publicAssignmentWaitMs: 4_000, + publicResolveConcurrency: 1, + publicResolveWaitMs: 5_000, + publicAssignmentRetryAfterSeconds: 5, + dataDir: './test-data' + } satisfies RelayConfig + const relay = createRelayServer(config, database, { + connectionLedgerLimits: { hardCap: 5, controlReserve: 1 } + }) + relay.server.listen(port, '127.0.0.1') + await new Promise((resolve) => relay.server.once('listening', resolve)) + cleanup.push(() => new Promise((resolve) => relay.server.close(() => resolve()))) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + // Vitest installs its own uncaughtException listener; capture ours first so the test reports + // the exception as a verdict instead of dying with it. + const uncaught: unknown[] = [] + const onUncaught = (error: unknown): void => { + uncaught.push(error) + } + process.prependListener('uncaughtException', onUncaught) + cleanup.push(() => { + process.off('uncaughtException', onUncaught) + }) + + const results = [] + for (const target of [ + '/v1/connect/%', + '/v1/connect/%E0%A4%A', + '/v1/connect/%C0%AF', + '/v1/host/data/%' + ]) { + results.push(await rawUpgrade(port, target)) + } + // A malformed percent-escape must be a client error, never a process-level throw. + expect(uncaught).toEqual([]) + for (const result of results) { + expect(result.status).toMatch(/^HTTP\/1\.1 4\d\d/) + } + // The server must still serve a well-formed upgrade afterwards. + const after = await rawUpgrade(port, '/v1/connect/abcdefghijklmnop') + expect(after.status).toMatch(/^HTTP\/1\.1 101/) + }) +}) diff --git a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs index 5eab757255a..e31403f6dd6 100644 --- a/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs +++ b/cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs @@ -191,3 +191,10 @@ test('director rollout has a strict one-time identity bootstrap', () => { assert.ok(candidateProof > 0 && candidateProof < trafficMove) assert.equal(script.indexOf('verifyRehomeDisabled', trafficMove), -1) }) + +test('rehome job pipes every control result through tee under pipefail', () => { + const job = workflow('operate-relay-production-rehome-job.yml') + // Without `shell: bash` the step exit code is tee's, so a thrown inspect/apply passes green. + assert.match(job, /defaults:\n run:\n(?: #.*\n)* shell: bash\n/) + assert.ok((job.match(/\| tee "\$\{RUNNER_TEMP\}/g) ?? []).length >= 5) +}) diff --git a/cloud/dev/scripts/relay-repository.mjs b/cloud/dev/scripts/relay-repository.mjs index bf41bed8012..7e8b01e4799 100644 --- a/cloud/dev/scripts/relay-repository.mjs +++ b/cloud/dev/scripts/relay-repository.mjs @@ -15,9 +15,16 @@ export function relayWorkflowFile(name) { return `${RELAY_WORKFLOW_FILE_PREFIX}${name}` } +// Repository-relative path for a repository that renames its copies with `prefix`. Terraform's +// trusted prefix is a variable and need not be this checkout's, so callers rendering a +// workflow_ref from Terraform pass it in rather than assuming the local one. +export function prefixedRelayWorkflowPath(prefix, name) { + return `.github/workflows/${prefix}${name}` +} + // Repository-relative path, the shape GitHub reports in workflow_ref and evidence payloads. export function relayWorkflowPath(name) { - return `.github/workflows/${relayWorkflowFile(name)}` + return prefixedRelayWorkflowPath(RELAY_WORKFLOW_FILE_PREFIX, name) } export function relayWorkflowUrl(name) { diff --git a/cloud/dev/scripts/relay-repository.test.mjs b/cloud/dev/scripts/relay-repository.test.mjs index 56383db4a1e..cf33f869773 100644 --- a/cloud/dev/scripts/relay-repository.test.mjs +++ b/cloud/dev/scripts/relay-repository.test.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { RELAY_GITHUB_REPOSITORY, RELAY_WORKFLOW_FILE_PREFIX, + prefixedRelayWorkflowPath, readRelayWorkflow, relayWorkflowFile, relayWorkflowPath, @@ -21,6 +22,8 @@ test('workflow identity is derived, never restated', () => { assert.equal(relayWorkflowFile('deploy-relay-staging.yml'), `${RELAY_WORKFLOW_FILE_PREFIX}deploy-relay-staging.yml`) assert.equal(relayWorkflowPath('deploy-relay-staging.yml'), `.github/workflows/${relayWorkflowFile('deploy-relay-staging.yml')}`) assert.ok(relayWorkflowUrl('deploy-relay-staging.yml').pathname.endsWith(relayWorkflowPath('deploy-relay-staging.yml'))) + // A caller rendering Terraform's trusted ref supplies that prefix instead of this checkout's. + assert.equal(prefixedRelayWorkflowPath('cloud-', 'deploy-relay-staging.yml'), '.github/workflows/cloud-deploy-relay-staging.yml') assert.match(readRelayWorkflow('deploy-relay-staging.yml'), /^name:/m) assert.match(RELAY_GITHUB_REPOSITORY, /^[\w.-]+\/[\w.-]+$/) }) diff --git a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs index 75bbb9ac8d5..0e777b06c38 100644 --- a/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs +++ b/cloud/dev/scripts/relay-staging-c4-refresh-workflow.test.mjs @@ -39,14 +39,17 @@ const launchDigest = '5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70ca // so a file-wide count no longer isolates Asia. const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'] -function productionCell(cellId) { - const start = productionTfvars.indexOf(`"${cellId}"`) +function cellBlock(tfvars, cellId) { + const start = tfvars.indexOf(`"${cellId}"`) assert.notEqual(start, -1, `${cellId} is missing`) - return productionTfvars.slice(start, productionTfvars.indexOf('\n }', start)) + return tfvars.slice(start, tfvars.indexOf('\n }', start)) } +const productionCell = (cellId) => cellBlock(productionTfvars, cellId) + +// Scoped to C4 by name: staging C3 serves this digest too since its 2026-09-03 re-pin. test('pins staging C4 and all production Asia cells to the same launch image', () => { - assert.equal(stagingTfvars.match(new RegExp(launchDigest, 'g'))?.length, 1) + assert.match(cellBlock(stagingTfvars, 'staging-gce-c4'), new RegExp(`relay@sha256:${launchDigest}"`)) for (const cellId of asiaCells) { assert.match(productionCell(cellId), new RegExp(`relay@sha256:${launchDigest}"`), cellId) } diff --git a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs index fece62f9ea2..8afbfb5ca94 100644 --- a/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs +++ b/cloud/dev/scripts/relay-staging-deploy-identity.test.mjs @@ -2,11 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { readWorkflow, workflowFiles } from './cloud-sql-rollout-lock-census.mjs' -import { - RELAY_WORKFLOW_FILE_PREFIX, - relayWorkflowFile, - relayWorkflowPath -} from './relay-repository.mjs' +import { prefixedRelayWorkflowPath, relayWorkflowFile } from './relay-repository.mjs' const identity = readFileSync( new URL('../../infra/terraform/relay-staging-deploy-iam.tf', import.meta.url), @@ -128,8 +124,11 @@ test('the rendered attribute condition stays inside the provider limit', () => { `assertion.repository_id == '${variableDefault('github_repo_id')}'`, `assertion.repository_owner_id == '${variableDefault('github_owner_id')}'` ] + // The prefix is the Terraform variable, not this checkout's own workflow filenames: the + // condition names the files as the trusted repository carries them. + const prefix = variableDefault('github_workflow_file_prefix') const workflowRefs = providerWorkflowFiles().map( - (file) => `${repository}/${relayWorkflowPath(file)}@refs/heads/main` + (file) => `${repository}/${prefixedRelayWorkflowPath(prefix, file)}@refs/heads/main` ) const rendered = [ ...claims, @@ -138,9 +137,7 @@ test('the rendered attribute condition stays inside the provider limit', () => { `(${workflowRefs.map((ref) => `assertion.workflow_ref == '${ref}'`).join(' || ')})` ].join(' && ') assert.ok(rendered.length < 4096, `rendered condition is ${rendered.length} characters`) - // 797 is the private repository's rendered length. This copy prefixes every workflow filename, - // which is the only difference, so the pin still moves the moment a workflow is added or dropped. - assert.equal(rendered.length, 797 + workflowRefs.length * RELAY_WORKFLOW_FILE_PREFIX.length) + assert.equal(rendered.length, 791) }) // Why: the census is the point. A binding added here without a workflow step behind it, or one diff --git a/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs b/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs index f25e442d8c0..1d3f3ce4d79 100644 --- a/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs +++ b/cloud/dev/scripts/workload-identity-attribute-conditions.test.mjs @@ -13,13 +13,13 @@ const EXPECTED_CONDITIONS = { staging: { relay: { github_staging_relay_capacity: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && (assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/prove-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/recover-relay-staging-c4-image.yml@refs/heads/main')) || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-prove-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-recover-relay-staging-c4-image.yml@refs/heads/main')))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-prove-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-recover-relay-staging-c4-image.yml@refs/heads/main')", github_staging_relay_deploy: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && (assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-staging-gce-candidate.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-staging.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/power-relay-staging.yml@refs/heads/main')) || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging-gce-candidate.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-power-relay-staging.yml@refs/heads/main')))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-bootstrap-relay-staging-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging-gce-candidate.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-staging.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-power-relay-staging.yml@refs/heads/main')", github_relay_asia_topology: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && assertion.event_name == 'workflow_dispatch' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-asia-topology.yml@refs/heads/main') || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-asia-topology.yml@refs/heads/main'))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && assertion.event_name == 'workflow_dispatch' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-asia-topology.yml@refs/heads/main'", github_relay_asia_proof: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && assertion.event_name == 'workflow_dispatch' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/prove-relay-asia-staging.yml@refs/heads/main') || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-prove-relay-asia-staging.yml@refs/heads/main'))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'staging' && assertion.event_name == 'workflow_dispatch' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-prove-relay-asia-staging.yml@refs/heads/main'", }, // The relay root creates this provider only in production, so staging has exactly one // definition and it lives here. @@ -31,15 +31,15 @@ const EXPECTED_CONDITIONS = { production: { relay: { github: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && ((assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-fence-broker.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-director.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-multi-target.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/publish-relay-production.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/operate-relay-production-rehome.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/operate-relay-production-rehome-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-same-cap.yml@refs/heads/main' && (assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-same-cap-job.yml@refs/heads/main' || assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-same-cap.yml@refs/heads/main')))) || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-fence-broker.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-director.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-publish-relay-production.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && (assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main' || assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main')))))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-fence-broker.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-director.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-asia-admission.yml@refs/heads/main' || assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-publish-relay-production.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-operate-relay-production-rehome-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && (assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main' || assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main')))", github_monitor: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/monitor-relay-production.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/monitor-relay-production-job.yml@refs/heads/main') || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-monitor-relay-production.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-monitor-relay-production-job.yml@refs/heads/main'))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-monitor-relay-production.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-monitor-relay-production-job.yml@refs/heads/main'", github_fence: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-multi-target.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-multi-target.yml@refs/heads/main') || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main'))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-multi-target.yml@refs/heads/main'", github_production_relay_capacity: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && ((assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-capacity.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-capacity-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-same-cap.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-production-same-cap-job.yml@refs/heads/main'))) || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main'))))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && ((assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-capacity-job.yml@refs/heads/main') || (assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap.yml@refs/heads/main' && assertion.job_workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml@refs/heads/main'))", github_relay_asia_topology: - "assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && assertion.event_name == 'workflow_dispatch' && ((assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca-cloud/.github/workflows/deploy-relay-asia-topology.yml@refs/heads/main') || (assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-asia-topology.yml@refs/heads/main'))", + "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420' && assertion.ref == 'refs/heads/main' && assertion.environment == 'production' && assertion.event_name == 'workflow_dispatch' && assertion.workflow_ref == 'stablyai/orca/.github/workflows/cloud-deploy-relay-asia-topology.yml@refs/heads/main'", }, apps: { github_production_app_deploy: @@ -48,20 +48,21 @@ const EXPECTED_CONDITIONS = { }, } -// Every repository the relay root accepts while the public extraction runs, with the workflow-ref -// head each one contributes. The apps root is not part of the dual accept. -const ACCEPTED_REPOSITORIES = [ - { - claims: - "assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420'", - workflowHead: 'stablyai/orca-cloud/.github/workflows/' - }, - { +// The one repository each root trusts, with the workflow-ref head it contributes. The relay root +// moved to the public repository, where the workflow files carry the `cloud-` prefix; the apps +// root still deploys from the private one. +const ROOT_REPOSITORIES = { + relay: { claims: "assertion.repository == 'stablyai/orca' && assertion.repository_id == '1183888342' && assertion.repository_owner_id == '127256420'", workflowHead: 'stablyai/orca/.github/workflows/cloud-' + }, + apps: { + claims: + "assertion.repository == 'stablyai/orca-cloud' && assertion.repository_id == '1273841466' && assertion.repository_owner_id == '127256420'", + workflowHead: 'stablyai/orca-cloud/.github/workflows/' } -] +} // [root, provider, condition] for every provider the environment creates, across all roots. async function flatten(environment) { @@ -103,9 +104,7 @@ for (const environment of Object.keys(EXPECTED_CONDITIONS)) { test(`${environment} pins repository, branch, and environment on every provider`, async () => { for (const [root, provider, condition] of await flatten(environment)) { for (const pin of [ - "assertion.repository == 'stablyai/orca-cloud'", - "assertion.repository_id == '1273841466'", - "assertion.repository_owner_id == '127256420'", + ROOT_REPOSITORIES[root].claims, "assertion.ref == 'refs/heads/main'", `assertion.environment == '${environment}'` ]) { @@ -131,27 +130,23 @@ for (const environment of Object.keys(EXPECTED_CONDITIONS)) { }) } -// Why: the dual accept is only safe if each OR arm carries its own repository claims. An arm that -// inherited them, or a workflow ref that named the other repository, would let one repository's -// workflows run under the other's proof. +// Why: the cutover left one arm per relay provider. A leftover `stablyai/orca-cloud` claim or +// workflow ref would keep trusting a repository whose relay workflows are retired, and an unprefixed +// ref would name a file the public repository does not have. for (const environment of Object.keys(EXPECTED_CONDITIONS)) { - test(`${environment} admits both repositories through every relay provider`, async () => { + test(`${environment} admits only the public repository through every relay provider`, async () => { + const { claims, workflowHead } = ROOT_REPOSITORIES.relay const rendered = await renderAttributeConditions(environment) for (const [provider, condition] of Object.entries(rendered.relay)) { - assert.ok( - condition.startsWith("assertion.ref == 'refs/heads/main' && "), - `${provider} does not lead with the repository-independent claims` - ) + assert.ok(condition.startsWith(`${claims} && `), `${provider} does not lead with the claims`) + assert.doesNotMatch(condition, /stablyai\/orca-cloud|1273841466/, `${provider} keeps an old arm`) const refs = [...condition.matchAll(/(?:job_)?workflow_ref == '([^']+)'/g)].map( (match) => match[1] ) - const perRepository = ACCEPTED_REPOSITORIES.map((repository) => { - assert.ok(condition.includes(`(${repository.claims} && `), `${provider} misses an arm`) - return refs.filter((ref) => ref.startsWith(repository.workflowHead)).length - }) - assert.equal(refs.length, perRepository[0] + perRepository[1], `${provider} names a stray ref`) - assert.equal(perRepository[0], perRepository[1], `${provider} arms are not the same size`) - assert.ok(perRepository[0] > 0, `${provider} names no workflow`) + assert.ok(refs.length > 0, `${provider} names no workflow`) + for (const ref of refs) { + assert.ok(ref.startsWith(workflowHead), `${provider} names a stray ref ${ref}`) + } } }) } diff --git a/cloud/docs/relay-incident-monitor.md b/cloud/docs/relay-incident-monitor.md index 3e5fc04836e..870c95dd413 100644 --- a/cloud/docs/relay-incident-monitor.md +++ b/cloud/docs/relay-incident-monitor.md @@ -99,8 +99,8 @@ durably marked consumed before mutation and cannot authorize another run. | Cloud SQL deadlocks | over 0 | | Relay pool waiters | over 800 | | Relay pool wait | over 2,500 ms | -| PostgreSQL retries in five minutes | over 300 | -| Exhausted PostgreSQL retries | over 0 | +| PostgreSQL retries in five minutes | over 2,000 | +| Exhausted PostgreSQL retries in five minutes | over 300 | | Director instances | outside 5–6 | | Director CPU or memory | over 80% | | Director concurrency | over 64 | @@ -132,15 +132,41 @@ heartbeats, and matching live admission. 10 minutes over the old bar of 160 — enough to freeze roughly one in ten 15-minute pre-drain gates on baseline noise. 250 clears measured healthy peaks and still fires well before the verified 400-connection ceiling; - pool waiters, pool wait latency, and exhausted retries keep their strict - thresholds. + pool waiters and pool wait latency keep their strict thresholds. - Recalibrated the PostgreSQL-retry freeze from 20 to 300 per five minutes (2026-08-26). Basis, measured from `jsonPayload.event="orca_relay_postgres_transaction_retry"` in production logs: healthy-day bursts reach 234/5min with zero exhausted retries and 26% of five-minute windows over 20, while the 2026-08-23 lock-contention - incident ran roughly 2,200–3,000/5min. Exhausted retries stay at zero - tolerance. + incident ran roughly 2,200–3,000/5min by raw log-line count (the gate's + own `orca_relay_postgres_retries` metric read 1,510 for that window; see the + 2026-09-04 entry). +- Recalibrated the PostgreSQL-retry freeze from 300 to 2,000 per five minutes + (2026-09-04). Basis: the global `relay_cells FOR UPDATE` lock made + successful retries a steady-state rate. Measured fleet-wide (director + + cells, summed per five minutes from the `orca_relay_postgres_retries` + log metric) over 2026-09-03T05Z..2026-09-04T05Z: p50 430 / p90 924 / + p99 1,320 / max 1,504; 55% of windows over 300; only 22% of 15-minute gates + clean at 300 versus 100% at 2,000. Three read-only dry-runs on 2026-09-04 + froze on this bar (runs 33836470590, 33838698725) or on a genuine six-cell + crash storm (33837160275), blocking the same-cap roll that carries #18521 + and the `beginProof` crash guard to the 23 cells. The 2026-08-23 incident + on this metric peaked at 1,510 then 646, so retries alone no longer + separate it from today's baseline; the exhausted-retry bar (incident peak + 467 vs bar 300), director concurrency, and the pool bars carry that role. + Re-tighten after the fleet is on the 500 ms lock wait. +- Recalibrated the exhausted-PostgreSQL-retry freeze from 0 to 300 per five + minutes (2026-09-04). Basis: #18521 cut the request-path cell-inventory + lock wait from the 1 s pool `lock_timeout` to 500 ms, so contended waiters + now fail fast (one `/v1/assign` 503 with `Retry-After`) instead of + succeeding slowly, and `orca_relay_postgres_transaction_exhausted` became + a steady contention rate. Measured fleet-wide per five minutes over + 2026-09-03T03Z..2026-09-04T02Z: 236 of 236 windows non-zero; quiet hours + p50 2 / max 36; pre-#18521 daytime p50 10 / p90 25 / max 87; post-#18521 + p50 42 / p90 147 / max 220; the 2026-08-23 incident peaked at 467. Every + pre-drain dry-run since the director deploy froze at minute one on this + bar, which blocked the cell roll that carries the same fix to the 23 GCE + cells. `/v1/assign` 503 share was unchanged by #18521 (13.9% vs 12.3%). - Added a fail-closed state machine with latched threshold freezes, generation-scoped checkpoint boundaries, continuity-reset evidence, cadence accounting, restart-gap recovery, and the 15-minute pre-drain gate. diff --git a/cloud/infra/terraform/README.md b/cloud/infra/terraform/README.md index 2e2b9b02011..8aa7aa0a95c 100644 --- a/cloud/infra/terraform/README.md +++ b/cloud/infra/terraform/README.md @@ -37,35 +37,26 @@ identity (`google_service_account.github_deploy`, its provider, and its bindings with production-only counts; staging's copies are declared by `infra/terraform-apps`. An untargeted plan is orderable again; the `Plan:` line still reflects the standing cell-template drift backlog. -### Dual-accept Workload Identity during the public extraction +### Workload Identity trusts the public repository -While the relay source moves to the public `stablyai/orca` repository, every relay Workload -Identity provider accepts the same workflows from both repositories. `github_accepted_repositories` -lists the extra repositories; `relay-github-workflow-trust.tf` renders one parenthesised OR arm per -accepted repository, each arm carrying that repository's own `repository`, `repository_id`, and -`repository_owner_id` claims plus its exact workflow refs. `ref`, `environment`, and `event_name` -stay outside the OR. Workflow files keep their names in the private repo and take the -`workflow_file_prefix` (`cloud-`) in the public one. +The cutover closed on 2026-09-03. Every relay Workload Identity provider now accepts exactly one +repository, `stablyai/orca` (`1183888342`, owner `127256420`), and every workflow ref it names is +built from `github_workflow_file_prefix` (`cloud-`), which is the rename the public repo applies to +the workflow files it carries. `github_repo`, `github_repo_id`, and that prefix are set in both +`environments/*.tfvars` as well as defaulted here, and `github_accepted_repositories` is empty. +Nothing in this root trusts `stablyai/orca-cloud` any more; the apps and foundation roots still do, +because the app workflows still live there. -Adding a repository is a tfvars edit: no provider block changes, and the rendered strings are -pinned by `dev/scripts/workload-identity-attribute-conditions.test.mjs`. An empty list renders -byte-identically to the single-repository form, which is what makes the arms reviewable against -the pre-extraction condition. +`github_accepted_repositories` stays available for the next repository move. Each entry renders its +own parenthesised OR arm in `relay-github-workflow-trust.tf`, carrying that repository's own +`repository`, `repository_id`, and `repository_owner_id` claims plus its exact workflow refs, while +`ref`, `environment`, and `event_name` stay outside the OR. An empty list renders byte-identically +to the single-repository form, so adding and removing a repository is a tfvars edit with no provider +block change. The rendered strings are pinned by +`dev/scripts/workload-identity-attribute-conditions.test.mjs`. -Closing the cutover is an owner step, in this order: - -1. Retire the private workflows, so nothing runs from `stablyai/orca-cloud` any more. -2. Point `github_owner`, `github_repo`, `github_repo_id`, and `github_owner_id` at - `stablyai/orca` (`1183888342`, owner `127256420`), and set `workflow_file_prefix` for it by - moving the surviving entry's prefix onto the primary: the public files keep the `cloud-` names, - so the primary prefix becomes `cloud-` unless the files are renamed back. -3. Empty `github_accepted_repositories` in both `environments/*.tfvars`. -4. Re-render and update the pinned conditions, then apply. Each provider goes back to a single - arm, and `google_service_account_iam_member.github_accepted_repository_workload_identity_user` - is destroyed as the primary `attribute.repository` binding takes over. - -Step 2 and step 3 must land in the same apply: dropping the accepted entry before repointing the -primary would revoke the public repository mid-flight. +Repointing the primary and emptying the list must land in the same apply: dropping the accepted +entry before repointing the primary would revoke the surviving repository mid-flight. ### `ORCA_RELAY_IMAGE_DIGEST` is not Terraform-owned diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index 60d36e3d832..8e442c75900 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -5,19 +5,11 @@ region = "us-central1" artifact_repository_id = "orca-cloud" -# Dual accept while the relay source moves to the public stablyai/orca repository: the same -# workflows are trusted from both repos, and the public copies carry a `cloud-` file prefix. -# Remove this entry once the private workflows are retired and point github_owner/github_repo, -# github_repo_id, and github_owner_id at the surviving repository. -github_accepted_repositories = [ - { - owner = "stablyai" - repo = "orca" - repo_id = "1183888342" - owner_id = "127256420" - workflow_file_prefix = "cloud-" - } -] +# The relay source lives in the public stablyai/orca repository, where the workflows carry a +# `cloud-` file prefix. github_owner and github_owner_id keep their defaults. +github_repo = "orca" +github_repo_id = "1183888342" +github_workflow_file_prefix = "cloud-" # Our first-party auth service. auth.onorca.dev is PropelAuth's prod domain, so # our service lives at login.onorca.dev (desktop points ORCA_CLOUD_API_URL here). diff --git a/cloud/infra/terraform/environments/staging.tfvars b/cloud/infra/terraform/environments/staging.tfvars index 3ee108fe874..4a32458fcd5 100644 --- a/cloud/infra/terraform/environments/staging.tfvars +++ b/cloud/infra/terraform/environments/staging.tfvars @@ -5,19 +5,11 @@ region = "us-central1" artifact_repository_id = "orca-cloud" -# Dual accept while the relay source moves to the public stablyai/orca repository: the same -# workflows are trusted from both repos, and the public copies carry a `cloud-` file prefix. -# Remove this entry once the private workflows are retired and point github_owner/github_repo, -# github_repo_id, and github_owner_id at the surviving repository. -github_accepted_repositories = [ - { - owner = "stablyai" - repo = "orca" - repo_id = "1183888342" - owner_id = "127256420" - workflow_file_prefix = "cloud-" - } -] +# The relay source lives in the public stablyai/orca repository, where the workflows carry a +# `cloud-` file prefix. github_owner and github_owner_id keep their defaults. +github_repo = "orca" +github_repo_id = "1183888342" +github_workflow_file_prefix = "cloud-" auth_base_url = "https://auth-staging.onorca.dev" @@ -67,7 +59,7 @@ relay_gce_cells = { boot_disk_gb = 30 boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-7" capacity_requests = 4000 - image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:9fba2a189ab3fa29853800830e77c7551ab3aaa8f43f3cd9adbdea28b876a8b9" + image = "us-central1-docker.pkg.dev/onorca-cloud-staging/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563" initially_enabled = false connection_hard_cap = 1000 connection_unobserved_bound = 60 diff --git a/cloud/infra/terraform/relay-shared.tf b/cloud/infra/terraform/relay-shared.tf index d4f0e5dac15..b1afc4d1890 100644 --- a/cloud/infra/terraform/relay-shared.tf +++ b/cloud/infra/terraform/relay-shared.tf @@ -14,16 +14,16 @@ locals { "assertion.repository_owner_id == '${var.github_owner_id}'", ] - # Dual accept during the public extraction: the primary repository first, then every repository - # var.github_accepted_repositories adds. Each one renders its own OR arm in every provider - # condition, so both repos can run the same workflows through the same identities. A repository - # that imports these workflows may rename the files, hence the per-repository prefix. + # The primary repository first, then every repository var.github_accepted_repositories adds. + # Each one renders its own OR arm in every provider condition, so a repository move can trust + # both repos at once. A repository that imports these workflows may rename the files, hence the + # per-repository prefix; the primary's is var.github_workflow_file_prefix. relay_github_accepted_repositories = concat([{ owner = var.github_owner repo = var.github_repo repo_id = var.github_repo_id owner_id = var.github_owner_id - workflow_file_prefix = "" + workflow_file_prefix = var.github_workflow_file_prefix }], var.github_accepted_repositories) relay_github_single_repository = length(local.relay_github_accepted_repositories) == 1 diff --git a/cloud/infra/terraform/variables.tf b/cloud/infra/terraform/variables.tf index c3e06ee280f..68f6c555bd3 100644 --- a/cloud/infra/terraform/variables.tf +++ b/cloud/infra/terraform/variables.tf @@ -22,14 +22,14 @@ variable "github_owner" { variable "github_repo" { type = string description = "GitHub repo allowed to deploy through Workload Identity Federation." - default = "orca-cloud" + default = "orca" } # Numeric IDs survive a rename or transfer of the repository; every provider pins them next to the name. variable "github_repo_id" { type = string description = "Numeric GitHub repository ID of github_owner/github_repo." - default = "1273841466" + default = "1183888342" validation { condition = can(regex("^[0-9]+$", var.github_repo_id)) @@ -48,12 +48,24 @@ variable "github_owner_id" { } } -# Additional repositories whose identical workflows the same identities must accept while the -# public extraction runs. Each entry renders its own OR arm in every provider condition, so the -# private repo keeps working while the public one takes over. `workflow_file_prefix` is the rename -# the importing repository applies to the workflow files it copies. Empty is the steady state: -# the final step of the cutover is to empty this list again and point github_owner/github_repo, -# github_repo_id, and github_owner_id at the surviving repository. +# The rename the relay repository applies to the workflow files it carries. The public repo keeps +# the workflows under `cloud-` names, so every relay workflow_ref is built from this head. +variable "github_workflow_file_prefix" { + type = string + description = "Filename prefix on github_owner/github_repo's copies of the relay workflows." + default = "cloud-" + + validation { + condition = can(regex("^[a-z0-9-]*$", var.github_workflow_file_prefix)) + error_message = "github_workflow_file_prefix must be lowercase letters, digits, or hyphens." + } +} + +# Additional repositories whose identical workflows the same identities must accept during a +# repository move. Each entry renders its own OR arm in every provider condition, so both repos +# can run the same workflows through the same identities. `workflow_file_prefix` is the rename the +# importing repository applies to the workflow files it copies. Empty is the steady state, and is +# where the public extraction left it: stablyai/orca is now the primary and only repository. variable "github_accepted_repositories" { type = list(object({ owner = string diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 06d41bad344..ebf4d275678 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -90,7 +90,19 @@ const bundledPluginResources = { // from package directories where pnpm's symlink farm is absent. Copy the exact // runtime dependency closure to Resources/node_modules so bare require() calls // do not fall through to a developer checkout's node_modules. -const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources] +// Why the single file rather than the package root: app.asar carries no node_modules, so main's +// lazy require in deferred-emoji-shortcode-dataset.ts resolves only out of Resources/node_modules, +// but emojibase-data is 49 MB of locale datasets and worktree naming reads exactly this 166 KB file. +const emojiShortcodeDatasetResource = { + from: 'node_modules/emojibase-data/en/shortcodes/emojibase.json', + to: 'node_modules/emojibase-data/en/shortcodes/emojibase.json' +} +const commonExtraResources = [ + relayExtraResource, + bundledPluginResources, + skillFreshnessResources, + emojiShortcodeDatasetResource +] // Why: native speech addons must be real files outside app.asar; copy only the // package matching the artifact target instead of every optional variant. const macSpeechNativeResource = { diff --git a/config/i18next.config.ts b/config/i18next.config.ts index 577375bd2e9..87e302ac213 100644 --- a/config/i18next.config.ts +++ b/config/i18next.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ ], output, defaultNS: false, - functions: ['t', '*.t', 'translate', 'translateMain'], + functions: ['t', '*.t', 'translate', 'translateMain', 'translateSearchKeyword'], useTranslationNames: ['useTranslation'], sort: true, disablePlurals: true, diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 57694b2033f..a10139d218f 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -55,6 +55,13 @@ "dynamic": false, "count": 1 }, + { + "filePath": "src/renderer/src/components/settings/appearance-search.ts", + "kind": "object-property:keywords", + "text": "Langue", + "dynamic": false, + "count": 1 + }, { "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", "kind": "object-property:keywords", diff --git a/config/patches/@xterm__addon-search@0.17.0-beta.300.patch b/config/patches/@xterm__addon-search@0.17.0-beta.300.patch new file mode 100644 index 00000000000..c2843ec6b3d --- /dev/null +++ b/config/patches/@xterm__addon-search@0.17.0-beta.300.patch @@ -0,0 +1,275 @@ +diff --git a/lib/addon-search.js b/lib/addon-search.js +index d939cf1a65f3de449059efbf4fc5c3c3515f533f..8d2b66d265c256d4ebd507624d6a29b8075929ce 100644 +--- a/lib/addon-search.js ++++ b/lib/addon-search.js +@@ -1,2 +1,2 @@ +-!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SearchAddon=t():e.SearchAddon=t()}(globalThis,()=>(()=>{"use strict";var e={578(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.IntervalTimer=t.MicrotaskTimer=t.TimeoutTimer=void 0,t.timeout=function(e){return new Promise(t=>setTimeout(t,e))},t.disposableTimeout=function(e,t=0,s){const r=setTimeout(()=>{e(),s&&n.dispose()},t),n=(0,i.toDisposable)(()=>{clearTimeout(r)});return s?.add(n),n};const i=s(426);t.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},t.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},t.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,s=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const i=s.setInterval(()=>{e()},t);this._disposable={dispose:()=>{s.clearInterval(i),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},414(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.EventUtils=t.Emitter=void 0;const i=s(426);var r;t.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(e,t,s)=>{if(this._disposed)return(0,i.toDisposable)(()=>{});const r={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(r);const n=(0,i.toDisposable)(()=>{const e=this._listeners.indexOf(r);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return s&&(Array.isArray(s)?s.push(n):s.add(n)),n}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let s=0,i=t.length;st.fire(e))},e.map=function(e,t){return(s,i,r)=>e(e=>s.call(i,t(e)),void 0,r)},e.any=function(...e){return(t,s,r)=>{const n=new i.DisposableStore;for(const i of e)n.add(i(e=>t.call(s,e)));return r&&(Array.isArray(r)?r.push(n):r.add(n)),n}},e.runAndSubscribe=function(e,t,s){return t(s),e(e=>t(e))}}(r||(t.EventUtils=r={}))},426(e,t){function s(e){return{dispose:e}}function i(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=s,t.dispose=i,t.combinedDisposable=function(...e){return s(()=>i(e))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=r;class n{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=n,n.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},864(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationManager=void 0;const i=s(426);class r extends i.Disposable{constructor(e){super(),this._terminal=e,this._highlightDecorations=[],this._highlightedLines=new Set,this._register((0,i.toDisposable)(()=>this.clearHighlightDecorations()))}createHighlightDecorations(e,t){this.clearHighlightDecorations();for(const s of e){const e=this._createResultDecorations(s,t,!1);if(e)for(const t of e)this._storeDecoration(t,s)}}createActiveDecoration(e,t){const s=this._createResultDecorations(e,t,!0);if(s)return{decorations:s,match:e,dispose(){(0,i.dispose)(s)}}}clearHighlightDecorations(){(0,i.dispose)(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear()}_storeDecoration(e,t){this._highlightedLines.add(e.marker.line),this._highlightDecorations.push({decoration:e,match:t,dispose(){e.dispose()}})}_applyStyles(e,t,s){e.classList.contains("xterm-find-result-decoration")||(e.classList.add("xterm-find-result-decoration"),t&&(e.style.outline=`1px solid ${t}`)),s&&e.classList.add("xterm-find-active-result-decoration")}_createResultDecorations(e,t,s){const r=[];let n=e.col,o=e.size,a=-this._terminal.buffer.active.baseY-this._terminal.buffer.active.cursorY+e.row;for(;o>0;){const e=Math.min(this._terminal.cols-n,o);r.push([a,n,e]),n=0,o-=e,a++}const h=[];for(const e of r){const r=this._terminal.registerMarker(e[0]),n=this._terminal.registerDecoration({marker:r,x:e[1],width:e[2],layer:s?"top":"bottom",backgroundColor:s?t.activeMatchBackground:t.matchBackground,overviewRulerOptions:this._highlightedLines.has(r.line)?void 0:{color:s?t.activeMatchColorOverviewRuler:t.matchOverviewRuler,position:"center"}});if(n){const e=[];e.push(r),e.push(n.onRender(e=>this._applyStyles(e,s?t.activeMatchBorder:t.matchBorder,!1))),e.push(n.onDispose(()=>(0,i.dispose)(e))),h.push(n)}}return 0===h.length?void 0:h}}t.DecorationManager=r},615(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.SearchEngine=void 0,t.SearchEngine=class{constructor(e,t){this._terminal=e,this._lineCache=t}find(e,t,s,i){if(!e||0===e.length)return void this._terminal.clearSelection();if(s>=this._terminal.cols)throw new Error(`Invalid col: ${s} to search in terminal of ${this._terminal.cols} cols`);this._lineCache.initLinesCache();const r={startRow:t,startCol:s};let n=this._findInLine(e,r,i);if(!n)for(let s=t+1;s=0&&(a.startRow=s,h=this._findInLine(e,a,t,o),!h);s--);}if(!h&&r!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let s=this._terminal.buffer.active.baseY+this._terminal.rows-1;s>=r&&(a.startRow=s,h=this._findInLine(e,a,t,o),!h);s--);return h}_isWholeWord(e,t,s){return(0===e||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(t[e-1]))&&(e+s.length===t.length||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(t[e+s.length]))}_findInLine(e,t,s={},i=!1){const r=t.startRow,n=t.startCol,o=this._terminal.buffer.active.getLine(r);if(o?.isWrapped)return i?void(t.startCol+=this._terminal.cols):(t.startRow--,t.startCol+=this._terminal.cols,this._findInLine(e,t,s));let a=this._lineCache.getLineFromCache(r);a||(a=this._lineCache.translateBufferLineToStringWithWrap(r,!0),this._lineCache.setLineInCache(r,a));const[h,l]=a,c=this._bufferColsToStringOffset(r,n);let d=e,_=h;s.regex||(d=s.caseSensitive?e:e.toLowerCase(),_=s.caseSensitive?h:h.toLowerCase());let u=-1;if(s.regex){const t=RegExp(d,s.caseSensitive?"g":"gi");let r;if(i)for(;r=t.exec(_.slice(0,c));)u=t.lastIndex-r[0].length,e=r[0],t.lastIndex-=e.length-1;else r=t.exec(_.slice(c)),r&&r[0].length>0&&(u=c+(t.lastIndex-r[0].length),e=r[0])}else i?c-d.length>=0&&(u=_.lastIndexOf(d,c-d.length)):u=_.indexOf(d,c);if(u>=0){if(s.wholeWord&&!this._isWholeWord(u,_,e))return;let t=0;for(;t=l[t+1];)t++;let i=t;for(;i=l[i+1];)i++;const n=u-l[t],o=u+e.length-l[i],a=this._stringLengthToBufferSize(r+t,n);return{term:e,col:a,row:r+t,size:this._stringLengthToBufferSize(r+i,o)-a+this._terminal.cols*(i-t)}}}_stringLengthToBufferSize(e,t){const s=this._terminal.buffer.active.getLine(e);if(!s)return 0;for(let e=0;e1&&(t-=r.length-1);const n=s.getCell(e+1);n&&0===n.getWidth()&&t++}return t}_bufferColsToStringOffset(e,t){let s=e,i=0,r=this._terminal.buffer.active.getLine(s);for(;t>0&&r;){for(let e=0;ethis._destroyLinesCache()))}initLinesCache(){this._linesCache||(this._linesCache=new Array(this._terminal.buffer.active.length),this._linesCacheDisposables.value=(0,i.combinedDisposable)(this._terminal.onLineFeed(()=>this._destroyLinesCache()),this._terminal.onCursorMove(()=>this._destroyLinesCache()),this._terminal.onResize(()=>this._destroyLinesCache()))),this._lastAccessTimestamp=Date.now(),this._linesCacheTimeout.value||this._scheduleLinesCacheTimeout(15e3)}_destroyLinesCache(){this._linesCache=void 0,this._lastAccessTimestamp=0,this._linesCacheDisposables.clear(),this._linesCacheTimeout.clear()}_scheduleLinesCacheTimeout(e){this._linesCacheTimeout.value=(0,r.disposableTimeout)(()=>{if(!this._linesCache)return;const e=Date.now()-this._lastAccessTimestamp;e>=15e3?this._destroyLinesCache():this._scheduleLinesCacheTimeout(15e3-e)},e)}getLineFromCache(e){return this._linesCache?.[e]}setLineInCache(e,t){this._linesCache&&(this._linesCache[e]=t)}translateBufferLineToStringWithWrap(e,t){const s=[],i=[0];let r=this._terminal.buffer.active.getLine(e);for(;r;){const n=this._terminal.buffer.active.getLine(e+1),o=!!n&&n.isWrapped;let a=r.translateToString(!o&&t);if(o&&n){const e=r.getCell(r.length-1);e&&0===e.getCode()&&1===e.getWidth()&&2===n.getCell(0)?.getWidth()&&(a=a.slice(0,-1))}if(s.push(a),!o)break;i.push(i[i.length-1]+a.length),e++,r=n}return[s.join(""),i]}}t.SearchLineCache=n},438(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.SearchResultTracker=void 0;const i=s(414),r=s(426);class n extends r.Disposable{constructor(){super(...arguments),this._searchResults=[],this._onDidChangeResults=this._register(new i.Emitter)}get onDidChangeResults(){return this._onDidChangeResults.event}get searchResults(){return this._searchResults}get selectedDecoration(){return this._selectedDecoration}set selectedDecoration(e){this._selectedDecoration=e}updateResults(e,t){this._searchResults=e.slice(0,t)}clearResults(){this._searchResults=[]}clearSelectedDecoration(){this._selectedDecoration&&(this._selectedDecoration.dispose(),this._selectedDecoration=void 0)}findResultIndex(e){for(let t=0;t0)}didOptionsChange(e){return!this._lastSearchOptions||!!e&&(this._lastSearchOptions.caseSensitive!==e.caseSensitive||this._lastSearchOptions.regex!==e.regex||this._lastSearchOptions.wholeWord!==e.wholeWord)}shouldUpdateHighlighting(e,t){return!!t?.decorations&&(void 0===this._cachedSearchTerm||e!==this._cachedSearchTerm||this.didOptionsChange(t))}clearCachedTerm(){this._cachedSearchTerm=void 0}reset(){this._cachedSearchTerm=void 0,this._lastSearchOptions=void 0}}}},t={};function s(i){var r=t[i];if(void 0!==r)return r.exports;var n=t[i]={exports:{}};return e[i](n,n.exports,s),n.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.SearchAddon=void 0;const t=s(414),r=s(426),n=s(578),o=s(149),a=s(772),h=s(615),l=s(864),c=s(438);class d extends r.Disposable{get onDidChangeResults(){return this._resultTracker.onDidChangeResults}constructor(e){super(),this._highlightTimeout=this._register(new r.MutableDisposable),this._lineCache=this._register(new r.MutableDisposable),this._state=new a.SearchState,this._resultTracker=this._register(new c.SearchResultTracker),this._onAfterSearch=this._register(new t.Emitter),this.onAfterSearch=this._onAfterSearch.event,this._onBeforeSearch=this._register(new t.Emitter),this.onBeforeSearch=this._onBeforeSearch.event,this._highlightLimit=e?.highlightLimit??1e3}activate(e){this._terminal=e,this._lineCache.value=new o.SearchLineCache(e),this._engine=new h.SearchEngine(e,this._lineCache.value),this._decorationManager=new l.DecorationManager(e),this._register(this._terminal.onWriteParsed(()=>this._updateMatches())),this._register(this._terminal.onResize(()=>this._updateMatches())),this._register((0,r.toDisposable)(()=>this.clearDecorations()))}_updateMatches(){this._highlightTimeout.clear(),this._state.cachedSearchTerm&&this._state.lastSearchOptions?.decorations&&(this._highlightTimeout.value=(0,n.disposableTimeout)(()=>{const e=this._state.cachedSearchTerm;this._state.clearCachedTerm(),this.findPrevious(e,{...this._state.lastSearchOptions,incremental:!0},{noScroll:!0})},200))}clearDecorations(e){this._resultTracker.clearSelectedDecoration(),this._decorationManager?.clearHighlightDecorations(),this._resultTracker.clearResults(),e||this._state.clearCachedTerm()}clearActiveDecoration(){this._resultTracker.clearSelectedDecoration()}findNext(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);const i=this._findNextAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),i}_highlightAllMatches(e,t){if(!this._terminal||!this._engine||!this._decorationManager)throw new Error("Cannot use addon until it has been loaded");if(!this._state.isValidSearchTerm(e))return void this.clearDecorations();this.clearDecorations(!0);const s=[];let i,r=this._engine.find(e,0,0,t);for(;r&&(i?.row!==r.row||i?.col!==r.col)&&!(s.length>=this._highlightLimit);){i=r,s.push(i);const n=this._terminal.cols;let o=i.col+i.size,a=i.row;o>=n&&(a+=Math.floor(o/n),o%=n),r=this._engine.find(e,a,o,t)}this._resultTracker.updateResults(s,this._highlightLimit),t.decorations&&this._decorationManager.createHighlightDecorations(s,t.decorations)}_findNextAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;const i=this._engine.findNextWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(i,t?.decorations,s?.noScroll)}findPrevious(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);const i=this._findPreviousAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),i}_fireResults(e){this._resultTracker.fireResultsChanged(!!e?.decorations)}_findPreviousAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;const i=this._engine.findPreviousWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(i,t?.decorations,s?.noScroll)}_selectResult(e,t,s){if(!this._terminal||!this._decorationManager)return!1;if(this._resultTracker.clearSelectedDecoration(),!e)return this._terminal.clearSelection(),!1;if(this._terminal.select(e.col,e.row,e.size),t){const s=this._decorationManager.createActiveDecoration(e,t);s&&(this._resultTracker.selectedDecoration=s)}if(!s&&(e.row>=this._terminal.buffer.active.viewportY+this._terminal.rows||e.row(()=>{"use strict";var e={578(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.IntervalTimer=t.MicrotaskTimer=t.TimeoutTimer=void 0,t.timeout=function(e){return new Promise(t=>setTimeout(t,e))},t.disposableTimeout=function(e,t=0,s){const r=setTimeout(()=>{e(),s&&o.dispose()},t),o=(0,i.toDisposable)(()=>{clearTimeout(r)});return s?.add(o),o};const i=s(426);t.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},t.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},t.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,s=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const i=s.setInterval(()=>{e()},t);this._disposable={dispose:()=>{s.clearInterval(i),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},414(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.EventUtils=t.Emitter=void 0;const i=s(426);var r;t.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(e,t,s)=>{if(this._disposed)return(0,i.toDisposable)(()=>{});const r={fn:e,thisArgs:t};this._listeners=this._listeners.slice(),this._listeners.push(r);const o=(0,i.toDisposable)(()=>{const e=this._listeners.indexOf(r);-1!==e&&(this._listeners=this._listeners.slice(),this._listeners.splice(e,1))});return s&&(Array.isArray(s)?s.push(o):s.add(o)),o}),this._event}fire(e){if(this._disposed||!this._listeners.length)return;if(1===this._listeners.length)return void this._listeners[0].fn.call(this._listeners[0].thisArgs,e);const t=this._listeners;for(let s=0,i=t.length;st.fire(e))},e.map=function(e,t){return(s,i,r)=>e(e=>s.call(i,t(e)),void 0,r)},e.any=function(...e){return(t,s,r)=>{const o=new i.DisposableStore;for(const i of e)o.add(i(e=>t.call(s,e)));return r&&(Array.isArray(r)?r.push(o):r.add(o)),o}},e.runAndSubscribe=function(e,t,s){return t(s),e(e=>t(e))}}(r||(t.EventUtils=r={}))},426(e,t){function s(e){return{dispose:e}}function i(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=s,t.dispose=i,t.combinedDisposable=function(...e){return s(()=>i(e))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=r;class o{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=o,o.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},864(e,t,s){Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationManager=void 0;const i=s(426);class r extends i.Disposable{constructor(e){super(),this._terminal=e,this._highlightDecorations=[],this._highlightedLines=new Set,this._register((0,i.toDisposable)(()=>this.clearHighlightDecorations()))}createHighlightDecorations(e,t){this.clearHighlightDecorations();for(const s of e){const e=this._createResultDecorations(s,t,!1);if(e)for(const t of e)this._storeDecoration(t,s)}}createActiveDecoration(e,t){const s=this._createResultDecorations(e,t,!0);if(s)return{decorations:s,match:e,dispose(){(0,i.dispose)(s)}}}clearHighlightDecorations(){(0,i.dispose)(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear()}_storeDecoration(e,t){this._highlightedLines.add(e.marker.line),this._highlightDecorations.push({decoration:e,match:t,dispose(){e.dispose()}})}_applyStyles(e,t,s){e.classList.contains("xterm-find-result-decoration")||(e.classList.add("xterm-find-result-decoration"),t&&(e.style.outline=`1px solid ${t}`)),s&&e.classList.add("xterm-find-active-result-decoration")}_createResultDecorations(e,t,s){const r=[];let o=e.col,n=e.size,a=-this._terminal.buffer.active.baseY-this._terminal.buffer.active.cursorY+e.row;for(;n>0;){const e=Math.min(this._terminal.cols-o,n);r.push([a,o,e]),o=0,n-=e,a++}const h=[];for(const e of r){const r=this._terminal.registerMarker(e[0]),o=this._terminal.registerDecoration({marker:r,x:e[1],width:e[2],layer:s?"top":"bottom",backgroundColor:s?t.activeMatchBackground:t.matchBackground,overviewRulerOptions:this._highlightedLines.has(r.line)?void 0:{color:s?t.activeMatchColorOverviewRuler:t.matchOverviewRuler,position:"center"}});if(o){const e=[];e.push(r),e.push(o.onRender(e=>this._applyStyles(e,s?t.activeMatchBorder:t.matchBorder,!1))),e.push(o.onDispose(()=>(0,i.dispose)(e))),h.push(o)}}return 0===h.length?void 0:h}}t.DecorationManager=r},615(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.SearchEngine=void 0,t.SearchEngine=class{constructor(e,t){this._terminal=e,this._lineCache=t}find(e,t,s,i){if(!e||0===e.length)return void this._terminal.clearSelection();if(s>=this._terminal.cols)throw new Error(`Invalid col: ${s} to search in terminal of ${this._terminal.cols} cols`);this._lineCache.initLinesCache();const r={startRow:t,startCol:s};let o=this._findInLine(e,r,i);if(!o)for(let s=t+1;s0&&this._isRowCoveredByEarlierSearch(s)||(n.startRow=s,n.startCol=0,a=this._findInLine(e,n,t),!a));s++);return!a&&i&&(n.startRow=i.start.y,n.startCol=0,a=this._findInLine(e,n,t)),a}findPreviousWithSelection(e,t,s){if(!e||0===e.length)return void this._terminal.clearSelection();const i=this._terminal.getSelectionPosition();this._terminal.clearSelection();let r=this._terminal.buffer.active.baseY+this._terminal.rows-1;const o=this._terminal.cols,n=!0;this._lineCache.initLinesCache();const a={startRow:r,startCol:o};let h;if(i&&(a.startRow=r=i.start.y,a.startCol=i.start.x,s!==e&&(h=this._findInLine(e,a,t,!1),h||(a.startRow=r=i.end.y,a.startCol=i.end.x))),h??=this._findInLine(e,a,t,n),!h){a.startCol=Math.max(a.startCol,this._terminal.cols);for(let s=r-1;s>=0&&(a.startRow=s,h=this._findInLine(e,a,t,n),!h);s--);}if(!h&&r!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let s=this._terminal.buffer.active.baseY+this._terminal.rows-1;s>=r&&(a.startRow=s,h=this._findInLine(e,a,t,n),!h);s--);return h}_isWholeWord(e,t,s){return(0===e||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(t[e-1]))&&(e+s.length===t.length||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(t[e+s.length]))}_satisfiesWholeWord(e,t,s,i){return!i.wholeWord||this._isWholeWord(e,t,s)}_isRowCoveredByEarlierSearch(e){return!0===this._terminal.buffer.active.getLine(e)?.isWrapped}_findInLine(e,t,s={},i=!1){if(i){if(t.startRow>0&&this._terminal.buffer.active.getLine(t.startRow)?.isWrapped)return void(t.startCol+=this._terminal.cols)}else for(;t.startRow>0&&this._terminal.buffer.active.getLine(t.startRow)?.isWrapped;)t.startRow--,t.startCol+=this._terminal.cols;const r=t.startRow,o=t.startCol;let n=this._lineCache.getLineFromCache(r);n||(n=this._lineCache.translateBufferLineToStringWithWrap(r,!0),this._lineCache.setLineInCache(r,n));const[a,h]=n,l=this._bufferColsToStringOffset(r,o,h);let c=e,d=a;s.regex||(c=s.caseSensitive?e:e.toLowerCase(),d=s.caseSensitive?a:a.toLowerCase());let _=-1;if(s.regex){const t=RegExp(c,s.caseSensitive?"g":"gi");let r;if(i)for(;r=t.exec(d.slice(0,l));){const i=t.lastIndex-r[0].length;r[0].length>0&&this._satisfiesWholeWord(i,d,r[0],s)&&(_=i,e=r[0]),t.lastIndex=i+1}else for(t.lastIndex=l;r=t.exec(d);){const i=t.lastIndex-r[0].length;if(r[0].length>0&&this._satisfiesWholeWord(i,d,r[0],s)){_=i,e=r[0];break}t.lastIndex=i+1}}else if(i){let e=l-c.length>=0?d.lastIndexOf(c,l-c.length):-1;for(;e>=0&&!this._satisfiesWholeWord(e,d,c,s);)e=e>0?d.lastIndexOf(c,e-1):-1;_=e}else{let e=d.indexOf(c,l);for(;e>=0&&!this._satisfiesWholeWord(e,d,c,s);)e=d.indexOf(c,e+1);_=e}if(_>=0){let t=0;for(;t=h[t+1];)t++;let s=t;for(;s=h[s+1];)s++;const i=_-h[t],o=_+e.length-h[s],n=this._stringLengthToBufferSize(r+t,i);return{term:e,col:n,row:r+t,size:this._stringLengthToBufferSize(r+s,o)-n+this._terminal.cols*(s-t)}}}_stringLengthToBufferSize(e,t){const s=this._terminal.buffer.active.getLine(e);if(!s)return 0;for(let e=0;e1&&(t-=r.length-1);const o=s.getCell(e+1);o&&0===o.getWidth()&&t++}return t}_bufferColsToStringOffset(e,t,s){const i=Math.min(Math.floor(t/this._terminal.cols),s.length-1);let r=s[i];const o=this._terminal.buffer.active.getLine(e+i);if(o){const e=Math.min(t-i*this._terminal.cols,this._terminal.cols);for(let t=0;tthis._destroyLinesCache()))}initLinesCache(){this._linesCache||(this._linesCache=new Array(this._terminal.buffer.active.length),this._linesCacheDisposables.value=(0,i.combinedDisposable)(this._terminal.onLineFeed(()=>this._destroyLinesCache()),this._terminal.onCursorMove(()=>this._destroyLinesCache()),this._terminal.onResize(()=>this._destroyLinesCache()))),this._lastAccessTimestamp=Date.now(),this._linesCacheTimeout.value||this._scheduleLinesCacheTimeout(15e3)}_destroyLinesCache(){this._linesCache=void 0,this._lastAccessTimestamp=0,this._linesCacheDisposables.clear(),this._linesCacheTimeout.clear()}_scheduleLinesCacheTimeout(e){this._linesCacheTimeout.value=(0,r.disposableTimeout)(()=>{if(!this._linesCache)return;const e=Date.now()-this._lastAccessTimestamp;e>=15e3?this._destroyLinesCache():this._scheduleLinesCacheTimeout(15e3-e)},e)}getLineFromCache(e){return this._linesCache?.[e]}setLineInCache(e,t){this._linesCache&&(this._linesCache[e]=t)}translateBufferLineToStringWithWrap(e,t){const s=[],i=[0],r=this._terminal.buffer.active.length;let o=this._terminal.buffer.active.getLine(e);for(;o;){const n=e+10)}didOptionsChange(e){return!this._lastSearchOptions||!!e&&(this._lastSearchOptions.caseSensitive!==e.caseSensitive||this._lastSearchOptions.regex!==e.regex||this._lastSearchOptions.wholeWord!==e.wholeWord)}shouldUpdateHighlighting(e,t){return!!t?.decorations&&(void 0===this._cachedSearchTerm||e!==this._cachedSearchTerm||this.didOptionsChange(t))}clearCachedTerm(){this._cachedSearchTerm=void 0}reset(){this._cachedSearchTerm=void 0,this._lastSearchOptions=void 0}}}},t={};function s(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i](o,o.exports,s),o.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.SearchAddon=void 0;const t=s(414),r=s(426),o=s(578),n=s(149),a=s(772),h=s(615),l=s(864),c=s(438);class d extends r.Disposable{get onDidChangeResults(){return this._resultTracker.onDidChangeResults}constructor(e){super(),this._highlightTimeout=this._register(new r.MutableDisposable),this._lineCache=this._register(new r.MutableDisposable),this._state=new a.SearchState,this._resultTracker=this._register(new c.SearchResultTracker),this._onAfterSearch=this._register(new t.Emitter),this.onAfterSearch=this._onAfterSearch.event,this._onBeforeSearch=this._register(new t.Emitter),this.onBeforeSearch=this._onBeforeSearch.event,this._highlightLimit=e?.highlightLimit??1e3}activate(e){this._terminal=e,this._lineCache.value=new n.SearchLineCache(e),this._engine=new h.SearchEngine(e,this._lineCache.value),this._decorationManager=new l.DecorationManager(e),this._register(this._terminal.onWriteParsed(()=>this._updateMatches())),this._register(this._terminal.onResize(()=>this._updateMatches())),this._register((0,r.toDisposable)(()=>this.clearDecorations()))}_updateMatches(){this._highlightTimeout.clear(),this._state.cachedSearchTerm&&this._state.lastSearchOptions?.decorations&&(this._highlightTimeout.value=(0,o.disposableTimeout)(()=>{const e=this._state.cachedSearchTerm;this._state.clearCachedTerm(),this.findPrevious(e,{...this._state.lastSearchOptions,incremental:!0},{noScroll:!0})},200))}clearDecorations(e){this._resultTracker.clearSelectedDecoration(),this._decorationManager?.clearHighlightDecorations(),this._resultTracker.clearResults(),e||this._state.clearCachedTerm()}clearActiveDecoration(){this._resultTracker.clearSelectedDecoration()}findNext(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);const i=this._findNextAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),i}_highlightAllMatches(e,t){if(!this._terminal||!this._engine||!this._decorationManager)throw new Error("Cannot use addon until it has been loaded");if(!this._state.isValidSearchTerm(e))return void this.clearDecorations();this.clearDecorations(!0);const s=[];let i,r=this._engine.find(e,0,0,t);for(;r&&(i?.row!==r.row||i?.col!==r.col)&&!(s.length>=this._highlightLimit);){i=r,s.push(i);const o=this._terminal.cols;let n=i.col+i.size,a=i.row;n>=o&&(a+=Math.floor(n/o),n%=o),r=this._engine.find(e,a,n,t)}this._resultTracker.updateResults(s,this._highlightLimit),t.decorations&&this._decorationManager.createHighlightDecorations(s,t.decorations)}_findNextAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;const i=this._engine.findNextWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(i,t?.decorations,s?.noScroll)}findPrevious(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);const i=this._findPreviousAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),i}_fireResults(e){this._resultTracker.fireResultsChanged(!!e?.decorations)}_findPreviousAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;const i=this._engine.findPreviousWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(i,t?.decorations,s?.noScroll)}_selectResult(e,t,s){if(!this._terminal||!this._decorationManager)return!1;if(this._resultTracker.clearSelectedDecoration(),!e)return this._terminal.clearSelection(),!1;if(this._terminal.select(e.col,e.row,e.size),t){const s=this._decorationManager.createActiveDecoration(e,t);s&&(this._resultTracker.selectedDecoration=s)}if(!s&&(e.row>=this._terminal.buffer.active.viewportY+this._terminal.rows||e.row {\nreturn ","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal async helpers for xterm.js core.\n */\n\nimport { DisposableStore, IDisposable, toDisposable } from './Lifecycle';\n\nexport function timeout(millis: number): Promise {\n return new Promise(resolve => setTimeout(resolve, millis));\n}\n\n/**\n * Creates a timeout that can be disposed using its returned value.\n * @param handler The timeout handler.\n * @param timeout An optional timeout in milliseconds.\n * @param store An optional {@link DisposableStore} that will have the timeout disposable managed\n * automatically.\n */\nexport function disposableTimeout(handler: () => void, timeout = 0, store?: DisposableStore): IDisposable {\n const timer = setTimeout(() => {\n handler();\n if (store) {\n disposable.dispose();\n }\n }, timeout);\n const disposable = toDisposable(() => {\n clearTimeout(timer);\n });\n store?.add(disposable);\n return disposable;\n}\n\nexport class TimeoutTimer implements IDisposable {\n private _token: any = -1;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n if (this._token !== -1) {\n clearTimeout(this._token);\n this._token = -1;\n }\n }\n\n public cancelAndSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed TimeoutTimer');\n }\n this.cancel();\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n\n public setIfNotSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling setIfNotSet on a disposed TimeoutTimer');\n }\n if (this._token !== -1) {\n return;\n }\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n}\n\n/**\n * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled\n * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not\n * run yet.\n */\nexport class MicrotaskTimer implements IDisposable {\n private _isScheduled = false;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n this._isScheduled = false;\n }\n\n public set(runner: () => void): void {\n if (this._isDisposed) {\n throw new Error('Calling set on a disposed MicrotaskTimer');\n }\n if (this._isScheduled) {\n return;\n }\n this._isScheduled = true;\n queueMicrotask(() => {\n if (!this._isScheduled) {\n return;\n }\n this._isScheduled = false;\n runner();\n });\n }\n}\n\nexport class IntervalTimer implements IDisposable {\n private _disposable: IDisposable | undefined;\n private _isDisposed = false;\n\n public cancel(): void {\n this._disposable?.dispose();\n this._disposable = undefined;\n }\n\n public cancelAndSet(runner: () => void, interval: number, context: Window | typeof globalThis = globalThis): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed IntervalTimer');\n }\n this.cancel();\n const handle = context.setInterval(() => {\n runner();\n }, interval);\n this._disposable = {\n dispose: () => {\n context.clearInterval(handle as any);\n this._disposable = undefined;\n }\n };\n }\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';\nimport type { ISearchDecorationOptions } from '@xterm/addon-search';\nimport { dispose, Disposable, toDisposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a highlight decoration.\n */\ninterface IHighlight extends IDisposable {\n decoration: IDecoration;\n match: ISearchResult;\n}\n\n/**\n * Interface for managing multiple decorations for a single match.\n */\ninterface IMultiHighlight extends IDisposable {\n decorations: IDecoration[];\n match: ISearchResult;\n}\n\n/**\n * Manages visual decorations for search results including highlighting and active selection\n * indicators. This class handles the creation, styling, and disposal of search-related decorations.\n */\nexport class DecorationManager extends Disposable {\n private _highlightDecorations: IHighlight[] = [];\n private _highlightedLines: Set = new Set();\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this.clearHighlightDecorations()));\n }\n\n /**\n * Creates decorations for all provided search results.\n * @param results The search results to create decorations for.\n * @param options The decoration options.\n */\n public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {\n this.clearHighlightDecorations();\n\n for (const match of results) {\n const decorations = this._createResultDecorations(match, options, false);\n if (decorations) {\n for (const decoration of decorations) {\n this._storeDecoration(decoration, match);\n }\n }\n }\n }\n\n /**\n * Creates decorations for the currently active search result.\n * @param result The active search result.\n * @param options The decoration options.\n * @returns The multi-highlight decoration or undefined if creation failed.\n */\n public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {\n const decorations = this._createResultDecorations(result, options, true);\n if (decorations) {\n return { decorations, match: result, dispose() { dispose(decorations); } };\n }\n return undefined;\n }\n\n /**\n * Clears all highlight decorations.\n */\n public clearHighlightDecorations(): void {\n dispose(this._highlightDecorations);\n this._highlightDecorations = [];\n this._highlightedLines.clear();\n }\n\n /**\n * Stores a decoration and tracks it for management.\n * @param decoration The decoration to store.\n * @param match The search result this decoration represents.\n */\n private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {\n this._highlightedLines.add(decoration.marker.line);\n this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });\n }\n\n /**\n * Applies styles to the decoration when it is rendered.\n * @param element The decoration's element.\n * @param borderColor The border color to apply.\n * @param isActiveResult Whether the element is part of the active search result.\n */\n private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {\n if (!element.classList.contains('xterm-find-result-decoration')) {\n element.classList.add('xterm-find-result-decoration');\n if (borderColor) {\n element.style.outline = `1px solid ${borderColor}`;\n }\n }\n if (isActiveResult) {\n element.classList.add('xterm-find-active-result-decoration');\n }\n }\n\n /**\n * Creates a decoration for the result and applies styles\n * @param result the search result for which to create the decoration\n * @param options the options for the decoration\n * @param isActiveResult whether this is the currently active result\n * @returns the decorations or undefined if the marker has already been disposed of\n */\n private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {\n // Gather decoration ranges for this match as it could wrap\n const decorationRanges: [number, number, number][] = [];\n let currentCol = result.col;\n let remainingSize = result.size;\n let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;\n while (remainingSize > 0) {\n const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);\n decorationRanges.push([markerOffset, currentCol, amountThisRow]);\n currentCol = 0;\n remainingSize -= amountThisRow;\n markerOffset++;\n }\n\n // Create the decorations\n const decorations: IDecoration[] = [];\n for (const range of decorationRanges) {\n const marker = this._terminal.registerMarker(range[0]);\n const decoration = this._terminal.registerDecoration({\n marker,\n x: range[1],\n width: range[2],\n layer: isActiveResult ? 'top' : 'bottom',\n backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,\n overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {\n color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,\n position: 'center'\n }\n });\n if (decoration) {\n const disposables: IDisposable[] = [];\n disposables.push(marker);\n disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));\n disposables.push(decoration.onDispose(() => dispose(disposables)));\n decorations.push(decoration);\n }\n }\n\n return decorations.length === 0 ? undefined : decorations;\n }\n}\n\n\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport type { ISearchOptions } from '@xterm/addon-search';\nimport type { SearchLineCache } from './SearchLineCache';\n\n/**\n * Represents the position to start a search from.\n */\ninterface ISearchPosition {\n startCol: number;\n startRow: number;\n}\n\n/**\n * Represents a search result with its position and content.\n */\nexport interface ISearchResult {\n term: string;\n col: number;\n row: number;\n size: number;\n}\n\n/**\n * Configuration constants for the search engine functionality.\n */\nconst enum Constants {\n /**\n * Characters that are considered non-word characters for search boundary detection. These\n * characters are used to determine word boundaries when performing whole-word searches. Includes\n * common punctuation, symbols, and whitespace characters.\n */\n NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\\\;:\"\\',./<>?'\n}\n\n/**\n * Core search engine that handles finding text within terminal content.\n * This class is responsible for the actual search algorithms and position calculations.\n */\nexport class SearchEngine {\n constructor(\n private readonly _terminal: Terminal,\n private readonly _lineCache: SearchLineCache\n ) {}\n\n /**\n * Find the first occurrence of a term starting from a specific position.\n * @param term The search term.\n * @param startRow The row to start searching from.\n * @param startCol The column to start searching from.\n * @param searchOptions Search options.\n * @returns The search result if found, undefined otherwise.\n */\n public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n if (startCol >= this._terminal.cols) {\n throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n return result;\n }\n\n /**\n * Find the next occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine incremental behavior.\n * @returns The search result if found, undefined otherwise.\n */\n public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startCol = 0;\n let startRow = 0;\n if (prevSelectedPos) {\n if (cachedSearchTerm === term) {\n startCol = prevSelectedPos.end.x;\n startRow = prevSelectedPos.end.y;\n } else {\n startCol = prevSelectedPos.start.x;\n startRow = prevSelectedPos.start.y;\n }\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n // If we hit the bottom and didn't search from the very top wrap back up\n if (!result && startRow !== 0) {\n for (let y = 0; y < startRow; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n\n // If there is only one result, wrap back and return selection if it exists.\n if (!result && prevSelectedPos) {\n searchPosition.startRow = prevSelectedPos.start.y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n }\n\n return result;\n }\n\n /**\n * Find the previous occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine if expansion should occur.\n * @returns The search result if found, undefined otherwise.\n */\n public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;\n const startCol = this._terminal.cols;\n const isReverseSearch = true;\n\n this._lineCache.initLinesCache();\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n let result: ISearchResult | undefined;\n if (prevSelectedPos) {\n searchPosition.startRow = startRow = prevSelectedPos.start.y;\n searchPosition.startCol = prevSelectedPos.start.x;\n if (cachedSearchTerm !== term) {\n // Try to expand selection to right first.\n result = this._findInLine(term, searchPosition, searchOptions, false);\n if (!result) {\n // If selection was not able to be expanded to the right, then try reverse search\n searchPosition.startRow = startRow = prevSelectedPos.end.y;\n searchPosition.startCol = prevSelectedPos.end.x;\n }\n }\n }\n\n result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n\n // Search from startRow - 1 to top\n if (!result) {\n searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);\n for (let y = startRow - 1; y >= 0; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n // If we hit the top and didn't search from the very bottom wrap back down\n if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {\n for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n\n return result;\n }\n\n /**\n * A found substring is a whole word if it doesn't have an alphanumeric character directly\n * adjacent to it.\n * @param searchIndex starting index of the potential whole word substring\n * @param line entire string in which the potential whole word was found\n * @param term the substring that starts at searchIndex\n */\n private _isWholeWord(searchIndex: number, line: string, term: string): boolean {\n return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&\n (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));\n }\n\n /**\n * Searches a line for a search term. Takes the provided terminal line and searches the text line,\n * which may contain subsequent terminal lines if the text is wrapped. If the provided line number\n * is part of a wrapped text line that started on an earlier line then it is skipped since it will\n * be properly searched when the terminal line that the text starts on is searched.\n * @param term The search term.\n * @param searchPosition The position to start the search.\n * @param searchOptions Search options.\n * @param isReverseSearch Whether the search should start from the right side of the terminal and\n * search to the left.\n * @returns The search result if it was found.\n */\n private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {\n const row = searchPosition.startRow;\n const col = searchPosition.startCol;\n\n // Ignore wrapped lines, only consider on unwrapped line (first row of command string).\n const firstLine = this._terminal.buffer.active.getLine(row);\n if (firstLine?.isWrapped) {\n if (isReverseSearch) {\n searchPosition.startCol += this._terminal.cols;\n return;\n }\n\n // This will iterate until we find the line start.\n // When we find it, we will search using the calculated start column.\n searchPosition.startRow--;\n searchPosition.startCol += this._terminal.cols;\n return this._findInLine(term, searchPosition, searchOptions);\n }\n let cache = this._lineCache.getLineFromCache(row);\n if (!cache) {\n cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);\n this._lineCache.setLineInCache(row, cache);\n }\n const [stringLine, offsets] = cache;\n\n const offset = this._bufferColsToStringOffset(row, col);\n let searchTerm = term;\n let searchStringLine = stringLine;\n if (!searchOptions.regex) {\n searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();\n searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();\n }\n\n let resultIndex = -1;\n if (searchOptions.regex) {\n const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');\n let foundTerm: RegExpExecArray | null;\n if (isReverseSearch) {\n // This loop will get the resultIndex of the _last_ regex match in the range 0..offset\n while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {\n resultIndex = searchRegex.lastIndex - foundTerm[0].length;\n term = foundTerm[0];\n searchRegex.lastIndex -= (term.length - 1);\n }\n } else {\n foundTerm = searchRegex.exec(searchStringLine.slice(offset));\n if (foundTerm && foundTerm[0].length > 0) {\n resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);\n term = foundTerm[0];\n }\n }\n } else {\n if (isReverseSearch) {\n if (offset - searchTerm.length >= 0) {\n resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);\n }\n } else {\n resultIndex = searchStringLine.indexOf(searchTerm, offset);\n }\n }\n\n if (resultIndex >= 0) {\n if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {\n return;\n }\n\n // Adjust the row number and search index if needed since a \"line\" of text can span multiple\n // rows\n let startRowOffset = 0;\n while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {\n startRowOffset++;\n }\n let endRowOffset = startRowOffset;\n while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {\n endRowOffset++;\n }\n const startColOffset = resultIndex - offsets[startRowOffset];\n const endColOffset = resultIndex + term.length - offsets[endRowOffset];\n const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);\n const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);\n const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);\n\n return {\n term,\n col: startColIndex,\n row: row + startRowOffset,\n size\n };\n }\n }\n\n private _stringLengthToBufferSize(row: number, offset: number): number {\n const line = this._terminal.buffer.active.getLine(row);\n if (!line) {\n return 0;\n }\n for (let i = 0; i < offset; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n // Adjust the searchIndex to normalize emoji into single chars\n const char = cell.getChars();\n if (char.length > 1) {\n offset -= char.length - 1;\n }\n // Adjust the searchIndex for empty characters following wide unicode\n // chars (eg. CJK)\n const nextCell = line.getCell(i + 1);\n if (nextCell && nextCell.getWidth() === 0) {\n offset++;\n }\n }\n return offset;\n }\n\n private _bufferColsToStringOffset(startRow: number, cols: number): number {\n let lineIndex = startRow;\n let offset = 0;\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (cols > 0 && line) {\n for (let i = 0; i < cols && i < this._terminal.cols; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n if (cell.getWidth()) {\n // Treat null characters as whitespace to align with the translateToString API\n offset += cell.getCode() === 0 ? 1 : cell.getChars().length;\n }\n }\n lineIndex++;\n line = this._terminal.buffer.active.getLine(lineIndex);\n if (line && !line.isWrapped) {\n break;\n }\n cols -= this._terminal.cols;\n }\n return offset;\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\n\nexport type LineCacheEntry = [\n /**\n * The string representation of a line (as opposed to the buffer cell representation).\n */\n lineAsString: string,\n /**\n * The offsets where each line starts when the entry describes a wrapped line.\n */\n lineOffsets: number[]\n];\n\n/**\n * Configuration constants for the search line cache functionality.\n */\nconst enum Constants {\n /**\n * Time-to-live for cached search results in milliseconds. After this duration, cached search\n * results will be invalidated to ensure they remain consistent with terminal content changes.\n */\n LINES_CACHE_TIME_TO_LIVE = 15000\n}\n\nexport class SearchLineCache extends Disposable {\n /**\n * translateBufferLineToStringWithWrap is a fairly expensive call.\n * We memoize the calls into an array that has a time based ttl.\n * _linesCache is also invalidated when the terminal cursor moves.\n */\n private _linesCache: LineCacheEntry[] | undefined;\n private _linesCacheTimeout = this._register(new MutableDisposable());\n private _linesCacheDisposables = this._register(new MutableDisposable());\n // Track access to avoid recreating a timeout on every init call which occurs once per search\n // result (findNext/findPrevious -> _highlightAllMatches -> find loop).\n private _lastAccessTimestamp = 0;\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this._destroyLinesCache()));\n }\n\n /**\n * Sets up a line cache with a ttl\n */\n public initLinesCache(): void {\n if (!this._linesCache) {\n this._linesCache = new Array(this._terminal.buffer.active.length);\n this._linesCacheDisposables.value = combinedDisposable(\n this._terminal.onLineFeed(() => this._destroyLinesCache()),\n this._terminal.onCursorMove(() => this._destroyLinesCache()),\n this._terminal.onResize(() => this._destroyLinesCache())\n );\n }\n\n this._lastAccessTimestamp = Date.now();\n if (!this._linesCacheTimeout.value) {\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);\n }\n }\n\n private _destroyLinesCache(): void {\n this._linesCache = undefined;\n this._lastAccessTimestamp = 0;\n this._linesCacheDisposables.clear();\n this._linesCacheTimeout.clear();\n }\n\n private _scheduleLinesCacheTimeout(delay: number): void {\n this._linesCacheTimeout.value = disposableTimeout(() => {\n if (!this._linesCache) {\n return;\n }\n const now = Date.now();\n const elapsed = now - this._lastAccessTimestamp;\n if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {\n this._destroyLinesCache();\n return;\n }\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);\n }, delay);\n }\n\n public getLineFromCache(row: number): LineCacheEntry | undefined {\n return this._linesCache?.[row];\n }\n\n public setLineInCache(row: number, entry: LineCacheEntry): void {\n if (this._linesCache) {\n this._linesCache[row] = entry;\n }\n }\n\n /**\n * Translates a buffer line to a string, including subsequent lines if they are wraps.\n * Wide characters will count as two columns in the resulting string. This\n * function is useful for getting the actual text underneath the raw selection\n * position.\n * @param lineIndex The index of the line being translated.\n * @param trimRight Whether to trim whitespace to the right.\n */\n public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {\n const strings = [];\n const lineOffsets = [0];\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (line) {\n const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);\n const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;\n let string = line.translateToString(!lineWrapsToNext && trimRight);\n if (lineWrapsToNext && nextLine) {\n const lastCell = line.getCell(line.length - 1);\n const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;\n // a wide character wrapped to the next line\n if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {\n string = string.slice(0, -1);\n }\n }\n strings.push(string);\n if (lineWrapsToNext) {\n lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);\n } else {\n break;\n }\n lineIndex++;\n line = nextLine;\n }\n return [strings.join(''), lineOffsets];\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchResultChangeEvent } from '@xterm/addon-search';\nimport type { IDisposable } from '@xterm/xterm';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a currently selected decoration.\n */\ninterface ISelectedDecoration extends IDisposable {\n match: ISearchResult;\n}\n\n/**\n * Tracks search results, manages result indexing, and fires events when results change.\n * This class provides centralized management of search result state and notifications.\n */\nexport class SearchResultTracker extends Disposable {\n private _searchResults: ISearchResult[] = [];\n private _selectedDecoration: ISelectedDecoration | undefined;\n\n private readonly _onDidChangeResults = this._register(new Emitter());\n public get onDidChangeResults(): IEvent { return this._onDidChangeResults.event; }\n\n /**\n * Gets the current search results.\n */\n public get searchResults(): ReadonlyArray {\n return this._searchResults;\n }\n\n /**\n * Gets the currently selected decoration.\n */\n public get selectedDecoration(): ISelectedDecoration | undefined {\n return this._selectedDecoration;\n }\n\n /**\n * Sets the currently selected decoration.\n */\n public set selectedDecoration(decoration: ISelectedDecoration | undefined) {\n this._selectedDecoration = decoration;\n }\n\n /**\n * Updates the search results with a new set of results.\n * @param results The new search results.\n * @param maxResults The maximum number of results to track.\n */\n public updateResults(results: ISearchResult[], maxResults: number): void {\n this._searchResults = results.slice(0, maxResults);\n }\n\n /**\n * Clears all search results.\n */\n public clearResults(): void {\n this._searchResults = [];\n }\n\n /**\n * Clears the selected decoration.\n */\n public clearSelectedDecoration(): void {\n if (this._selectedDecoration) {\n this._selectedDecoration.dispose();\n this._selectedDecoration = undefined;\n }\n }\n\n /**\n * Finds the index of a result in the current results array.\n * @param result The result to find.\n * @returns The index of the result, or -1 if not found.\n */\n public findResultIndex(result: ISearchResult): number {\n for (let i = 0; i < this._searchResults.length; i++) {\n const match = this._searchResults[i];\n if (match.row === result.row && match.col === result.col && match.size === result.size) {\n return i;\n }\n }\n return -1;\n }\n\n /**\n * Fires a result change event with the current state.\n * @param hasDecorations Whether decorations are enabled.\n */\n public fireResultsChanged(hasDecorations: boolean): void {\n if (!hasDecorations) {\n return;\n }\n\n let resultIndex = -1;\n if (this._selectedDecoration) {\n resultIndex = this.findResultIndex(this._selectedDecoration.match);\n }\n\n this._onDidChangeResults.fire({\n resultIndex,\n resultCount: this._searchResults.length\n });\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this.clearSelectedDecoration();\n this.clearResults();\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchOptions } from '@xterm/addon-search';\n\n/**\n * Manages search state including cached search terms, options tracking, and validation.\n * This class provides a centralized way to handle search state consistency and option changes.\n */\nexport class SearchState {\n private _cachedSearchTerm: string | undefined;\n private _lastSearchOptions: ISearchOptions | undefined;\n\n /**\n * Gets the currently cached search term.\n */\n public get cachedSearchTerm(): string | undefined {\n return this._cachedSearchTerm;\n }\n\n /**\n * Sets the cached search term.\n */\n public set cachedSearchTerm(term: string | undefined) {\n this._cachedSearchTerm = term;\n }\n\n /**\n * Gets the last search options used.\n */\n public get lastSearchOptions(): ISearchOptions | undefined {\n return this._lastSearchOptions;\n }\n\n /**\n * Sets the last search options used.\n */\n public set lastSearchOptions(options: ISearchOptions | undefined) {\n this._lastSearchOptions = options;\n }\n\n /**\n * Validates a search term to ensure it's not empty or invalid.\n * @param term The search term to validate.\n * @returns true if the term is valid for searching.\n */\n public isValidSearchTerm(term: string): boolean {\n return !!(term && term.length > 0);\n }\n\n /**\n * Determines if search options have changed compared to the last search.\n * @param newOptions The new search options to compare.\n * @returns true if the options have changed.\n */\n public didOptionsChange(newOptions?: ISearchOptions): boolean {\n if (!this._lastSearchOptions) {\n return true;\n }\n if (!newOptions) {\n return false;\n }\n if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {\n return true;\n }\n if (this._lastSearchOptions.regex !== newOptions.regex) {\n return true;\n }\n if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {\n return true;\n }\n return false;\n }\n\n /**\n * Determines if a new search should trigger highlighting updates.\n * @param term The search term.\n * @param options The search options.\n * @returns true if highlighting should be updated.\n */\n public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {\n if (!options?.decorations) {\n return false;\n }\n return this._cachedSearchTerm === undefined ||\n term !== this._cachedSearchTerm ||\n this.didOptionsChange(options);\n }\n\n /**\n * Clears the cached search term.\n */\n public clearCachedTerm(): void {\n this._cachedSearchTerm = undefined;\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this._cachedSearchTerm = undefined;\n this._lastSearchOptions = undefined;\n }\n}\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';\nimport type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\nimport { SearchLineCache } from './SearchLineCache';\nimport { SearchState } from './SearchState';\nimport { SearchEngine, type ISearchResult } from './SearchEngine';\nimport { DecorationManager } from './DecorationManager';\nimport { SearchResultTracker } from './SearchResultTracker';\n\ninterface IInternalSearchOptions {\n noScroll: boolean;\n}\n\n/**\n * Configuration constants for the search addon functionality.\n */\nconst enum Constants {\n /**\n * Default maximum number of search results to highlight simultaneously. This limit prevents\n * performance degradation when searching for very common terms that would result in excessive\n * highlighting decorations.\n */\n DEFAULT_HIGHLIGHT_LIMIT = 1000\n}\n\nexport class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {\n private _terminal: Terminal | undefined;\n private _highlightLimit: number;\n private _highlightTimeout = this._register(new MutableDisposable());\n private _lineCache = this._register(new MutableDisposable());\n\n // Component instances\n private _state = new SearchState();\n private _engine: SearchEngine | undefined;\n private _decorationManager: DecorationManager | undefined;\n private _resultTracker = this._register(new SearchResultTracker());\n\n private readonly _onAfterSearch = this._register(new Emitter());\n public readonly onAfterSearch = this._onAfterSearch.event;\n private readonly _onBeforeSearch = this._register(new Emitter());\n public readonly onBeforeSearch = this._onBeforeSearch.event;\n\n public get onDidChangeResults(): IEvent {\n return this._resultTracker.onDidChangeResults;\n }\n\n constructor(options?: Partial) {\n super();\n\n this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;\n }\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n this._lineCache.value = new SearchLineCache(terminal);\n this._engine = new SearchEngine(terminal, this._lineCache.value);\n this._decorationManager = new DecorationManager(terminal);\n this._register(this._terminal.onWriteParsed(() => this._updateMatches()));\n this._register(this._terminal.onResize(() => this._updateMatches()));\n this._register(toDisposable(() => this.clearDecorations()));\n }\n\n private _updateMatches(): void {\n this._highlightTimeout.clear();\n if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {\n this._highlightTimeout.value = disposableTimeout(() => {\n const term = this._state.cachedSearchTerm;\n this._state.clearCachedTerm();\n this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });\n }, 200);\n }\n }\n\n public clearDecorations(retainCachedSearchTerm?: boolean): void {\n this._resultTracker.clearSelectedDecoration();\n this._decorationManager?.clearHighlightDecorations();\n this._resultTracker.clearResults();\n if (!retainCachedSearchTerm) {\n this._state.clearCachedTerm();\n }\n }\n\n public clearActiveDecoration(): void {\n this._resultTracker.clearSelectedDecoration();\n }\n\n /**\n * Find the next instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {\n if (!this._terminal || !this._engine || !this._decorationManager) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n if (!this._state.isValidSearchTerm(term)) {\n this.clearDecorations();\n return;\n }\n\n // new search, clear out the old decorations\n this.clearDecorations(true);\n\n const results: ISearchResult[] = [];\n let prevResult: ISearchResult | undefined = undefined;\n let result = this._engine.find(term, 0, 0, searchOptions);\n\n while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {\n if (results.length >= this._highlightLimit) {\n break;\n }\n prevResult = result;\n results.push(prevResult);\n const cols = this._terminal.cols;\n let nextCol = prevResult.col + prevResult.size;\n let nextRow = prevResult.row;\n if (nextCol >= cols) {\n nextRow += Math.floor(nextCol / cols);\n nextCol = nextCol % cols;\n }\n result = this._engine.find(term, nextRow, nextCol, searchOptions);\n }\n\n this._resultTracker.updateResults(results, this._highlightLimit);\n if (searchOptions.decorations) {\n this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);\n }\n }\n\n private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Find the previous instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _fireResults(searchOptions?: ISearchOptions): void {\n this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);\n }\n\n private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Selects and scrolls to a result.\n * @param result The result to select.\n * @returns Whether a result was selected.\n */\n private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {\n if (!this._terminal || !this._decorationManager) {\n return false;\n }\n\n this._resultTracker.clearSelectedDecoration();\n if (!result) {\n this._terminal.clearSelection();\n return false;\n }\n\n this._terminal.select(result.col, result.row, result.size);\n if (options) {\n const activeDecoration = this._decorationManager.createActiveDecoration(result, options);\n if (activeDecoration) {\n this._resultTracker.selectedDecoration = activeDecoration;\n }\n }\n\n if (!noScroll) {\n // If it is not in the viewport then we scroll else it just gets selected\n if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {\n let scroll = result.row - this._terminal.buffer.active.viewportY;\n scroll -= Math.floor(this._terminal.rows / 2);\n this._terminal.scrollLines(scroll);\n }\n }\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","millis","Promise","resolve","setTimeout","handler","timeout","store","timer","disposable","dispose","Lifecycle_1","toDisposable","clearTimeout","add","__webpack_require__","constructor","this","_token","_isDisposed","cancel","cancelAndSet","runner","Error","setIfNotSet","_isScheduled","set","queueMicrotask","_disposable","undefined","interval","context","handle","setInterval","clearInterval","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","entry","fn","slice","push","result","idx","indexOf","splice","Array","isArray","fire","length","call","listeners","i","len","forward","from","to","e","map","any","events","DisposableStore","runAndSubscribe","initial","arg","d","_disposables","Set","isDisposed","o","clear","Disposable","_store","_register","None","Object","freeze","value","_value","DecorationManager","_terminal","super","_highlightDecorations","_highlightedLines","clearHighlightDecorations","createHighlightDecorations","results","options","match","decorations","_createResultDecorations","decoration","_storeDecoration","createActiveDecoration","marker","line","_applyStyles","element","borderColor","isActiveResult","classList","contains","style","outline","decorationRanges","currentCol","col","remainingSize","size","markerOffset","buffer","active","baseY","cursorY","row","amountThisRow","Math","min","cols","range","registerMarker","registerDecoration","x","width","layer","backgroundColor","activeMatchBackground","matchBackground","overviewRulerOptions","has","color","activeMatchColorOverviewRuler","matchOverviewRuler","position","onRender","activeMatchBorder","matchBorder","onDispose","_lineCache","find","term","startRow","startCol","searchOptions","clearSelection","initLinesCache","searchPosition","_findInLine","y","rows","findNextWithSelection","cachedSearchTerm","prevSelectedPos","getSelectionPosition","end","start","findPreviousWithSelection","isReverseSearch","max","_isWholeWord","searchIndex","includes","firstLine","getLine","isWrapped","cache","getLineFromCache","translateBufferLineToStringWithWrap","setLineInCache","stringLine","offsets","offset","_bufferColsToStringOffset","searchTerm","searchStringLine","regex","caseSensitive","toLowerCase","resultIndex","searchRegex","RegExp","foundTerm","exec","lastIndex","lastIndexOf","wholeWord","startRowOffset","endRowOffset","startColOffset","endColOffset","startColIndex","_stringLengthToBufferSize","cell","getCell","char","getChars","nextCell","getWidth","lineIndex","getCode","Async_1","SearchLineCache","_linesCacheTimeout","MutableDisposable","_linesCacheDisposables","_lastAccessTimestamp","_destroyLinesCache","_linesCache","combinedDisposable","onLineFeed","onCursorMove","onResize","Date","now","_scheduleLinesCacheTimeout","delay","disposableTimeout","elapsed","trimRight","strings","lineOffsets","nextLine","lineWrapsToNext","string","translateToString","lastCell","join","Event_1","SearchResultTracker","_searchResults","_onDidChangeResults","Emitter","onDidChangeResults","searchResults","selectedDecoration","_selectedDecoration","updateResults","maxResults","clearResults","clearSelectedDecoration","findResultIndex","fireResultsChanged","hasDecorations","resultCount","reset","_cachedSearchTerm","lastSearchOptions","_lastSearchOptions","isValidSearchTerm","didOptionsChange","newOptions","shouldUpdateHighlighting","clearCachedTerm","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","SearchLineCache_1","SearchState_1","SearchEngine_1","DecorationManager_1","SearchResultTracker_1","SearchAddon","_resultTracker","_highlightTimeout","_state","SearchState","_onAfterSearch","onAfterSearch","_onBeforeSearch","onBeforeSearch","_highlightLimit","highlightLimit","activate","terminal","_engine","SearchEngine","_decorationManager","onWriteParsed","_updateMatches","clearDecorations","findPrevious","incremental","noScroll","retainCachedSearchTerm","clearActiveDecoration","findNext","internalSearchOptions","_highlightAllMatches","found","_findNextAndSelect","_fireResults","prevResult","nextCol","nextRow","floor","_selectResult","_findPreviousAndSelect","select","activeDecoration","viewportY","scroll","scrollLines"],"sourceRoot":""} +\ No newline at end of file ++{"version":3,"file":"addon-search.js","mappings":"CAAA,SAAAA,EAAAC,GACA,iBAAAC,SAAA,iBAAAC,OACAA,OAAAD,QAAAD,IACA,mBAAAG,QAAAA,OAAAC,IACAD,OAAA,GAAAH,GACA,iBAAAC,QACAA,QAAA,YAAAD,IAEAD,EAAA,YAAAC,GACC,CATD,CASCK,WAAA,2JCAD,SAAwBC,GACtB,OAAO,IAAIC,QAAQC,GAAWC,WAAWD,EAASF,GACpD,sBASA,SAAkCI,EAAqBC,EAAU,EAAGC,GAClE,MAAMC,EAAQJ,WAAW,KACvBC,IACIE,GACFE,EAAWC,WAEZJ,GACGG,GAAa,EAAAE,EAAAC,cAAa,KAC9BC,aAAaL,KAGf,OADAD,GAAOO,IAAIL,GACJA,CACT,EAzBA,MAAAE,EAAAI,EAAA,oBA2BA,iBAAAC,GACUC,KAAAC,QAAe,EACfD,KAAAE,aAAc,CAqCxB,CAnCS,OAAAT,GACLO,KAAKG,SACLH,KAAKE,aAAc,CACrB,CAEO,MAAAC,IACgB,IAAjBH,KAAKC,SACPL,aAAaI,KAAKC,QAClBD,KAAKC,QAAU,EAEnB,CAEO,YAAAG,CAAaC,EAAoBhB,GACtC,GAAIW,KAAKE,YACP,MAAM,IAAII,MAAM,mDAElBN,KAAKG,SACLH,KAAKC,OAASd,WAAW,KACvBa,KAAKC,QAAU,EACfI,KACChB,EACL,CAEO,WAAAkB,CAAYF,EAAoBhB,GACrC,GAAIW,KAAKE,YACP,MAAM,IAAII,MAAM,mDAEG,IAAjBN,KAAKC,SAGTD,KAAKC,OAASd,WAAW,KACvBa,KAAKC,QAAU,EACfI,KACChB,GACL,oBAQF,iBAAAU,GACUC,KAAAQ,cAAe,EACfR,KAAAE,aAAc,CA2BxB,CAzBS,OAAAT,GACLO,KAAKG,SACLH,KAAKE,aAAc,CACrB,CAEO,MAAAC,GACLH,KAAKQ,cAAe,CACtB,CAEO,GAAAC,CAAIJ,GACT,GAAIL,KAAKE,YACP,MAAM,IAAII,MAAM,4CAEdN,KAAKQ,eAGTR,KAAKQ,cAAe,EACpBE,eAAe,KACRV,KAAKQ,eAGVR,KAAKQ,cAAe,EACpBH,OAEJ,mBAGF,iBAAAN,GAEUC,KAAAE,aAAc,CA2BxB,CAzBS,MAAAC,GACLH,KAAKW,aAAalB,UAClBO,KAAKW,iBAAcC,CACrB,CAEO,YAAAR,CAAaC,EAAoBQ,EAAkBC,EAAsC/B,YAC9F,GAAIiB,KAAKE,YACP,MAAM,IAAII,MAAM,oDAElBN,KAAKG,SACL,MAAMY,EAASD,EAAQE,YAAY,KACjCX,KACCQ,GACHb,KAAKW,YAAc,CACjBlB,QAAS,KACPqB,EAAQG,cAAcF,GACtBf,KAAKW,iBAAcC,GAGzB,CAEO,OAAAnB,GACLO,KAAKG,SACLH,KAAKE,aAAc,CACrB,8FCnIF,MAAAR,EAAAI,EAAA,KAoEA,IAAiBoB,YA9DjB,iBAAAnB,GACUC,KAAAmB,WAAqD,GACrDnB,KAAAoB,WAAY,CA0DtB,CAvDE,SAAWC,GACT,OAAIrB,KAAKsB,SAGTtB,KAAKsB,OAAS,CAACC,EAAyBC,EAAgBC,KACtD,GAAIzB,KAAKoB,UACP,OAAO,EAAA1B,EAAAC,cAAa,QAGtB,MAAM+B,EAAQ,CAAEC,GAAIJ,EAAUC,YAC9BxB,KAAKmB,WAAanB,KAAKmB,WAAWS,QAClC5B,KAAKmB,WAAWU,KAAKH,GAErB,MAAMI,GAAS,EAAApC,EAAAC,cAAa,KAC1B,MAAMoC,EAAM/B,KAAKmB,WAAWa,QAAQN,IACvB,IAATK,IACF/B,KAAKmB,WAAanB,KAAKmB,WAAWS,QAClC5B,KAAKmB,WAAWc,OAAOF,EAAK,MAYhC,OARIN,IACES,MAAMC,QAAQV,GAChBA,EAAYI,KAAKC,GAEjBL,EAAY5B,IAAIiC,IAIbA,IA3BA9B,KAAKsB,MA8BhB,CAEO,IAAAc,CAAKf,GACV,GAAIrB,KAAKoB,YAAcpB,KAAKmB,WAAWkB,OACrC,OAEF,GAA+B,IAA3BrC,KAAKmB,WAAWkB,OAElB,YADArC,KAAKmB,WAAW,GAAGQ,GAAGW,KAAKtC,KAAKmB,WAAW,GAAGK,SAAUH,GAG1D,MAAMkB,EAAYvC,KAAKmB,WACvB,IAAK,IAAIqB,EAAI,EAAGC,EAAMF,EAAUF,OAAQG,EAAIC,IAAOD,EACjDD,EAAUC,GAAGb,GAAGW,KAAKC,EAAUC,GAAGhB,SAAUH,EAEhD,CAEO,OAAA5B,GACDO,KAAKoB,YAGTpB,KAAKoB,WAAY,EACjBpB,KAAKmB,WAAWkB,OAAS,EAC3B,GAGF,SAAiBnB,GACCA,EAAAwB,QAAhB,SAA2BC,EAAiBC,GAC1C,OAAOD,EAAKE,GAAKD,EAAGR,KAAKS,GAC3B,EAEgB3B,EAAA4B,IAAhB,SAA0BzB,EAAkByB,GAC1C,MAAO,CAACvB,EAAyBC,EAAgBC,IACxCJ,EAAMmB,GAAKjB,EAASe,KAAKd,EAAUsB,EAAIN,SAAK5B,EAAWa,EAElE,EAIgBP,EAAA6B,IAAhB,YAA0BC,GACxB,MAAO,CAACzB,EAAyBC,EAAgBC,KAC/C,MAAMnC,EAAQ,IAAII,EAAAuD,gBAClB,IAAK,MAAM5B,KAAS2B,EAClB1D,EAAMO,IAAIwB,EAAMwB,GAAKtB,EAASe,KAAKd,EAAUqB,KAS/C,OAPIpB,IACES,MAAMC,QAAQV,GAChBA,EAAYI,KAAKvC,GAEjBmC,EAAY5B,IAAIP,IAGbA,EAEX,EAIgB4B,EAAAgC,gBAAhB,SAAmC7B,EAAkBjC,EAAqC+D,GAExF,OADA/D,EAAQ+D,GACD9B,EAAMwB,GAAKzD,EAAQyD,GAC5B,CACD,CApCD,CAAiB3B,IAAUvC,EAAAuC,WAAVA,EAAU,eChE3B,SAAAvB,EAA6BgC,GAC3B,MAAO,CAAElC,QAASkC,EACpB,CAKA,SAAAlC,EAA+C2D,GAC7C,IAAKA,EACH,OAAOA,EAET,GAAIlB,MAAMC,QAAQiB,GAAM,CACtB,IAAK,MAAMC,KAAKD,EACdC,EAAE5D,UAEJ,MAAO,EACT,CAEA,OADA2D,EAAI3D,UACG2D,CACT,8JAEA,YAAsC3B,GACpC,OAAO9B,EAAa,IAAMF,EAAQgC,GACpC,EAEA,MAAAwB,EAAA,WAAAlD,GACmBC,KAAAsD,aAAe,IAAIC,IAC5BvD,KAAAE,aAAc,CAgCxB,CA9BE,cAAWsD,GACT,OAAOxD,KAAKE,WACd,CAEO,GAAAL,CAA2B4D,GAMhC,OALIzD,KAAKE,YACPuD,EAAEhE,UAEFO,KAAKsD,aAAazD,IAAI4D,GAEjBA,CACT,CAEO,OAAAhE,GACL,IAAIO,KAAKE,YAAT,CAGAF,KAAKE,aAAc,EACnB,IAAK,MAAMmD,KAAKrD,KAAKsD,aACnBD,EAAE5D,UAEJO,KAAKsD,aAAaI,OALlB,CAMF,CAEO,KAAAA,GACL,IAAK,MAAML,KAAKrD,KAAKsD,aACnBD,EAAE5D,UAEJO,KAAKsD,aAAaI,OACpB,sBAGF,MAAAC,EAAA,WAAA5D,GAGqBC,KAAA4D,OAAS,IAAIX,CASlC,CAPS,OAAAxD,GACLO,KAAK4D,OAAOnE,SACd,CAEU,SAAAoE,CAAiCJ,GACzC,OAAOzD,KAAK4D,OAAO/D,IAAI4D,EACzB,iBAVuBE,EAAAG,KAAoBC,OAAOC,OAAO,CAAE,OAAAvE,GAAY,wBAazE,iBAAAM,GAEUC,KAAAE,aAAc,CAuBxB,CArBE,SAAW+D,GACT,OAAOjE,KAAKE,iBAAcU,EAAYZ,KAAKkE,MAC7C,CAEA,SAAWD,CAAMA,GACXjE,KAAKE,aAAe+D,IAAUjE,KAAKkE,SAGvClE,KAAKkE,QAAQzE,UACbO,KAAKkE,OAASD,EAChB,CAEO,KAAAP,GACL1D,KAAKiE,WAAQrD,CACf,CAEO,OAAAnB,GACLO,KAAKE,aAAc,EACnBF,KAAKkE,QAAQzE,UACbO,KAAKkE,YAAStD,CAChB,2FCxGF,MAAAlB,EAAAI,EAAA,KAuBA,MAAAqE,UAAuCzE,EAAAiE,WAIrC,WAAA5D,CAA6BqE,GAC3BC,QAD2BrE,KAAAoE,UAAAA,EAHrBpE,KAAAsE,sBAAsC,GACtCtE,KAAAuE,kBAAiC,IAAIhB,IAI3CvD,KAAK6D,WAAU,EAAAnE,EAAAC,cAAa,IAAMK,KAAKwE,6BACzC,CAOO,0BAAAC,CAA2BC,EAA0BC,GAC1D3E,KAAKwE,4BAEL,IAAK,MAAMI,KAASF,EAAS,CAC3B,MAAMG,EAAc7E,KAAK8E,yBAAyBF,EAAOD,GAAS,GAClE,GAAIE,EACF,IAAK,MAAME,KAAcF,EACvB7E,KAAKgF,iBAAiBD,EAAYH,EAGxC,CACF,CAQO,sBAAAK,CAAuBnD,EAAuB6C,GACnD,MAAME,EAAc7E,KAAK8E,yBAAyBhD,EAAQ6C,GAAS,GACnE,GAAIE,EACF,MAAO,CAAEA,cAAaD,MAAO9C,EAAQ,OAAArC,IAAY,EAAAC,EAAAD,SAAQoF,EAAc,EAG3E,CAKO,yBAAAL,IACL,EAAA9E,EAAAD,SAAQO,KAAKsE,uBACbtE,KAAKsE,sBAAwB,GAC7BtE,KAAKuE,kBAAkBb,OACzB,CAOQ,gBAAAsB,CAAiBD,EAAyBH,GAChD5E,KAAKuE,kBAAkB1E,IAAIkF,EAAWG,OAAOC,MAC7CnF,KAAKsE,sBAAsBzC,KAAK,CAAEkD,aAAYH,QAAO,OAAAnF,GAAYsF,EAAWtF,SAAW,GACzF,CAQQ,YAAA2F,CAAaC,EAAsBC,EAAiCC,GACrEF,EAAQG,UAAUC,SAAS,kCAC9BJ,EAAQG,UAAU3F,IAAI,gCAClByF,IACFD,EAAQK,MAAMC,QAAU,aAAaL,MAGrCC,GACFF,EAAQG,UAAU3F,IAAI,sCAE1B,CASQ,wBAAAiF,CAAyBhD,EAAuB6C,EAAmCY,GAEzF,MAAMK,EAA+C,GACrD,IAAIC,EAAa/D,EAAOgE,IACpBC,EAAgBjE,EAAOkE,KACvBC,GAAgBjG,KAAKoE,UAAU8B,OAAOC,OAAOC,MAAQpG,KAAKoE,UAAU8B,OAAOC,OAAOE,QAAUvE,EAAOwE,IACvG,KAAOP,EAAgB,GAAG,CACxB,MAAMQ,EAAgBC,KAAKC,IAAIzG,KAAKoE,UAAUsC,KAAOb,EAAYE,GACjEH,EAAiB/D,KAAK,CAACoE,EAAcJ,EAAYU,IACjDV,EAAa,EACbE,GAAiBQ,EACjBN,GACF,CAGA,MAAMpB,EAA6B,GACnC,IAAK,MAAM8B,KAASf,EAAkB,CACpC,MAAMV,EAASlF,KAAKoE,UAAUwC,eAAeD,EAAM,IAC7C5B,EAAa/E,KAAKoE,UAAUyC,mBAAmB,CACnD3B,SACA4B,EAAGH,EAAM,GACTI,MAAOJ,EAAM,GACbK,MAAOzB,EAAiB,MAAQ,SAChC0B,gBAAiB1B,EAAiBZ,EAAQuC,sBAAwBvC,EAAQwC,gBAC1EC,qBAAsBpH,KAAKuE,kBAAkB8C,IAAInC,EAAOC,WAAQvE,EAAY,CAC1E0G,MAAO/B,EAAiBZ,EAAQ4C,8BAAgC5C,EAAQ6C,mBACxEC,SAAU,YAGd,GAAI1C,EAAY,CACd,MAAMtD,EAA6B,GACnCA,EAAYI,KAAKqD,GACjBzD,EAAYI,KAAKkD,EAAW2C,SAAU7E,GAAM7C,KAAKoF,aAAavC,EAAG0C,EAAiBZ,EAAQgD,kBAAoBhD,EAAQiD,aAAa,KACnInG,EAAYI,KAAKkD,EAAW8C,UAAU,KAAM,EAAAnI,EAAAD,SAAQgC,KACpDoD,EAAYhD,KAAKkD,EACnB,CACF,CAEA,OAA8B,IAAvBF,EAAYxC,YAAezB,EAAYiE,CAChD,wHC/GF,MACE,WAAA9E,CACmBqE,EACA0D,kBADA1D,kBACA0D,CAChB,CAUI,IAAAC,CAAKC,EAAcC,EAAkBC,EAAkBC,GAC5D,IAAKH,GAAwB,IAAhBA,EAAK3F,OAEhB,YADArC,KAAKoE,UAAUgE,iBAGjB,GAAIF,GAAYlI,KAAKoE,UAAUsC,KAC7B,MAAM,IAAIpG,MAAM,gBAAgB4H,8BAAqClI,KAAKoE,UAAUsC,aAGtF1G,KAAK8H,WAAWO,iBAEhB,MAAMC,EAAkC,CACtCL,WACAC,YAIF,IAAIpG,EAAS9B,KAAKuI,YAAYP,EAAMM,EAAgBH,GAEpD,IAAKrG,EACH,IAAK,IAAI0G,EAAIP,EAAW,EAAGO,EAAIxI,KAAKoE,UAAU8B,OAAOC,OAAOC,MAAQpG,KAAKoE,UAAUqE,OAC7EzI,KAAK0I,6BAA6BF,KAGtCF,EAAeL,SAAWO,EAC1BF,EAAeJ,SAAW,EAC1BpG,EAAS9B,KAAKuI,YAAYP,EAAMM,EAAgBH,IAC5CrG,IAPmF0G,KAY3F,OAAO1G,CACT,CASO,qBAAA6G,CAAsBX,EAAcG,EAAgCS,GACzE,IAAKZ,GAAwB,IAAhBA,EAAK3F,OAEhB,YADArC,KAAKoE,UAAUgE,iBAIjB,MAAMS,EAAkB7I,KAAKoE,UAAU0E,uBACvC9I,KAAKoE,UAAUgE,iBAEf,IAAIF,EAAW,EACXD,EAAW,EACXY,IACED,IAAqBZ,GACvBE,EAAWW,EAAgBE,IAAIjC,EAC/BmB,EAAWY,EAAgBE,IAAIP,IAE/BN,EAAWW,EAAgBG,MAAMlC,EACjCmB,EAAWY,EAAgBG,MAAMR,IAIrCxI,KAAK8H,WAAWO,iBAEhB,MAAMC,EAAkC,CACtCL,WACAC,YAIF,IAAIpG,EAAS9B,KAAKuI,YAAYP,EAAMM,EAAgBH,GAEpD,IAAKrG,EACH,IAAK,IAAI0G,EAAIP,EAAW,EAAGO,EAAIxI,KAAKoE,UAAU8B,OAAOC,OAAOC,MAAQpG,KAAKoE,UAAUqE,OAC7EzI,KAAK0I,6BAA6BF,KAGtCF,EAAeL,SAAWO,EAC1BF,EAAeJ,SAAW,EAC1BpG,EAAS9B,KAAKuI,YAAYP,EAAMM,EAAgBH,IAC5CrG,IAPmF0G,KAa3F,IAAK1G,GAAuB,IAAbmG,EACb,IAAK,IAAIO,EAAI,EAAGA,EAAIP,IAGdO,EAAI,GAAKxI,KAAK0I,6BAA6BF,KAG/CF,EAAeL,SAAWO,EAC1BF,EAAeJ,SAAW,EAC1BpG,EAAS9B,KAAKuI,YAAYP,EAAMM,EAAgBH,IAC5CrG,IATwB0G,KAsBhC,OANK1G,GAAU+G,IACbP,EAAeL,SAAWY,EAAgBG,MAAMR,EAChDF,EAAeJ,SAAW,EAC1BpG,EAAS9B,KAAKuI,YAAYP,EAAMM,EAAgBH,IAG3CrG,CACT,CASO,yBAAAmH,CAA0BjB,EAAcG,EAAgCS,GAC7E,IAAKZ,GAAwB,IAAhBA,EAAK3F,OAEhB,YADArC,KAAKoE,UAAUgE,iBAIjB,MAAMS,EAAkB7I,KAAKoE,UAAU0E,uBACvC9I,KAAKoE,UAAUgE,iBAEf,IAAIH,EAAWjI,KAAKoE,UAAU8B,OAAOC,OAAOC,MAAQpG,KAAKoE,UAAUqE,KAAO,EAC1E,MAAMP,EAAWlI,KAAKoE,UAAUsC,KAC1BwC,GAAkB,EAExBlJ,KAAK8H,WAAWO,iBAChB,MAAMC,EAAkC,CACtCL,WACAC,YAGF,IAAIpG,EAkBJ,GAjBI+G,IACFP,EAAeL,SAAWA,EAAWY,EAAgBG,MAAMR,EAC3DF,EAAeJ,SAAWW,EAAgBG,MAAMlC,EAC5C8B,IAAqBZ,IAEvBlG,EAAS9B,KAAKuI,YAAYP,EAAMM,EAAgBH,GAAe,GAC1DrG,IAEHwG,EAAeL,SAAWA,EAAWY,EAAgBE,IAAIP,EACzDF,EAAeJ,SAAWW,EAAgBE,IAAIjC,KAKpDhF,IAAW9B,KAAKuI,YAAYP,EAAMM,EAAgBH,EAAee,IAG5DpH,EAAQ,CACXwG,EAAeJ,SAAW1B,KAAK2C,IAAIb,EAAeJ,SAAUlI,KAAKoE,UAAUsC,MAC3E,IAAK,IAAI8B,EAAIP,EAAW,EAAGO,GAAK,IAC9BF,EAAeL,SAAWO,EAC1B1G,EAAS9B,KAAKuI,YAAYP,EAAMM,EAAgBH,EAAee,IAC3DpH,GAH6B0G,KAOrC,CAEA,IAAK1G,GAAUmG,IAAcjI,KAAKoE,UAAU8B,OAAOC,OAAOC,MAAQpG,KAAKoE,UAAUqE,KAAO,EACtF,IAAK,IAAID,EAAKxI,KAAKoE,UAAU8B,OAAOC,OAAOC,MAAQpG,KAAKoE,UAAUqE,KAAO,EAAID,GAAKP,IAChFK,EAAeL,SAAWO,EAC1B1G,EAAS9B,KAAKuI,YAAYP,EAAMM,EAAgBH,EAAee,IAC3DpH,GAHsF0G,KAS9F,OAAO1G,CACT,CASQ,YAAAsH,CAAaC,EAAqBlE,EAAc6C,GACtD,OAAyB,IAAhBqB,GAAuB,qCAA8BC,SAASnE,EAAKkE,EAAc,OACrFA,EAAcrB,EAAK3F,SAAY8C,EAAK9C,QAAY,qCAA8BiH,SAASnE,EAAKkE,EAAcrB,EAAK3F,SACtH,CAGQ,mBAAAkH,CAAoBF,EAAqBlE,EAAc6C,EAAcG,GAC3E,OAAQA,EAAcqB,WAAaxJ,KAAKoJ,aAAaC,EAAalE,EAAM6C,EAC1E,CASQ,4BAAAU,CAA6BpC,GACnC,OAAgE,IAAzDtG,KAAKoE,UAAU8B,OAAOC,OAAOsD,QAAQnD,IAAMoD,SACpD,CAcQ,WAAAnB,CAAYP,EAAcM,EAAiCH,EAAgC,GAAIe,GAA2B,GAEhI,GAAIA,GAGF,GAAIZ,EAAeL,SAAW,GAAKjI,KAAKoE,UAAU8B,OAAOC,OAAOsD,QAAQnB,EAAeL,WAAWyB,UAEhG,YADApB,EAAeJ,UAAYlI,KAAKoE,UAAUsC,WAO5C,KAAO4B,EAAeL,SAAW,GAAKjI,KAAKoE,UAAU8B,OAAOC,OAAOsD,QAAQnB,EAAeL,WAAWyB,WACnGpB,EAAeL,WACfK,EAAeJ,UAAYlI,KAAKoE,UAAUsC,KAG9C,MAAMJ,EAAMgC,EAAeL,SACrBnC,EAAMwC,EAAeJ,SAE3B,IAAIyB,EAAQ3J,KAAK8H,WAAW8B,iBAAiBtD,GACxCqD,IACHA,EAAQ3J,KAAK8H,WAAW+B,oCAAoCvD,GAAK,GACjEtG,KAAK8H,WAAWgC,eAAexD,EAAKqD,IAEtC,MAAOI,EAAYC,GAAWL,EAExBM,EAASjK,KAAKkK,0BAA0B5D,EAAKR,EAAKkE,GACxD,IAAIG,EAAanC,EACboC,EAAmBL,EAClB5B,EAAckC,QACjBF,EAAahC,EAAcmC,cAAgBtC,EAAOA,EAAKuC,cACvDH,EAAmBjC,EAAcmC,cAAgBP,EAAaA,EAAWQ,eAG3E,IAAIC,GAAe,EACnB,GAAIrC,EAAckC,MAAO,CACvB,MAAMI,EAAcC,OAAOP,EAAYhC,EAAcmC,cAAgB,IAAM,MAC3E,IAAIK,EACJ,GAAIzB,EAEF,KAAOyB,EAAYF,EAAYG,KAAKR,EAAiBxI,MAAM,EAAGqI,KAAU,CACtE,MAAMY,EAAaJ,EAAYK,UAAYH,EAAU,GAAGtI,OACpDsI,EAAU,GAAGtI,OAAS,GAAKrC,KAAKuJ,oBAAoBsB,EAAYT,EAAkBO,EAAU,GAAIxC,KAClGqC,EAAcK,EACd7C,EAAO2C,EAAU,IAEnBF,EAAYK,UAAYD,EAAa,CACvC,MAOA,IADAJ,EAAYK,UAAYb,EACjBU,EAAYF,EAAYG,KAAKR,IAAmB,CACrD,MAAMS,EAAaJ,EAAYK,UAAYH,EAAU,GAAGtI,OACxD,GAAIsI,EAAU,GAAGtI,OAAS,GAAKrC,KAAKuJ,oBAAoBsB,EAAYT,EAAkBO,EAAU,GAAIxC,GAAgB,CAClHqC,EAAcK,EACd7C,EAAO2C,EAAU,GACjB,KACF,CAEAF,EAAYK,UAAYD,EAAa,CACvC,CAEJ,MAAO,GAAI3B,EAAiB,CAC1B,IAAI2B,EAAaZ,EAASE,EAAW9H,QAAU,EAAI+H,EAAiBW,YAAYZ,EAAYF,EAASE,EAAW9H,SAAW,EAE3H,KAAOwI,GAAc,IAAM7K,KAAKuJ,oBAAoBsB,EAAYT,EAAkBD,EAAYhC,IAC5F0C,EAAaA,EAAa,EAAIT,EAAiBW,YAAYZ,EAAYU,EAAa,IAAM,EAE5FL,EAAcK,CAChB,KAAO,CACL,IAAIA,EAAaT,EAAiBpI,QAAQmI,EAAYF,GACtD,KAAOY,GAAc,IAAM7K,KAAKuJ,oBAAoBsB,EAAYT,EAAkBD,EAAYhC,IAC5F0C,EAAaT,EAAiBpI,QAAQmI,EAAYU,EAAa,GAEjEL,EAAcK,CAChB,CAEA,GAAIL,GAAe,EAAG,CAGpB,IAAIQ,EAAiB,EACrB,KAAOA,EAAiBhB,EAAQ3H,OAAS,GAAKmI,GAAeR,EAAQgB,EAAiB,IACpFA,IAEF,IAAIC,EAAeD,EACnB,KAAOC,EAAejB,EAAQ3H,OAAS,GAAKmI,EAAcxC,EAAK3F,QAAU2H,EAAQiB,EAAe,IAC9FA,IAEF,MAAMC,EAAiBV,EAAcR,EAAQgB,GACvCG,EAAeX,EAAcxC,EAAK3F,OAAS2H,EAAQiB,GACnDG,EAAgBpL,KAAKqL,0BAA0B/E,EAAM0E,EAAgBE,GAI3E,MAAO,CACLlD,OACAlC,IAAKsF,EACL9E,IAAKA,EAAM0E,EACXhF,KAPkBhG,KAAKqL,0BAA0B/E,EAAM2E,EAAcE,GAC5CC,EAAgBpL,KAAKoE,UAAUsC,MAAQuE,EAAeD,GAQnF,CACF,CAEQ,yBAAAK,CAA0B/E,EAAa2D,GAC7C,MAAM9E,EAAOnF,KAAKoE,UAAU8B,OAAOC,OAAOsD,QAAQnD,GAClD,IAAKnB,EACH,OAAO,EAET,IAAK,IAAI3C,EAAI,EAAGA,EAAIyH,EAAQzH,IAAK,CAC/B,MAAM8I,EAAOnG,EAAKoG,QAAQ/I,GAC1B,IAAK8I,EACH,MAGF,MAAME,EAAOF,EAAKG,WACdD,EAAKnJ,OAAS,IAChB4H,GAAUuB,EAAKnJ,OAAS,GAI1B,MAAMqJ,EAAWvG,EAAKoG,QAAQ/I,EAAI,GAC9BkJ,GAAoC,IAAxBA,EAASC,YACvB1B,GAEJ,CACA,OAAOA,CACT,CAUQ,yBAAAC,CAA0BjC,EAAkBvB,EAAckF,GAChE,MAAMC,EAAWrF,KAAKC,IAAID,KAAKsF,MAAMpF,EAAO1G,KAAKoE,UAAUsC,MAAOkF,EAAYvJ,OAAS,GACvF,IAAI4H,EAAS2B,EAAYC,GACzB,MAAM1G,EAAOnF,KAAKoE,UAAU8B,OAAOC,OAAOsD,QAAQxB,EAAW4D,GAC7D,GAAI1G,EAAM,CACR,MAAM4G,EAAYvF,KAAKC,IAAIC,EAAOmF,EAAW7L,KAAKoE,UAAUsC,KAAM1G,KAAKoE,UAAUsC,MACjF,IAAK,IAAIlE,EAAI,EAAGA,EAAIuJ,EAAWvJ,IAAK,CAClC,MAAM8I,EAAOnG,EAAKoG,QAAQ/I,GAC1B,IAAK8I,EACH,MAEEA,EAAKK,aAEP1B,GAA6B,IAAnBqB,EAAKU,UAAkB,EAAIV,EAAKG,WAAWpJ,OAEzD,CACF,CACA,OAAO4H,CACT,yFC/aF,MAAAvK,EAAAI,EAAA,KACAmM,EAAAnM,EAAA,KAwBA,MAAAoM,UAAqCxM,EAAAiE,WAanC,WAAA5D,CAA6BqE,GAC3BC,QAD2BrE,KAAAoE,UAAAA,EANrBpE,KAAAmM,mBAAqBnM,KAAK6D,UAAU,IAAInE,EAAA0M,mBACxCpM,KAAAqM,uBAAyBrM,KAAK6D,UAAU,IAAInE,EAAA0M,mBAG5CpM,KAAAsM,qBAAuB,EAI7BtM,KAAK6D,WAAU,EAAAnE,EAAAC,cAAa,IAAMK,KAAKuM,sBACzC,CAKO,cAAAlE,GACArI,KAAKwM,cACRxM,KAAKwM,YAAc,IAAItK,MAAMlC,KAAKoE,UAAU8B,OAAOC,OAAO9D,QAC1DrC,KAAKqM,uBAAuBpI,OAAQ,EAAAvE,EAAA+M,oBAClCzM,KAAKoE,UAAUsI,WAAW,IAAM1M,KAAKuM,sBACrCvM,KAAKoE,UAAUuI,aAAa,IAAM3M,KAAKuM,sBACvCvM,KAAKoE,UAAUwI,SAAS,IAAM5M,KAAKuM,wBAIvCvM,KAAKsM,qBAAuBO,KAAKC,MAC5B9M,KAAKmM,mBAAmBlI,OAC3BjE,KAAK+M,2BAA0B,KAEnC,CAEQ,kBAAAR,GACNvM,KAAKwM,iBAAc5L,EACnBZ,KAAKsM,qBAAuB,EAC5BtM,KAAKqM,uBAAuB3I,QAC5B1D,KAAKmM,mBAAmBzI,OAC1B,CAEQ,0BAAAqJ,CAA2BC,GACjChN,KAAKmM,mBAAmBlI,OAAQ,EAAAgI,EAAAgB,mBAAkB,KAChD,IAAKjN,KAAKwM,YACR,OAEF,MACMU,EADML,KAAKC,MACK9M,KAAKsM,qBACvBY,GAAO,KACTlN,KAAKuM,qBAGPvM,KAAK+M,2BAA2B,KAAqCG,IACpEF,EACL,CAEO,gBAAApD,CAAiBtD,GACtB,OAAOtG,KAAKwM,cAAclG,EAC5B,CAEO,cAAAwD,CAAexD,EAAa5E,GAC7B1B,KAAKwM,cACPxM,KAAKwM,YAAYlG,GAAO5E,EAE5B,CAUO,mCAAAmI,CAAoCsD,EAAmBC,GAC5D,MAAMC,EAAU,GACVzB,EAAc,CAAC,GAIf0B,EAAetN,KAAKoE,UAAU8B,OAAOC,OAAO9D,OAClD,IAAI8C,EAAOnF,KAAKoE,UAAU8B,OAAOC,OAAOsD,QAAQ0D,GAChD,KAAOhI,GAAM,CACX,MAAMoI,EAAWJ,EAAY,EAAIG,EAAetN,KAAKoE,UAAU8B,OAAOC,OAAOsD,QAAQ0D,EAAY,QAAKvM,EAChG4M,IAAkBD,GAAWA,EAAS7D,UAC5C,IAAI+D,EAAStI,EAAKuI,mBAAmBF,GAAmBJ,GACxD,GAAII,GAAmBD,EAAU,CAC/B,MAAMI,EAAWxI,EAAKoG,QAAQpG,EAAK9C,OAAS,GACrBsL,GAAmC,IAAvBA,EAAS3B,WAA2C,IAAxB2B,EAAShC,YAEd,IAApC4B,EAAShC,QAAQ,IAAII,aACzC8B,EAASA,EAAO7L,MAAM,GAAI,GAE9B,CAEA,GADAyL,EAAQxL,KAAK4L,IACTD,EAGF,MAFA5B,EAAY/J,KAAK+J,EAAYA,EAAYvJ,OAAS,GAAKoL,EAAOpL,QAIhE8K,IACAhI,EAAOoI,CACT,CACA,MAAO,CAACF,EAAQO,KAAK,IAAKhC,EAC5B,gHCnIF,MAAAiC,EAAA/N,EAAA,KACAJ,EAAAI,EAAA,KAcA,MAAAgO,UAAyCpO,EAAAiE,WAAzC,WAAA5D,uBACUC,KAAA+N,eAAkC,GAGzB/N,KAAAgO,oBAAsBhO,KAAK6D,UAAU,IAAIgK,EAAAI,QA4F5D,CA3FE,sBAAWC,GAAyD,OAAOlO,KAAKgO,oBAAoB3M,KAAO,CAK3G,iBAAW8M,GACT,OAAOnO,KAAK+N,cACd,CAKA,sBAAWK,GACT,OAAOpO,KAAKqO,mBACd,CAKA,sBAAWD,CAAmBrJ,GAC5B/E,KAAKqO,oBAAsBtJ,CAC7B,CAOO,aAAAuJ,CAAc5J,EAA0B6J,GAC7CvO,KAAK+N,eAAiBrJ,EAAQ9C,MAAM,EAAG2M,EACzC,CAKO,YAAAC,GACLxO,KAAK+N,eAAiB,EACxB,CAKO,uBAAAU,GACDzO,KAAKqO,sBACPrO,KAAKqO,oBAAoB5O,UACzBO,KAAKqO,yBAAsBzN,EAE/B,CAOO,eAAA8N,CAAgB5M,GACrB,IAAK,IAAIU,EAAI,EAAGA,EAAIxC,KAAK+N,eAAe1L,OAAQG,IAAK,CACnD,MAAMoC,EAAQ5E,KAAK+N,eAAevL,GAClC,GAAIoC,EAAM0B,MAAQxE,EAAOwE,KAAO1B,EAAMkB,MAAQhE,EAAOgE,KAAOlB,EAAMoB,OAASlE,EAAOkE,KAChF,OAAOxD,CAEX,CACA,OAAQ,CACV,CAMO,kBAAAmM,CAAmBC,GACxB,IAAKA,EACH,OAGF,IAAIpE,GAAe,EACfxK,KAAKqO,sBACP7D,EAAcxK,KAAK0O,gBAAgB1O,KAAKqO,oBAAoBzJ,QAG9D5E,KAAKgO,oBAAoB5L,KAAK,CAC5BoI,cACAqE,YAAa7O,KAAK+N,eAAe1L,QAErC,CAKO,KAAAyM,GACL9O,KAAKyO,0BACLzO,KAAKwO,cACP,wHC1GF,MAOE,oBAAW5F,GACT,OAAO5I,KAAK+O,iBACd,CAKA,oBAAWnG,CAAiBZ,GAC1BhI,KAAK+O,kBAAoB/G,CAC3B,CAKA,qBAAWgH,GACT,OAAOhP,KAAKiP,kBACd,CAKA,qBAAWD,CAAkBrK,GAC3B3E,KAAKiP,mBAAqBtK,CAC5B,CAOO,iBAAAuK,CAAkBlH,GACvB,SAAUA,GAAQA,EAAK3F,OAAS,EAClC,CAOO,gBAAA8M,CAAiBC,GACtB,OAAKpP,KAAKiP,sBAGLG,IAGDpP,KAAKiP,mBAAmB3E,gBAAkB8E,EAAW9E,eAGrDtK,KAAKiP,mBAAmB5E,QAAU+E,EAAW/E,OAG7CrK,KAAKiP,mBAAmBzF,YAAc4F,EAAW5F,UAIvD,CAQO,wBAAA6F,CAAyBrH,EAAcrD,GAC5C,QAAKA,GAASE,mBAGoBjE,IAA3BZ,KAAK+O,mBACL/G,IAAShI,KAAK+O,mBACd/O,KAAKmP,iBAAiBxK,GAC/B,CAKO,eAAA2K,GACLtP,KAAK+O,uBAAoBnO,CAC3B,CAKO,KAAAkO,GACL9O,KAAK+O,uBAAoBnO,EACzBZ,KAAKiP,wBAAqBrO,CAC5B,KCvGF2O,EAAA,GAGA,SAAAzP,EAAA0P,GAEA,IAAAC,EAAAF,EAAAC,GACA,QAAA5O,IAAA6O,EACA,OAAAA,EAAA9Q,QAGA,IAAAC,EAAA2Q,EAAAC,GAAA,CAGA7Q,QAAA,IAOA,OAHA+Q,EAAAF,GAAA5Q,EAAAA,EAAAD,QAAAmB,GAGAlB,EAAAD,OACA,oGCfA,MAAAkP,EAAA/N,EAAA,KACAJ,EAAAI,EAAA,KACAmM,EAAAnM,EAAA,KACA6P,EAAA7P,EAAA,KACA8P,EAAA9P,EAAA,KACA+P,EAAA/P,EAAA,KACAgQ,EAAAhQ,EAAA,KACAiQ,EAAAjQ,EAAA,KAkBA,MAAAkQ,UAAiCtQ,EAAAiE,WAiB/B,sBAAWuK,GACT,OAAOlO,KAAKiQ,eAAe/B,kBAC7B,CAEA,WAAAnO,CAAY4E,GACVN,QAnBMrE,KAAAkQ,kBAAoBlQ,KAAK6D,UAAU,IAAInE,EAAA0M,mBACvCpM,KAAA8H,WAAa9H,KAAK6D,UAAU,IAAInE,EAAA0M,mBAGhCpM,KAAAmQ,OAAS,IAAIP,EAAAQ,YAGbpQ,KAAAiQ,eAAiBjQ,KAAK6D,UAAU,IAAIkM,EAAAjC,qBAE3B9N,KAAAqQ,eAAiBrQ,KAAK6D,UAAU,IAAIgK,EAAAI,SACrCjO,KAAAsQ,cAAgBtQ,KAAKqQ,eAAehP,MACnCrB,KAAAuQ,gBAAkBvQ,KAAK6D,UAAU,IAAIgK,EAAAI,SACtCjO,KAAAwQ,eAAiBxQ,KAAKuQ,gBAAgBlP,MASpDrB,KAAKyQ,gBAAkB9L,GAAS+L,gBAAc,GAChD,CAEO,QAAAC,CAASC,GACd5Q,KAAKoE,UAAYwM,EACjB5Q,KAAK8H,WAAW7D,MAAQ,IAAI0L,EAAAzD,gBAAgB0E,GAC5C5Q,KAAK6Q,QAAU,IAAIhB,EAAAiB,aAAaF,EAAU5Q,KAAK8H,WAAW7D,OAC1DjE,KAAK+Q,mBAAqB,IAAIjB,EAAA3L,kBAAkByM,GAChD5Q,KAAK6D,UAAU7D,KAAKoE,UAAU4M,cAAc,IAAMhR,KAAKiR,mBACvDjR,KAAK6D,UAAU7D,KAAKoE,UAAUwI,SAAS,IAAM5M,KAAKiR,mBAClDjR,KAAK6D,WAAU,EAAAnE,EAAAC,cAAa,IAAMK,KAAKkR,oBACzC,CAEQ,cAAAD,GACNjR,KAAKkQ,kBAAkBxM,QACnB1D,KAAKmQ,OAAOvH,kBAAoB5I,KAAKmQ,OAAOnB,mBAAmBnK,cACjE7E,KAAKkQ,kBAAkBjM,OAAQ,EAAAgI,EAAAgB,mBAAkB,KAC/C,MAAMjF,EAAOhI,KAAKmQ,OAAOvH,iBACzB5I,KAAKmQ,OAAOb,kBACZtP,KAAKmR,aAAanJ,EAAO,IAAKhI,KAAKmQ,OAAOnB,kBAAmBoC,aAAa,GAAQ,CAAEC,UAAU,KAC7F,KAEP,CAEO,gBAAAH,CAAiBI,GACtBtR,KAAKiQ,eAAexB,0BACpBzO,KAAK+Q,oBAAoBvM,4BACzBxE,KAAKiQ,eAAezB,eACf8C,GACHtR,KAAKmQ,OAAOb,iBAEhB,CAEO,qBAAAiC,GACLvR,KAAKiQ,eAAexB,yBACtB,CASO,QAAA+C,CAASxJ,EAAcG,EAAgCsJ,GAC5D,IAAKzR,KAAKoE,YAAcpE,KAAK6Q,QAC3B,MAAM,IAAIvQ,MAAM,6CAGlBN,KAAKuQ,gBAAgBnO,OAErBpC,KAAKmQ,OAAOnB,kBAAoB7G,EAE5BnI,KAAKmQ,OAAOd,yBAAyBrH,EAAMG,IAC7CnI,KAAK0R,qBAAqB1J,EAAMG,GAGlC,MAAMwJ,EAAQ3R,KAAK4R,mBAAmB5J,EAAMG,EAAesJ,GAM3D,OALAzR,KAAK6R,aAAa1J,GAClBnI,KAAKmQ,OAAOvH,iBAAmBZ,EAE/BhI,KAAKqQ,eAAejO,OAEbuP,CACT,CAEQ,oBAAAD,CAAqB1J,EAAcG,GACzC,IAAKnI,KAAKoE,YAAcpE,KAAK6Q,UAAY7Q,KAAK+Q,mBAC5C,MAAM,IAAIzQ,MAAM,6CAElB,IAAKN,KAAKmQ,OAAOjB,kBAAkBlH,GAEjC,YADAhI,KAAKkR,mBAKPlR,KAAKkR,kBAAiB,GAEtB,MAAMxM,EAA2B,GACjC,IAAIoN,EACAhQ,EAAS9B,KAAK6Q,QAAQ9I,KAAKC,EAAM,EAAG,EAAGG,GAE3C,KAAOrG,IAAWgQ,GAAYxL,MAAQxE,EAAOwE,KAAOwL,GAAYhM,MAAQhE,EAAOgE,QACzEpB,EAAQrC,QAAUrC,KAAKyQ,kBADwD,CAInFqB,EAAahQ,EACb4C,EAAQ7C,KAAKiQ,GACb,MAAMpL,EAAO1G,KAAKoE,UAAUsC,KAC5B,IAAIqL,EAAUD,EAAWhM,IAAMgM,EAAW9L,KACtCgM,EAAUF,EAAWxL,IACrByL,GAAWrL,IACbsL,GAAWxL,KAAKsF,MAAMiG,EAAUrL,GAChCqL,GAAoBrL,GAEtB5E,EAAS9B,KAAK6Q,QAAQ9I,KAAKC,EAAMgK,EAASD,EAAS5J,EACrD,CAEAnI,KAAKiQ,eAAe3B,cAAc5J,EAAS1E,KAAKyQ,iBAC5CtI,EAActD,aAChB7E,KAAK+Q,mBAAmBtM,2BAA2BC,EAASyD,EAActD,YAE9E,CAEQ,kBAAA+M,CAAmB5J,EAAcG,EAAgCsJ,GACvE,IAAKzR,KAAKoE,YAAcpE,KAAK6Q,QAC3B,OAAO,EAET,IAAK7Q,KAAKmQ,OAAOjB,kBAAkBlH,GAGjC,OAFAhI,KAAKoE,UAAUgE,iBACfpI,KAAKkR,oBACE,EAGT,MAAMpP,EAAS9B,KAAK6Q,QAAQlI,sBAAsBX,EAAMG,EAAenI,KAAKmQ,OAAOvH,kBACnF,OAAO5I,KAAKiS,cAAcnQ,EAAQqG,GAAetD,YAAa4M,GAAuBJ,SACvF,CASO,YAAAF,CAAanJ,EAAcG,EAAgCsJ,GAChE,IAAKzR,KAAKoE,YAAcpE,KAAK6Q,QAC3B,MAAM,IAAIvQ,MAAM,6CAGlBN,KAAKuQ,gBAAgBnO,OAErBpC,KAAKmQ,OAAOnB,kBAAoB7G,EAE5BnI,KAAKmQ,OAAOd,yBAAyBrH,EAAMG,IAC7CnI,KAAK0R,qBAAqB1J,EAAMG,GAGlC,MAAMwJ,EAAQ3R,KAAKkS,uBAAuBlK,EAAMG,EAAesJ,GAM/D,OALAzR,KAAK6R,aAAa1J,GAClBnI,KAAKmQ,OAAOvH,iBAAmBZ,EAE/BhI,KAAKqQ,eAAejO,OAEbuP,CACT,CAEQ,YAAAE,CAAa1J,GACnBnI,KAAKiQ,eAAetB,qBAAqBxG,GAAetD,YAC1D,CAEQ,sBAAAqN,CAAuBlK,EAAcG,EAAgCsJ,GAC3E,IAAKzR,KAAKoE,YAAcpE,KAAK6Q,QAC3B,OAAO,EAET,IAAK7Q,KAAKmQ,OAAOjB,kBAAkBlH,GAGjC,OAFAhI,KAAKoE,UAAUgE,iBACfpI,KAAKkR,oBACE,EAGT,MAAMpP,EAAS9B,KAAK6Q,QAAQ5H,0BAA0BjB,EAAMG,EAAenI,KAAKmQ,OAAOvH,kBACvF,OAAO5I,KAAKiS,cAAcnQ,EAAQqG,GAAetD,YAAa4M,GAAuBJ,SACvF,CAOQ,aAAAY,CAAcnQ,EAAmC6C,EAAoC0M,GAC3F,IAAKrR,KAAKoE,YAAcpE,KAAK+Q,mBAC3B,OAAO,EAIT,GADA/Q,KAAKiQ,eAAexB,2BACf3M,EAEH,OADA9B,KAAKoE,UAAUgE,kBACR,EAIT,GADApI,KAAKoE,UAAU+N,OAAOrQ,EAAOgE,IAAKhE,EAAOwE,IAAKxE,EAAOkE,MACjDrB,EAAS,CACX,MAAMyN,EAAmBpS,KAAK+Q,mBAAmB9L,uBAAuBnD,EAAQ6C,GAC5EyN,IACFpS,KAAKiQ,eAAe7B,mBAAqBgE,EAE7C,CAEA,IAAKf,IAECvP,EAAOwE,KAAQtG,KAAKoE,UAAU8B,OAAOC,OAAOkM,UAAYrS,KAAKoE,UAAUqE,MAAS3G,EAAOwE,IAAMtG,KAAKoE,UAAU8B,OAAOC,OAAOkM,WAAW,CACvI,IAAIC,EAASxQ,EAAOwE,IAAMtG,KAAKoE,UAAU8B,OAAOC,OAAOkM,UACvDC,GAAU9L,KAAKsF,MAAM9L,KAAKoE,UAAUqE,KAAO,GAC3CzI,KAAKoE,UAAUmO,YAAYD,EAC7B,CAEF,OAAO,CACT","sources":["webpack://SearchAddon/webpack/universalModuleDefinition","webpack://SearchAddon/../src/common/Async.ts","webpack://SearchAddon/../src/common/Event.ts","webpack://SearchAddon/../src/common/Lifecycle.ts","webpack://SearchAddon/./src/DecorationManager.ts","webpack://SearchAddon/./src/SearchEngine.ts","webpack://SearchAddon/./src/SearchLineCache.ts","webpack://SearchAddon/./src/SearchResultTracker.ts","webpack://SearchAddon/./src/SearchState.ts","webpack://SearchAddon/webpack/bootstrap","webpack://SearchAddon/./src/SearchAddon.ts"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"SearchAddon\"] = factory();\n\telse\n\t\troot[\"SearchAddon\"] = factory();\n})(globalThis, () => {\nreturn ","/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal async helpers for xterm.js core.\n */\n\nimport { DisposableStore, IDisposable, toDisposable } from './Lifecycle';\n\nexport function timeout(millis: number): Promise {\n return new Promise(resolve => setTimeout(resolve, millis));\n}\n\n/**\n * Creates a timeout that can be disposed using its returned value.\n * @param handler The timeout handler.\n * @param timeout An optional timeout in milliseconds.\n * @param store An optional {@link DisposableStore} that will have the timeout disposable managed\n * automatically.\n */\nexport function disposableTimeout(handler: () => void, timeout = 0, store?: DisposableStore): IDisposable {\n const timer = setTimeout(() => {\n handler();\n if (store) {\n disposable.dispose();\n }\n }, timeout);\n const disposable = toDisposable(() => {\n clearTimeout(timer);\n });\n store?.add(disposable);\n return disposable;\n}\n\nexport class TimeoutTimer implements IDisposable {\n private _token: any = -1;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n if (this._token !== -1) {\n clearTimeout(this._token);\n this._token = -1;\n }\n }\n\n public cancelAndSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed TimeoutTimer');\n }\n this.cancel();\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n\n public setIfNotSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling setIfNotSet on a disposed TimeoutTimer');\n }\n if (this._token !== -1) {\n return;\n }\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n}\n\n/**\n * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled\n * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not\n * run yet.\n */\nexport class MicrotaskTimer implements IDisposable {\n private _isScheduled = false;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n this._isScheduled = false;\n }\n\n public set(runner: () => void): void {\n if (this._isDisposed) {\n throw new Error('Calling set on a disposed MicrotaskTimer');\n }\n if (this._isScheduled) {\n return;\n }\n this._isScheduled = true;\n queueMicrotask(() => {\n if (!this._isScheduled) {\n return;\n }\n this._isScheduled = false;\n runner();\n });\n }\n}\n\nexport class IntervalTimer implements IDisposable {\n private _disposable: IDisposable | undefined;\n private _isDisposed = false;\n\n public cancel(): void {\n this._disposable?.dispose();\n this._disposable = undefined;\n }\n\n public cancelAndSet(runner: () => void, interval: number, context: Window | typeof globalThis = globalThis): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed IntervalTimer');\n }\n this.cancel();\n const handle = context.setInterval(() => {\n runner();\n }, interval);\n this._disposable = {\n dispose: () => {\n context.clearInterval(handle as any);\n this._disposable = undefined;\n }\n };\n }\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n","/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';\nimport type { ISearchDecorationOptions } from '@xterm/addon-search';\nimport { dispose, Disposable, toDisposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a highlight decoration.\n */\ninterface IHighlight extends IDisposable {\n decoration: IDecoration;\n match: ISearchResult;\n}\n\n/**\n * Interface for managing multiple decorations for a single match.\n */\ninterface IMultiHighlight extends IDisposable {\n decorations: IDecoration[];\n match: ISearchResult;\n}\n\n/**\n * Manages visual decorations for search results including highlighting and active selection\n * indicators. This class handles the creation, styling, and disposal of search-related decorations.\n */\nexport class DecorationManager extends Disposable {\n private _highlightDecorations: IHighlight[] = [];\n private _highlightedLines: Set = new Set();\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this.clearHighlightDecorations()));\n }\n\n /**\n * Creates decorations for all provided search results.\n * @param results The search results to create decorations for.\n * @param options The decoration options.\n */\n public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {\n this.clearHighlightDecorations();\n\n for (const match of results) {\n const decorations = this._createResultDecorations(match, options, false);\n if (decorations) {\n for (const decoration of decorations) {\n this._storeDecoration(decoration, match);\n }\n }\n }\n }\n\n /**\n * Creates decorations for the currently active search result.\n * @param result The active search result.\n * @param options The decoration options.\n * @returns The multi-highlight decoration or undefined if creation failed.\n */\n public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {\n const decorations = this._createResultDecorations(result, options, true);\n if (decorations) {\n return { decorations, match: result, dispose() { dispose(decorations); } };\n }\n return undefined;\n }\n\n /**\n * Clears all highlight decorations.\n */\n public clearHighlightDecorations(): void {\n dispose(this._highlightDecorations);\n this._highlightDecorations = [];\n this._highlightedLines.clear();\n }\n\n /**\n * Stores a decoration and tracks it for management.\n * @param decoration The decoration to store.\n * @param match The search result this decoration represents.\n */\n private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {\n this._highlightedLines.add(decoration.marker.line);\n this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });\n }\n\n /**\n * Applies styles to the decoration when it is rendered.\n * @param element The decoration's element.\n * @param borderColor The border color to apply.\n * @param isActiveResult Whether the element is part of the active search result.\n */\n private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {\n if (!element.classList.contains('xterm-find-result-decoration')) {\n element.classList.add('xterm-find-result-decoration');\n if (borderColor) {\n element.style.outline = `1px solid ${borderColor}`;\n }\n }\n if (isActiveResult) {\n element.classList.add('xterm-find-active-result-decoration');\n }\n }\n\n /**\n * Creates a decoration for the result and applies styles\n * @param result the search result for which to create the decoration\n * @param options the options for the decoration\n * @param isActiveResult whether this is the currently active result\n * @returns the decorations or undefined if the marker has already been disposed of\n */\n private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {\n // Gather decoration ranges for this match as it could wrap\n const decorationRanges: [number, number, number][] = [];\n let currentCol = result.col;\n let remainingSize = result.size;\n let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;\n while (remainingSize > 0) {\n const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);\n decorationRanges.push([markerOffset, currentCol, amountThisRow]);\n currentCol = 0;\n remainingSize -= amountThisRow;\n markerOffset++;\n }\n\n // Create the decorations\n const decorations: IDecoration[] = [];\n for (const range of decorationRanges) {\n const marker = this._terminal.registerMarker(range[0]);\n const decoration = this._terminal.registerDecoration({\n marker,\n x: range[1],\n width: range[2],\n layer: isActiveResult ? 'top' : 'bottom',\n backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,\n overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {\n color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,\n position: 'center'\n }\n });\n if (decoration) {\n const disposables: IDisposable[] = [];\n disposables.push(marker);\n disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));\n disposables.push(decoration.onDispose(() => dispose(disposables)));\n decorations.push(decoration);\n }\n }\n\n return decorations.length === 0 ? undefined : decorations;\n }\n}\n\n\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport type { ISearchOptions } from '@xterm/addon-search';\nimport type { SearchLineCache } from './SearchLineCache';\n\n/**\n * Represents the position to start a search from.\n */\ninterface ISearchPosition {\n startCol: number;\n startRow: number;\n}\n\n/**\n * Represents a search result with its position and content.\n */\nexport interface ISearchResult {\n term: string;\n col: number;\n row: number;\n size: number;\n}\n\n/**\n * Configuration constants for the search engine functionality.\n */\nconst enum Constants {\n /**\n * Characters that are considered non-word characters for search boundary detection. These\n * characters are used to determine word boundaries when performing whole-word searches. Includes\n * common punctuation, symbols, and whitespace characters.\n */\n NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\\\;:\"\\',./<>?'\n}\n\n/**\n * Core search engine that handles finding text within terminal content.\n * This class is responsible for the actual search algorithms and position calculations.\n */\nexport class SearchEngine {\n constructor(\n private readonly _terminal: Terminal,\n private readonly _lineCache: SearchLineCache\n ) {}\n\n /**\n * Find the first occurrence of a term starting from a specific position.\n * @param term The search term.\n * @param startRow The row to start searching from.\n * @param startCol The column to start searching from.\n * @param searchOptions Search options.\n * @returns The search result if found, undefined otherwise.\n */\n public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n if (startCol >= this._terminal.cols) {\n throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n if (this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n return result;\n }\n\n /**\n * Find the next occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine incremental behavior.\n * @returns The search result if found, undefined otherwise.\n */\n public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startCol = 0;\n let startRow = 0;\n if (prevSelectedPos) {\n if (cachedSearchTerm === term) {\n startCol = prevSelectedPos.end.x;\n startRow = prevSelectedPos.end.y;\n } else {\n startCol = prevSelectedPos.start.x;\n startRow = prevSelectedPos.start.y;\n }\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n if (this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n // If we hit the bottom and didn't search from the very top wrap back up\n if (!result && startRow !== 0) {\n for (let y = 0; y < startRow; y++) {\n // Row 0 is never skipped: it can be a continuation whose line start was trimmed from the\n // scrollback, and nothing earlier in this loop has searched it.\n if (y > 0 && this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n\n // If there is only one result, wrap back and return selection if it exists.\n if (!result && prevSelectedPos) {\n searchPosition.startRow = prevSelectedPos.start.y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n }\n\n return result;\n }\n\n /**\n * Find the previous occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine if expansion should occur.\n * @returns The search result if found, undefined otherwise.\n */\n public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;\n const startCol = this._terminal.cols;\n const isReverseSearch = true;\n\n this._lineCache.initLinesCache();\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n let result: ISearchResult | undefined;\n if (prevSelectedPos) {\n searchPosition.startRow = startRow = prevSelectedPos.start.y;\n searchPosition.startCol = prevSelectedPos.start.x;\n if (cachedSearchTerm !== term) {\n // Try to expand selection to right first.\n result = this._findInLine(term, searchPosition, searchOptions, false);\n if (!result) {\n // If selection was not able to be expanded to the right, then try reverse search\n searchPosition.startRow = startRow = prevSelectedPos.end.y;\n searchPosition.startCol = prevSelectedPos.end.x;\n }\n }\n }\n\n result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n\n // Search from startRow - 1 to top\n if (!result) {\n searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);\n for (let y = startRow - 1; y >= 0; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n // If we hit the top and didn't search from the very bottom wrap back down\n if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {\n for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n\n return result;\n }\n\n /**\n * A found substring is a whole word if it doesn't have an alphanumeric character directly\n * adjacent to it.\n * @param searchIndex starting index of the potential whole word substring\n * @param line entire string in which the potential whole word was found\n * @param term the substring that starts at searchIndex\n */\n private _isWholeWord(searchIndex: number, line: string, term: string): boolean {\n return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&\n (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));\n }\n\n /** `_isWholeWord` gated on the option, so a rejected hit can be stepped past instead of ending the scan. */\n private _satisfiesWholeWord(searchIndex: number, line: string, term: string, searchOptions: ISearchOptions): boolean {\n return !searchOptions.wholeWord || this._isWholeWord(searchIndex, line, term);\n }\n\n /**\n * Whether an earlier `_findInLine` in this same call already scanned this row's line from an\n * equal or lower offset, which makes rescanning it pure O(rows^2) work on one long line. Sound\n * for every option because `_findInLine` returns the first accepted match at or after its\n * offset, which is monotone in that offset. Only valid once such a search has happened — the\n * wrap-around loop starts at row 0, whose line start may have been trimmed from the scrollback.\n */\n private _isRowCoveredByEarlierSearch(row: number): boolean {\n return this._terminal.buffer.active.getLine(row)?.isWrapped === true;\n }\n\n /**\n * Searches a line for a search term. Takes the provided terminal line and searches the text line,\n * which may contain subsequent terminal lines if the text is wrapped. If the provided line number\n * is part of a wrapped text line that started on an earlier line then it is skipped since it will\n * be properly searched when the terminal line that the text starts on is searched.\n * @param term The search term.\n * @param searchPosition The position to start the search.\n * @param searchOptions Search options.\n * @param isReverseSearch Whether the search should start from the right side of the terminal and\n * search to the left.\n * @returns The search result if it was found.\n */\n private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {\n // Ignore wrapped lines, only consider on unwrapped line (first row of command string).\n if (isReverseSearch) {\n // Reverse search never rewinds: its caller carries startCol down the rows of the line. Row 0\n // is searched even when wrapped, since its line start may have been trimmed from the scrollback.\n if (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {\n searchPosition.startCol += this._terminal.cols;\n return;\n }\n } else {\n // A loop rather than recursion: one frame per wrapped row overflows the stack on a line long\n // enough to fill the scrollback. Bounded at row 0 because after a reflow the buffer's ring\n // holds stale entries at negative indices, so `getLine(-1)` answers with a wrapped line.\n while (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {\n searchPosition.startRow--;\n searchPosition.startCol += this._terminal.cols;\n }\n }\n const row = searchPosition.startRow;\n const col = searchPosition.startCol;\n\n let cache = this._lineCache.getLineFromCache(row);\n if (!cache) {\n cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);\n this._lineCache.setLineInCache(row, cache);\n }\n const [stringLine, offsets] = cache;\n\n const offset = this._bufferColsToStringOffset(row, col, offsets);\n let searchTerm = term;\n let searchStringLine = stringLine;\n if (!searchOptions.regex) {\n searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();\n searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();\n }\n\n let resultIndex = -1;\n if (searchOptions.regex) {\n const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');\n let foundTerm: RegExpExecArray | null;\n if (isReverseSearch) {\n // This loop will get the resultIndex of the _last_ regex match in the range 0..offset\n while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {\n const matchIndex = searchRegex.lastIndex - foundTerm[0].length;\n if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) {\n resultIndex = matchIndex;\n term = foundTerm[0];\n }\n searchRegex.lastIndex = matchIndex + 1;\n }\n } else {\n // Driven over the whole line from `offset` rather than over `slice(offset)`: a slice\n // re-anchors ^ and \\b at whatever column the row happened to wrap at, and only\n // first-accepted-match-at-or-after-offset is monotone in `offset`, which is what lets\n // `_isRowCoveredByEarlierSearch` skip a wrapped row an earlier scan already covered.\n searchRegex.lastIndex = offset;\n while (foundTerm = searchRegex.exec(searchStringLine)) {\n const matchIndex = searchRegex.lastIndex - foundTerm[0].length;\n if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) {\n resultIndex = matchIndex;\n term = foundTerm[0];\n break;\n }\n // A zero-length or rejected match would otherwise repeat forever.\n searchRegex.lastIndex = matchIndex + 1;\n }\n }\n } else if (isReverseSearch) {\n let matchIndex = offset - searchTerm.length >= 0 ? searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length) : -1;\n // `lastIndexOf` clamps a negative fromIndex to 0, so index 0 has to end the walk.\n while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) {\n matchIndex = matchIndex > 0 ? searchStringLine.lastIndexOf(searchTerm, matchIndex - 1) : -1;\n }\n resultIndex = matchIndex;\n } else {\n let matchIndex = searchStringLine.indexOf(searchTerm, offset);\n while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) {\n matchIndex = searchStringLine.indexOf(searchTerm, matchIndex + 1);\n }\n resultIndex = matchIndex;\n }\n\n if (resultIndex >= 0) {\n // Adjust the row number and search index if needed since a \"line\" of text can span multiple\n // rows\n let startRowOffset = 0;\n while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {\n startRowOffset++;\n }\n let endRowOffset = startRowOffset;\n while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {\n endRowOffset++;\n }\n const startColOffset = resultIndex - offsets[startRowOffset];\n const endColOffset = resultIndex + term.length - offsets[endRowOffset];\n const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);\n const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);\n const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);\n\n return {\n term,\n col: startColIndex,\n row: row + startRowOffset,\n size\n };\n }\n }\n\n private _stringLengthToBufferSize(row: number, offset: number): number {\n const line = this._terminal.buffer.active.getLine(row);\n if (!line) {\n return 0;\n }\n for (let i = 0; i < offset; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n // Adjust the searchIndex to normalize emoji into single chars\n const char = cell.getChars();\n if (char.length > 1) {\n offset -= char.length - 1;\n }\n // Adjust the searchIndex for empty characters following wide unicode\n // chars (eg. CJK)\n const nextCell = line.getCell(i + 1);\n if (nextCell && nextCell.getWidth() === 0) {\n offset++;\n }\n }\n return offset;\n }\n\n /**\n * `cols` counts from the start of the logical line, so summing the cells of every row before the\n * resume point costs O(line) per call and the highlight-all pass makes one call per match.\n * `lineOffsets` already holds the string offset each wrapped row starts at — the same map used\n * above to turn a match index back into a row — so only the last, partial row needs cells. It is\n * also the map the row a match lands on is read from, which the cell sum disagreed with by one\n * for a row whose trailing cell is the null placeholder of a wide character that wrapped.\n */\n private _bufferColsToStringOffset(startRow: number, cols: number, lineOffsets: number[]): number {\n const rowsBack = Math.min(Math.floor(cols / this._terminal.cols), lineOffsets.length - 1);\n let offset = lineOffsets[rowsBack];\n const line = this._terminal.buffer.active.getLine(startRow + rowsBack);\n if (line) {\n const colsInRow = Math.min(cols - rowsBack * this._terminal.cols, this._terminal.cols);\n for (let i = 0; i < colsInRow; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n if (cell.getWidth()) {\n // Treat null characters as whitespace to align with the translateToString API\n offset += cell.getCode() === 0 ? 1 : cell.getChars().length;\n }\n }\n }\n return offset;\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\n\nexport type LineCacheEntry = [\n /**\n * The string representation of a line (as opposed to the buffer cell representation).\n */\n lineAsString: string,\n /**\n * The offsets where each line starts when the entry describes a wrapped line.\n */\n lineOffsets: number[]\n];\n\n/**\n * Configuration constants for the search line cache functionality.\n */\nconst enum Constants {\n /**\n * Time-to-live for cached search results in milliseconds. After this duration, cached search\n * results will be invalidated to ensure they remain consistent with terminal content changes.\n */\n LINES_CACHE_TIME_TO_LIVE = 15000\n}\n\nexport class SearchLineCache extends Disposable {\n /**\n * translateBufferLineToStringWithWrap is a fairly expensive call.\n * We memoize the calls into an array that has a time based ttl.\n * _linesCache is also invalidated when the terminal cursor moves.\n */\n private _linesCache: LineCacheEntry[] | undefined;\n private _linesCacheTimeout = this._register(new MutableDisposable());\n private _linesCacheDisposables = this._register(new MutableDisposable());\n // Track access to avoid recreating a timeout on every init call which occurs once per search\n // result (findNext/findPrevious -> _highlightAllMatches -> find loop).\n private _lastAccessTimestamp = 0;\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this._destroyLinesCache()));\n }\n\n /**\n * Sets up a line cache with a ttl\n */\n public initLinesCache(): void {\n if (!this._linesCache) {\n this._linesCache = new Array(this._terminal.buffer.active.length);\n this._linesCacheDisposables.value = combinedDisposable(\n this._terminal.onLineFeed(() => this._destroyLinesCache()),\n this._terminal.onCursorMove(() => this._destroyLinesCache()),\n this._terminal.onResize(() => this._destroyLinesCache())\n );\n }\n\n this._lastAccessTimestamp = Date.now();\n if (!this._linesCacheTimeout.value) {\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);\n }\n }\n\n private _destroyLinesCache(): void {\n this._linesCache = undefined;\n this._lastAccessTimestamp = 0;\n this._linesCacheDisposables.clear();\n this._linesCacheTimeout.clear();\n }\n\n private _scheduleLinesCacheTimeout(delay: number): void {\n this._linesCacheTimeout.value = disposableTimeout(() => {\n if (!this._linesCache) {\n return;\n }\n const now = Date.now();\n const elapsed = now - this._lastAccessTimestamp;\n if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {\n this._destroyLinesCache();\n return;\n }\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);\n }, delay);\n }\n\n public getLineFromCache(row: number): LineCacheEntry | undefined {\n return this._linesCache?.[row];\n }\n\n public setLineInCache(row: number, entry: LineCacheEntry): void {\n if (this._linesCache) {\n this._linesCache[row] = entry;\n }\n }\n\n /**\n * Translates a buffer line to a string, including subsequent lines if they are wraps.\n * Wide characters will count as two columns in the resulting string. This\n * function is useful for getting the actual text underneath the raw selection\n * position.\n * @param lineIndex The index of the line being translated.\n * @param trimRight Whether to trim whitespace to the right.\n */\n public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {\n const strings = [];\n const lineOffsets = [0];\n // A single line longer than the whole scrollback leaves every buffer row wrapped, and the\n // buffer's ring answers an out-of-range row by cycling back to the start, so an unbounded walk\n // never reaches an unwrapped line.\n const bufferLength = this._terminal.buffer.active.length;\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (line) {\n const nextLine = lineIndex + 1 < bufferLength ? this._terminal.buffer.active.getLine(lineIndex + 1) : undefined;\n const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;\n let string = line.translateToString(!lineWrapsToNext && trimRight);\n if (lineWrapsToNext && nextLine) {\n const lastCell = line.getCell(line.length - 1);\n const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;\n // a wide character wrapped to the next line\n if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {\n string = string.slice(0, -1);\n }\n }\n strings.push(string);\n if (lineWrapsToNext) {\n lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);\n } else {\n break;\n }\n lineIndex++;\n line = nextLine;\n }\n return [strings.join(''), lineOffsets];\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchResultChangeEvent } from '@xterm/addon-search';\nimport type { IDisposable } from '@xterm/xterm';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a currently selected decoration.\n */\ninterface ISelectedDecoration extends IDisposable {\n match: ISearchResult;\n}\n\n/**\n * Tracks search results, manages result indexing, and fires events when results change.\n * This class provides centralized management of search result state and notifications.\n */\nexport class SearchResultTracker extends Disposable {\n private _searchResults: ISearchResult[] = [];\n private _selectedDecoration: ISelectedDecoration | undefined;\n\n private readonly _onDidChangeResults = this._register(new Emitter());\n public get onDidChangeResults(): IEvent { return this._onDidChangeResults.event; }\n\n /**\n * Gets the current search results.\n */\n public get searchResults(): ReadonlyArray {\n return this._searchResults;\n }\n\n /**\n * Gets the currently selected decoration.\n */\n public get selectedDecoration(): ISelectedDecoration | undefined {\n return this._selectedDecoration;\n }\n\n /**\n * Sets the currently selected decoration.\n */\n public set selectedDecoration(decoration: ISelectedDecoration | undefined) {\n this._selectedDecoration = decoration;\n }\n\n /**\n * Updates the search results with a new set of results.\n * @param results The new search results.\n * @param maxResults The maximum number of results to track.\n */\n public updateResults(results: ISearchResult[], maxResults: number): void {\n this._searchResults = results.slice(0, maxResults);\n }\n\n /**\n * Clears all search results.\n */\n public clearResults(): void {\n this._searchResults = [];\n }\n\n /**\n * Clears the selected decoration.\n */\n public clearSelectedDecoration(): void {\n if (this._selectedDecoration) {\n this._selectedDecoration.dispose();\n this._selectedDecoration = undefined;\n }\n }\n\n /**\n * Finds the index of a result in the current results array.\n * @param result The result to find.\n * @returns The index of the result, or -1 if not found.\n */\n public findResultIndex(result: ISearchResult): number {\n for (let i = 0; i < this._searchResults.length; i++) {\n const match = this._searchResults[i];\n if (match.row === result.row && match.col === result.col && match.size === result.size) {\n return i;\n }\n }\n return -1;\n }\n\n /**\n * Fires a result change event with the current state.\n * @param hasDecorations Whether decorations are enabled.\n */\n public fireResultsChanged(hasDecorations: boolean): void {\n if (!hasDecorations) {\n return;\n }\n\n let resultIndex = -1;\n if (this._selectedDecoration) {\n resultIndex = this.findResultIndex(this._selectedDecoration.match);\n }\n\n this._onDidChangeResults.fire({\n resultIndex,\n resultCount: this._searchResults.length\n });\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this.clearSelectedDecoration();\n this.clearResults();\n }\n}\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchOptions } from '@xterm/addon-search';\n\n/**\n * Manages search state including cached search terms, options tracking, and validation.\n * This class provides a centralized way to handle search state consistency and option changes.\n */\nexport class SearchState {\n private _cachedSearchTerm: string | undefined;\n private _lastSearchOptions: ISearchOptions | undefined;\n\n /**\n * Gets the currently cached search term.\n */\n public get cachedSearchTerm(): string | undefined {\n return this._cachedSearchTerm;\n }\n\n /**\n * Sets the cached search term.\n */\n public set cachedSearchTerm(term: string | undefined) {\n this._cachedSearchTerm = term;\n }\n\n /**\n * Gets the last search options used.\n */\n public get lastSearchOptions(): ISearchOptions | undefined {\n return this._lastSearchOptions;\n }\n\n /**\n * Sets the last search options used.\n */\n public set lastSearchOptions(options: ISearchOptions | undefined) {\n this._lastSearchOptions = options;\n }\n\n /**\n * Validates a search term to ensure it's not empty or invalid.\n * @param term The search term to validate.\n * @returns true if the term is valid for searching.\n */\n public isValidSearchTerm(term: string): boolean {\n return !!(term && term.length > 0);\n }\n\n /**\n * Determines if search options have changed compared to the last search.\n * @param newOptions The new search options to compare.\n * @returns true if the options have changed.\n */\n public didOptionsChange(newOptions?: ISearchOptions): boolean {\n if (!this._lastSearchOptions) {\n return true;\n }\n if (!newOptions) {\n return false;\n }\n if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {\n return true;\n }\n if (this._lastSearchOptions.regex !== newOptions.regex) {\n return true;\n }\n if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {\n return true;\n }\n return false;\n }\n\n /**\n * Determines if a new search should trigger highlighting updates.\n * @param term The search term.\n * @param options The search options.\n * @returns true if highlighting should be updated.\n */\n public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {\n if (!options?.decorations) {\n return false;\n }\n return this._cachedSearchTerm === undefined ||\n term !== this._cachedSearchTerm ||\n this.didOptionsChange(options);\n }\n\n /**\n * Clears the cached search term.\n */\n public clearCachedTerm(): void {\n this._cachedSearchTerm = undefined;\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this._cachedSearchTerm = undefined;\n this._lastSearchOptions = undefined;\n }\n}\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';\nimport type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\nimport { SearchLineCache } from './SearchLineCache';\nimport { SearchState } from './SearchState';\nimport { SearchEngine, type ISearchResult } from './SearchEngine';\nimport { DecorationManager } from './DecorationManager';\nimport { SearchResultTracker } from './SearchResultTracker';\n\ninterface IInternalSearchOptions {\n noScroll: boolean;\n}\n\n/**\n * Configuration constants for the search addon functionality.\n */\nconst enum Constants {\n /**\n * Default maximum number of search results to highlight simultaneously. This limit prevents\n * performance degradation when searching for very common terms that would result in excessive\n * highlighting decorations.\n */\n DEFAULT_HIGHLIGHT_LIMIT = 1000\n}\n\nexport class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {\n private _terminal: Terminal | undefined;\n private _highlightLimit: number;\n private _highlightTimeout = this._register(new MutableDisposable());\n private _lineCache = this._register(new MutableDisposable());\n\n // Component instances\n private _state = new SearchState();\n private _engine: SearchEngine | undefined;\n private _decorationManager: DecorationManager | undefined;\n private _resultTracker = this._register(new SearchResultTracker());\n\n private readonly _onAfterSearch = this._register(new Emitter());\n public readonly onAfterSearch = this._onAfterSearch.event;\n private readonly _onBeforeSearch = this._register(new Emitter());\n public readonly onBeforeSearch = this._onBeforeSearch.event;\n\n public get onDidChangeResults(): IEvent {\n return this._resultTracker.onDidChangeResults;\n }\n\n constructor(options?: Partial) {\n super();\n\n this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;\n }\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n this._lineCache.value = new SearchLineCache(terminal);\n this._engine = new SearchEngine(terminal, this._lineCache.value);\n this._decorationManager = new DecorationManager(terminal);\n this._register(this._terminal.onWriteParsed(() => this._updateMatches()));\n this._register(this._terminal.onResize(() => this._updateMatches()));\n this._register(toDisposable(() => this.clearDecorations()));\n }\n\n private _updateMatches(): void {\n this._highlightTimeout.clear();\n if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {\n this._highlightTimeout.value = disposableTimeout(() => {\n const term = this._state.cachedSearchTerm;\n this._state.clearCachedTerm();\n this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });\n }, 200);\n }\n }\n\n public clearDecorations(retainCachedSearchTerm?: boolean): void {\n this._resultTracker.clearSelectedDecoration();\n this._decorationManager?.clearHighlightDecorations();\n this._resultTracker.clearResults();\n if (!retainCachedSearchTerm) {\n this._state.clearCachedTerm();\n }\n }\n\n public clearActiveDecoration(): void {\n this._resultTracker.clearSelectedDecoration();\n }\n\n /**\n * Find the next instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {\n if (!this._terminal || !this._engine || !this._decorationManager) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n if (!this._state.isValidSearchTerm(term)) {\n this.clearDecorations();\n return;\n }\n\n // new search, clear out the old decorations\n this.clearDecorations(true);\n\n const results: ISearchResult[] = [];\n let prevResult: ISearchResult | undefined = undefined;\n let result = this._engine.find(term, 0, 0, searchOptions);\n\n while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {\n if (results.length >= this._highlightLimit) {\n break;\n }\n prevResult = result;\n results.push(prevResult);\n const cols = this._terminal.cols;\n let nextCol = prevResult.col + prevResult.size;\n let nextRow = prevResult.row;\n if (nextCol >= cols) {\n nextRow += Math.floor(nextCol / cols);\n nextCol = nextCol % cols;\n }\n result = this._engine.find(term, nextRow, nextCol, searchOptions);\n }\n\n this._resultTracker.updateResults(results, this._highlightLimit);\n if (searchOptions.decorations) {\n this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);\n }\n }\n\n private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Find the previous instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _fireResults(searchOptions?: ISearchOptions): void {\n this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);\n }\n\n private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Selects and scrolls to a result.\n * @param result The result to select.\n * @returns Whether a result was selected.\n */\n private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {\n if (!this._terminal || !this._decorationManager) {\n return false;\n }\n\n this._resultTracker.clearSelectedDecoration();\n if (!result) {\n this._terminal.clearSelection();\n return false;\n }\n\n this._terminal.select(result.col, result.row, result.size);\n if (options) {\n const activeDecoration = this._decorationManager.createActiveDecoration(result, options);\n if (activeDecoration) {\n this._resultTracker.selectedDecoration = activeDecoration;\n }\n }\n\n if (!noScroll) {\n // If it is not in the viewport then we scroll else it just gets selected\n if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {\n let scroll = result.row - this._terminal.buffer.active.viewportY;\n scroll -= Math.floor(this._terminal.rows / 2);\n this._terminal.scrollLines(scroll);\n }\n }\n return true;\n }\n}\n"],"names":["root","factory","exports","module","define","amd","globalThis","millis","Promise","resolve","setTimeout","handler","timeout","store","timer","disposable","dispose","Lifecycle_1","toDisposable","clearTimeout","add","__webpack_require__","constructor","this","_token","_isDisposed","cancel","cancelAndSet","runner","Error","setIfNotSet","_isScheduled","set","queueMicrotask","_disposable","undefined","interval","context","handle","setInterval","clearInterval","EventUtils","_listeners","_disposed","event","_event","listener","thisArgs","disposables","entry","fn","slice","push","result","idx","indexOf","splice","Array","isArray","fire","length","call","listeners","i","len","forward","from","to","e","map","any","events","DisposableStore","runAndSubscribe","initial","arg","d","_disposables","Set","isDisposed","o","clear","Disposable","_store","_register","None","Object","freeze","value","_value","DecorationManager","_terminal","super","_highlightDecorations","_highlightedLines","clearHighlightDecorations","createHighlightDecorations","results","options","match","decorations","_createResultDecorations","decoration","_storeDecoration","createActiveDecoration","marker","line","_applyStyles","element","borderColor","isActiveResult","classList","contains","style","outline","decorationRanges","currentCol","col","remainingSize","size","markerOffset","buffer","active","baseY","cursorY","row","amountThisRow","Math","min","cols","range","registerMarker","registerDecoration","x","width","layer","backgroundColor","activeMatchBackground","matchBackground","overviewRulerOptions","has","color","activeMatchColorOverviewRuler","matchOverviewRuler","position","onRender","activeMatchBorder","matchBorder","onDispose","_lineCache","find","term","startRow","startCol","searchOptions","clearSelection","initLinesCache","searchPosition","_findInLine","y","rows","_isRowCoveredByEarlierSearch","findNextWithSelection","cachedSearchTerm","prevSelectedPos","getSelectionPosition","end","start","findPreviousWithSelection","isReverseSearch","max","_isWholeWord","searchIndex","includes","_satisfiesWholeWord","wholeWord","getLine","isWrapped","cache","getLineFromCache","translateBufferLineToStringWithWrap","setLineInCache","stringLine","offsets","offset","_bufferColsToStringOffset","searchTerm","searchStringLine","regex","caseSensitive","toLowerCase","resultIndex","searchRegex","RegExp","foundTerm","exec","matchIndex","lastIndex","lastIndexOf","startRowOffset","endRowOffset","startColOffset","endColOffset","startColIndex","_stringLengthToBufferSize","cell","getCell","char","getChars","nextCell","getWidth","lineOffsets","rowsBack","floor","colsInRow","getCode","Async_1","SearchLineCache","_linesCacheTimeout","MutableDisposable","_linesCacheDisposables","_lastAccessTimestamp","_destroyLinesCache","_linesCache","combinedDisposable","onLineFeed","onCursorMove","onResize","Date","now","_scheduleLinesCacheTimeout","delay","disposableTimeout","elapsed","lineIndex","trimRight","strings","bufferLength","nextLine","lineWrapsToNext","string","translateToString","lastCell","join","Event_1","SearchResultTracker","_searchResults","_onDidChangeResults","Emitter","onDidChangeResults","searchResults","selectedDecoration","_selectedDecoration","updateResults","maxResults","clearResults","clearSelectedDecoration","findResultIndex","fireResultsChanged","hasDecorations","resultCount","reset","_cachedSearchTerm","lastSearchOptions","_lastSearchOptions","isValidSearchTerm","didOptionsChange","newOptions","shouldUpdateHighlighting","clearCachedTerm","__webpack_module_cache__","moduleId","cachedModule","__webpack_modules__","SearchLineCache_1","SearchState_1","SearchEngine_1","DecorationManager_1","SearchResultTracker_1","SearchAddon","_resultTracker","_highlightTimeout","_state","SearchState","_onAfterSearch","onAfterSearch","_onBeforeSearch","onBeforeSearch","_highlightLimit","highlightLimit","activate","terminal","_engine","SearchEngine","_decorationManager","onWriteParsed","_updateMatches","clearDecorations","findPrevious","incremental","noScroll","retainCachedSearchTerm","clearActiveDecoration","findNext","internalSearchOptions","_highlightAllMatches","found","_findNextAndSelect","_fireResults","prevResult","nextCol","nextRow","_selectResult","_findPreviousAndSelect","select","activeDecoration","viewportY","scroll","scrollLines"],"sourceRoot":""} +\ No newline at end of file +diff --git a/lib/addon-search.mjs b/lib/addon-search.mjs +index 5cf231a96b56284705711faebe6b0c492f133546..f2b8b804ac733d737a9bff22b4e1d24778a807c8 100644 +--- a/lib/addon-search.mjs ++++ b/lib/addon-search.mjs +@@ -14,5 +14,5 @@ + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +-function _(c){return{dispose:c}}function D(c){if(!c)return c;if(Array.isArray(c)){for(let i of c)i.dispose();return[]}return c.dispose(),c}function E(...c){return _(()=>D(c))}var S=class{constructor(){this._disposables=new Set;this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(i){return this._isDisposed?i.dispose():this._disposables.add(i),i}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(let i of this._disposables)i.dispose();this._disposables.clear()}}clear(){for(let i of this._disposables)i.dispose();this._disposables.clear()}},b=class{constructor(){this._store=new S}dispose(){this._store.dispose()}_register(i){return this._store.add(i)}};b.None=Object.freeze({dispose(){}});var v=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(i){this._isDisposed||i===this._value||(this._value?.dispose(),this._value=i)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}};var I=class{constructor(){this._listeners=[];this._disposed=!1}get event(){return this._event?this._event:(this._event=(i,e,t)=>{if(this._disposed)return _(()=>{});let r={fn:i,thisArgs:e};this._listeners=this._listeners.slice(),this._listeners.push(r);let s=_(()=>{let n=this._listeners.indexOf(r);n!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(n,1))});return t&&(Array.isArray(t)?t.push(s):t.add(s)),s},this._event)}fire(i){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,i);return}let e=this._listeners;for(let t=0,r=e.length;t{function c(s,n){return s(a=>n.fire(a))}r.forward=c;function i(s,n){return(a,o,l)=>s(h=>a.call(o,n(h)),void 0,l)}r.map=i;function e(...s){return(n,a,o)=>{let l=new S;for(let h of s)l.add(h(p=>n.call(a,p)));return o&&(Array.isArray(o)?o.push(l):o.add(l)),l}}r.any=e;function t(s,n,a){return n(a),s(o=>n(o))}r.runAndSubscribe=t})(W||={});function T(c,i=0,e){let t=setTimeout(()=>{c(),e&&r.dispose()},i),r=_(()=>{clearTimeout(t)});return e?.add(r),r}var C=class extends b{constructor(e){super();this._terminal=e;this._linesCacheTimeout=this._register(new v);this._linesCacheDisposables=this._register(new v);this._lastAccessTimestamp=0;this._register(_(()=>this._destroyLinesCache()))}initLinesCache(){this._linesCache||(this._linesCache=new Array(this._terminal.buffer.active.length),this._linesCacheDisposables.value=E(this._terminal.onLineFeed(()=>this._destroyLinesCache()),this._terminal.onCursorMove(()=>this._destroyLinesCache()),this._terminal.onResize(()=>this._destroyLinesCache()))),this._lastAccessTimestamp=Date.now(),this._linesCacheTimeout.value||this._scheduleLinesCacheTimeout(15e3)}_destroyLinesCache(){this._linesCache=void 0,this._lastAccessTimestamp=0,this._linesCacheDisposables.clear(),this._linesCacheTimeout.clear()}_scheduleLinesCacheTimeout(e){this._linesCacheTimeout.value=T(()=>{if(!this._linesCache)return;let r=Date.now()-this._lastAccessTimestamp;if(r>=15e3){this._destroyLinesCache();return}this._scheduleLinesCacheTimeout(15e3-r)},e)}getLineFromCache(e){return this._linesCache?.[e]}setLineInCache(e,t){this._linesCache&&(this._linesCache[e]=t)}translateBufferLineToStringWithWrap(e,t){let r=[],s=[0],n=this._terminal.buffer.active.getLine(e);for(;n;){let a=this._terminal.buffer.active.getLine(e+1),o=a?a.isWrapped:!1,l=n.translateToString(!o&&t);if(o&&a){let h=n.getCell(n.length-1);h&&h.getCode()===0&&h.getWidth()===1&&a.getCell(0)?.getWidth()===2&&(l=l.slice(0,-1))}if(r.push(l),o)s.push(s[s.length-1]+l.length);else break;e++,n=a}return[r.join(""),s]}};var x=class{get cachedSearchTerm(){return this._cachedSearchTerm}set cachedSearchTerm(i){this._cachedSearchTerm=i}get lastSearchOptions(){return this._lastSearchOptions}set lastSearchOptions(i){this._lastSearchOptions=i}isValidSearchTerm(i){return!!(i&&i.length>0)}didOptionsChange(i){return this._lastSearchOptions?i?this._lastSearchOptions.caseSensitive!==i.caseSensitive||this._lastSearchOptions.regex!==i.regex||this._lastSearchOptions.wholeWord!==i.wholeWord:!1:!0}shouldUpdateHighlighting(i,e){return e?.decorations?this._cachedSearchTerm===void 0||i!==this._cachedSearchTerm||this.didOptionsChange(e):!1}clearCachedTerm(){this._cachedSearchTerm=void 0}reset(){this._cachedSearchTerm=void 0,this._lastSearchOptions=void 0}};var R=class{constructor(i,e){this._terminal=i;this._lineCache=e}find(i,e,t,r){if(!i||i.length===0){this._terminal.clearSelection();return}if(t>=this._terminal.cols)throw new Error(`Invalid col: ${t} to search in terminal of ${this._terminal.cols} cols`);this._lineCache.initLinesCache();let s={startRow:e,startCol:t},n=this._findInLine(i,s,r);if(!n)for(let a=e+1;a=0&&(o.startRow=h,l=this._findInLine(i,o,e,a),!l);h--);}if(!l&&s!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let h=this._terminal.buffer.active.baseY+this._terminal.rows-1;h>=s&&(o.startRow=h,l=this._findInLine(i,o,e,a),!l);h--);return l}_isWholeWord(i,e,t){return(i===0||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(e[i-1]))&&(i+t.length===e.length||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(e[i+t.length]))}_findInLine(i,e,t={},r=!1){let s=e.startRow,n=e.startCol;if(this._terminal.buffer.active.getLine(s)?.isWrapped){if(r){e.startCol+=this._terminal.cols;return}return e.startRow--,e.startCol+=this._terminal.cols,this._findInLine(i,e,t)}let o=this._lineCache.getLineFromCache(s);o||(o=this._lineCache.translateBufferLineToStringWithWrap(s,!0),this._lineCache.setLineInCache(s,o));let[l,h]=o,p=this._bufferColsToStringOffset(s,n),m=i,g=l;t.regex||(m=t.caseSensitive?i:i.toLowerCase(),g=t.caseSensitive?l:l.toLowerCase());let f=-1;if(t.regex){let u=RegExp(m,t.caseSensitive?"g":"gi"),d;if(r)for(;d=u.exec(g.slice(0,p));)f=u.lastIndex-d[0].length,i=d[0],u.lastIndex-=i.length-1;else d=u.exec(g.slice(p)),d&&d[0].length>0&&(f=p+(u.lastIndex-d[0].length),i=d[0])}else r?p-m.length>=0&&(f=g.lastIndexOf(m,p-m.length)):f=g.indexOf(m,p);if(f>=0){if(t.wholeWord&&!this._isWholeWord(f,g,i))return;let u=0;for(;u=h[u+1];)u++;let d=u;for(;d=h[d+1];)d++;let O=f-h[u],k=f+i.length-h[d],y=this._stringLengthToBufferSize(s+u,O),M=this._stringLengthToBufferSize(s+d,k)-y+this._terminal.cols*(d-u);return{term:i,col:y,row:s+u,size:M}}}_stringLengthToBufferSize(i,e){let t=this._terminal.buffer.active.getLine(i);if(!t)return 0;for(let r=0;r1&&(e-=n.length-1);let a=t.getCell(r+1);a&&a.getWidth()===0&&e++}return e}_bufferColsToStringOffset(i,e){let t=i,r=0,s=this._terminal.buffer.active.getLine(t);for(;e>0&&s;){for(let n=0;nthis.clearHighlightDecorations()))}createHighlightDecorations(e,t){this.clearHighlightDecorations();for(let r of e){let s=this._createResultDecorations(r,t,!1);if(s)for(let n of s)this._storeDecoration(n,r)}}createActiveDecoration(e,t){let r=this._createResultDecorations(e,t,!0);if(r)return{decorations:r,match:e,dispose(){D(r)}}}clearHighlightDecorations(){D(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear()}_storeDecoration(e,t){this._highlightedLines.add(e.marker.line),this._highlightDecorations.push({decoration:e,match:t,dispose(){e.dispose()}})}_applyStyles(e,t,r){e.classList.contains("xterm-find-result-decoration")||(e.classList.add("xterm-find-result-decoration"),t&&(e.style.outline=`1px solid ${t}`)),r&&e.classList.add("xterm-find-active-result-decoration")}_createResultDecorations(e,t,r){let s=[],n=e.col,a=e.size,o=-this._terminal.buffer.active.baseY-this._terminal.buffer.active.cursorY+e.row;for(;a>0;){let h=Math.min(this._terminal.cols-n,a);s.push([o,n,h]),n=0,a-=h,o++}let l=[];for(let h of s){let p=this._terminal.registerMarker(h[0]),m=this._terminal.registerDecoration({marker:p,x:h[1],width:h[2],layer:r?"top":"bottom",backgroundColor:r?t.activeMatchBackground:t.matchBackground,overviewRulerOptions:this._highlightedLines.has(p.line)?void 0:{color:r?t.activeMatchColorOverviewRuler:t.matchOverviewRuler,position:"center"}});if(m){let g=[];g.push(p),g.push(m.onRender(f=>this._applyStyles(f,r?t.activeMatchBorder:t.matchBorder,!1))),g.push(m.onDispose(()=>D(g))),l.push(m)}}return l.length===0?void 0:l}};var L=class extends b{constructor(){super(...arguments);this._searchResults=[];this._onDidChangeResults=this._register(new I)}get onDidChangeResults(){return this._onDidChangeResults.event}get searchResults(){return this._searchResults}get selectedDecoration(){return this._selectedDecoration}set selectedDecoration(e){this._selectedDecoration=e}updateResults(e,t){this._searchResults=e.slice(0,t)}clearResults(){this._searchResults=[]}clearSelectedDecoration(){this._selectedDecoration&&(this._selectedDecoration.dispose(),this._selectedDecoration=void 0)}findResultIndex(e){for(let t=0;tthis._updateMatches())),this._register(this._terminal.onResize(()=>this._updateMatches())),this._register(_(()=>this.clearDecorations()))}_updateMatches(){this._highlightTimeout.clear(),this._state.cachedSearchTerm&&this._state.lastSearchOptions?.decorations&&(this._highlightTimeout.value=T(()=>{let e=this._state.cachedSearchTerm;this._state.clearCachedTerm(),this.findPrevious(e,{...this._state.lastSearchOptions,incremental:!0},{noScroll:!0})},200))}clearDecorations(e){this._resultTracker.clearSelectedDecoration(),this._decorationManager?.clearHighlightDecorations(),this._resultTracker.clearResults(),e||this._state.clearCachedTerm()}clearActiveDecoration(){this._resultTracker.clearSelectedDecoration()}findNext(e,t,r){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);let s=this._findNextAndSelect(e,t,r);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),s}_highlightAllMatches(e,t){if(!this._terminal||!this._engine||!this._decorationManager)throw new Error("Cannot use addon until it has been loaded");if(!this._state.isValidSearchTerm(e)){this.clearDecorations();return}this.clearDecorations(!0);let r=[],s,n=this._engine.find(e,0,0,t);for(;n&&(s?.row!==n.row||s?.col!==n.col)&&!(r.length>=this._highlightLimit);){s=n,r.push(s);let a=this._terminal.cols,o=s.col+s.size,l=s.row;o>=a&&(l+=Math.floor(o/a),o=o%a),n=this._engine.find(e,l,o,t)}this._resultTracker.updateResults(r,this._highlightLimit),t.decorations&&this._decorationManager.createHighlightDecorations(r,t.decorations)}_findNextAndSelect(e,t,r){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;let s=this._engine.findNextWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(s,t?.decorations,r?.noScroll)}findPrevious(e,t,r){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);let s=this._findPreviousAndSelect(e,t,r);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),s}_fireResults(e){this._resultTracker.fireResultsChanged(!!e?.decorations)}_findPreviousAndSelect(e,t,r){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;let s=this._engine.findPreviousWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(s,t?.decorations,r?.noScroll)}_selectResult(e,t,r){if(!this._terminal||!this._decorationManager)return!1;if(this._resultTracker.clearSelectedDecoration(),!e)return this._terminal.clearSelection(),!1;if(this._terminal.select(e.col,e.row,e.size),t){let s=this._decorationManager.createActiveDecoration(e,t);s&&(this._resultTracker.selectedDecoration=s)}if(!r&&(e.row>=this._terminal.buffer.active.viewportY+this._terminal.rows||e.rowD(d))}var S=class{constructor(){this._disposables=new Set;this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(i){return this._isDisposed?i.dispose():this._disposables.add(i),i}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(let i of this._disposables)i.dispose();this._disposables.clear()}}clear(){for(let i of this._disposables)i.dispose();this._disposables.clear()}},g=class{constructor(){this._store=new S}dispose(){this._store.dispose()}_register(i){return this._store.add(i)}};g.None=Object.freeze({dispose(){}});var v=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(i){this._isDisposed||i===this._value||(this._value?.dispose(),this._value=i)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}};var I=class{constructor(){this._listeners=[];this._disposed=!1}get event(){return this._event?this._event:(this._event=(i,e,t)=>{if(this._disposed)return m(()=>{});let s={fn:i,thisArgs:e};this._listeners=this._listeners.slice(),this._listeners.push(s);let r=m(()=>{let l=this._listeners.indexOf(s);l!==-1&&(this._listeners=this._listeners.slice(),this._listeners.splice(l,1))});return t&&(Array.isArray(t)?t.push(r):t.add(r)),r},this._event)}fire(i){if(this._disposed||!this._listeners.length)return;if(this._listeners.length===1){this._listeners[0].fn.call(this._listeners[0].thisArgs,i);return}let e=this._listeners;for(let t=0,s=e.length;t{function d(r,l){return r(o=>l.fire(o))}s.forward=d;function i(r,l){return(o,n,a)=>r(c=>o.call(n,l(c)),void 0,a)}s.map=i;function e(...r){return(l,o,n)=>{let a=new S;for(let c of r)a.add(c(u=>l.call(o,u)));return n&&(Array.isArray(n)?n.push(a):n.add(a)),a}}s.any=e;function t(r,l,o){return l(o),r(n=>l(n))}s.runAndSubscribe=t})(k||={});function T(d,i=0,e){let t=setTimeout(()=>{d(),e&&s.dispose()},i),s=m(()=>{clearTimeout(t)});return e?.add(s),s}var C=class extends g{constructor(e){super();this._terminal=e;this._linesCacheTimeout=this._register(new v);this._linesCacheDisposables=this._register(new v);this._lastAccessTimestamp=0;this._register(m(()=>this._destroyLinesCache()))}initLinesCache(){this._linesCache||(this._linesCache=new Array(this._terminal.buffer.active.length),this._linesCacheDisposables.value=E(this._terminal.onLineFeed(()=>this._destroyLinesCache()),this._terminal.onCursorMove(()=>this._destroyLinesCache()),this._terminal.onResize(()=>this._destroyLinesCache()))),this._lastAccessTimestamp=Date.now(),this._linesCacheTimeout.value||this._scheduleLinesCacheTimeout(15e3)}_destroyLinesCache(){this._linesCache=void 0,this._lastAccessTimestamp=0,this._linesCacheDisposables.clear(),this._linesCacheTimeout.clear()}_scheduleLinesCacheTimeout(e){this._linesCacheTimeout.value=T(()=>{if(!this._linesCache)return;let s=Date.now()-this._lastAccessTimestamp;if(s>=15e3){this._destroyLinesCache();return}this._scheduleLinesCacheTimeout(15e3-s)},e)}getLineFromCache(e){return this._linesCache?.[e]}setLineInCache(e,t){this._linesCache&&(this._linesCache[e]=t)}translateBufferLineToStringWithWrap(e,t){let s=[],r=[0],l=this._terminal.buffer.active.length,o=this._terminal.buffer.active.getLine(e);for(;o;){let n=e+10)}didOptionsChange(i){return this._lastSearchOptions?i?this._lastSearchOptions.caseSensitive!==i.caseSensitive||this._lastSearchOptions.regex!==i.regex||this._lastSearchOptions.wholeWord!==i.wholeWord:!1:!0}shouldUpdateHighlighting(i,e){return e?.decorations?this._cachedSearchTerm===void 0||i!==this._cachedSearchTerm||this.didOptionsChange(e):!1}clearCachedTerm(){this._cachedSearchTerm=void 0}reset(){this._cachedSearchTerm=void 0,this._lastSearchOptions=void 0}};var w=class{constructor(i,e){this._terminal=i;this._lineCache=e}find(i,e,t,s){if(!i||i.length===0){this._terminal.clearSelection();return}if(t>=this._terminal.cols)throw new Error(`Invalid col: ${t} to search in terminal of ${this._terminal.cols} cols`);this._lineCache.initLinesCache();let r={startRow:e,startCol:t},l=this._findInLine(i,r,s);if(!l)for(let o=e+1;o0&&this._isRowCoveredByEarlierSearch(a))&&(o.startRow=a,o.startCol=0,n=this._findInLine(i,o,e),n));a++);return!n&&s&&(o.startRow=s.start.y,o.startCol=0,n=this._findInLine(i,o,e)),n}findPreviousWithSelection(i,e,t){if(!i||i.length===0){this._terminal.clearSelection();return}let s=this._terminal.getSelectionPosition();this._terminal.clearSelection();let r=this._terminal.buffer.active.baseY+this._terminal.rows-1,l=this._terminal.cols,o=!0;this._lineCache.initLinesCache();let n={startRow:r,startCol:l},a;if(s&&(n.startRow=r=s.start.y,n.startCol=s.start.x,t!==i&&(a=this._findInLine(i,n,e,!1),a||(n.startRow=r=s.end.y,n.startCol=s.end.x))),a??=this._findInLine(i,n,e,o),!a){n.startCol=Math.max(n.startCol,this._terminal.cols);for(let c=r-1;c>=0&&(n.startRow=c,a=this._findInLine(i,n,e,o),!a);c--);}if(!a&&r!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let c=this._terminal.buffer.active.baseY+this._terminal.rows-1;c>=r&&(n.startRow=c,a=this._findInLine(i,n,e,o),!a);c--);return a}_isWholeWord(i,e,t){return(i===0||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(e[i-1]))&&(i+t.length===e.length||" ~!@#$%^&*()+`-=[]{}|\\;:\"',./<>?".includes(e[i+t.length]))}_satisfiesWholeWord(i,e,t,s){return!s.wholeWord||this._isWholeWord(i,e,t)}_isRowCoveredByEarlierSearch(i){return this._terminal.buffer.active.getLine(i)?.isWrapped===!0}_findInLine(i,e,t={},s=!1){if(s){if(e.startRow>0&&this._terminal.buffer.active.getLine(e.startRow)?.isWrapped){e.startCol+=this._terminal.cols;return}}else for(;e.startRow>0&&this._terminal.buffer.active.getLine(e.startRow)?.isWrapped;)e.startRow--,e.startCol+=this._terminal.cols;let r=e.startRow,l=e.startCol,o=this._lineCache.getLineFromCache(r);o||(o=this._lineCache.translateBufferLineToStringWithWrap(r,!0),this._lineCache.setLineInCache(r,o));let[n,a]=o,c=this._bufferColsToStringOffset(r,l,a),u=i,f=n;t.regex||(u=t.caseSensitive?i:i.toLowerCase(),f=t.caseSensitive?n:n.toLowerCase());let _=-1;if(t.regex){let h=RegExp(u,t.caseSensitive?"g":"gi"),p;if(s)for(;p=h.exec(f.slice(0,c));){let b=h.lastIndex-p[0].length;p[0].length>0&&this._satisfiesWholeWord(b,f,p[0],t)&&(_=b,i=p[0]),h.lastIndex=b+1}else for(h.lastIndex=c;p=h.exec(f);){let b=h.lastIndex-p[0].length;if(p[0].length>0&&this._satisfiesWholeWord(b,f,p[0],t)){_=b,i=p[0];break}h.lastIndex=b+1}}else if(s){let h=c-u.length>=0?f.lastIndexOf(u,c-u.length):-1;for(;h>=0&&!this._satisfiesWholeWord(h,f,u,t);)h=h>0?f.lastIndexOf(u,h-1):-1;_=h}else{let h=f.indexOf(u,c);for(;h>=0&&!this._satisfiesWholeWord(h,f,u,t);)h=f.indexOf(u,h+1);_=h}if(_>=0){let h=0;for(;h=a[h+1];)h++;let p=h;for(;p=a[p+1];)p++;let b=_-a[h],O=_+i.length-a[p],L=this._stringLengthToBufferSize(r+h,b),W=this._stringLengthToBufferSize(r+p,O)-L+this._terminal.cols*(p-h);return{term:i,col:L,row:r+h,size:W}}}_stringLengthToBufferSize(i,e){let t=this._terminal.buffer.active.getLine(i);if(!t)return 0;for(let s=0;s1&&(e-=l.length-1);let o=t.getCell(s+1);o&&o.getWidth()===0&&e++}return e}_bufferColsToStringOffset(i,e,t){let s=Math.min(Math.floor(e/this._terminal.cols),t.length-1),r=t[s],l=this._terminal.buffer.active.getLine(i+s);if(l){let o=Math.min(e-s*this._terminal.cols,this._terminal.cols);for(let n=0;nthis.clearHighlightDecorations()))}createHighlightDecorations(e,t){this.clearHighlightDecorations();for(let s of e){let r=this._createResultDecorations(s,t,!1);if(r)for(let l of r)this._storeDecoration(l,s)}}createActiveDecoration(e,t){let s=this._createResultDecorations(e,t,!0);if(s)return{decorations:s,match:e,dispose(){D(s)}}}clearHighlightDecorations(){D(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear()}_storeDecoration(e,t){this._highlightedLines.add(e.marker.line),this._highlightDecorations.push({decoration:e,match:t,dispose(){e.dispose()}})}_applyStyles(e,t,s){e.classList.contains("xterm-find-result-decoration")||(e.classList.add("xterm-find-result-decoration"),t&&(e.style.outline=`1px solid ${t}`)),s&&e.classList.add("xterm-find-active-result-decoration")}_createResultDecorations(e,t,s){let r=[],l=e.col,o=e.size,n=-this._terminal.buffer.active.baseY-this._terminal.buffer.active.cursorY+e.row;for(;o>0;){let c=Math.min(this._terminal.cols-l,o);r.push([n,l,c]),l=0,o-=c,n++}let a=[];for(let c of r){let u=this._terminal.registerMarker(c[0]),f=this._terminal.registerDecoration({marker:u,x:c[1],width:c[2],layer:s?"top":"bottom",backgroundColor:s?t.activeMatchBackground:t.matchBackground,overviewRulerOptions:this._highlightedLines.has(u.line)?void 0:{color:s?t.activeMatchColorOverviewRuler:t.matchOverviewRuler,position:"center"}});if(f){let _=[];_.push(u),_.push(f.onRender(h=>this._applyStyles(h,s?t.activeMatchBorder:t.matchBorder,!1))),_.push(f.onDispose(()=>D(_))),a.push(f)}}return a.length===0?void 0:a}};var y=class extends g{constructor(){super(...arguments);this._searchResults=[];this._onDidChangeResults=this._register(new I)}get onDidChangeResults(){return this._onDidChangeResults.event}get searchResults(){return this._searchResults}get selectedDecoration(){return this._selectedDecoration}set selectedDecoration(e){this._selectedDecoration=e}updateResults(e,t){this._searchResults=e.slice(0,t)}clearResults(){this._searchResults=[]}clearSelectedDecoration(){this._selectedDecoration&&(this._selectedDecoration.dispose(),this._selectedDecoration=void 0)}findResultIndex(e){for(let t=0;tthis._updateMatches())),this._register(this._terminal.onResize(()=>this._updateMatches())),this._register(m(()=>this.clearDecorations()))}_updateMatches(){this._highlightTimeout.clear(),this._state.cachedSearchTerm&&this._state.lastSearchOptions?.decorations&&(this._highlightTimeout.value=T(()=>{let e=this._state.cachedSearchTerm;this._state.clearCachedTerm(),this.findPrevious(e,{...this._state.lastSearchOptions,incremental:!0},{noScroll:!0})},200))}clearDecorations(e){this._resultTracker.clearSelectedDecoration(),this._decorationManager?.clearHighlightDecorations(),this._resultTracker.clearResults(),e||this._state.clearCachedTerm()}clearActiveDecoration(){this._resultTracker.clearSelectedDecoration()}findNext(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);let r=this._findNextAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),r}_highlightAllMatches(e,t){if(!this._terminal||!this._engine||!this._decorationManager)throw new Error("Cannot use addon until it has been loaded");if(!this._state.isValidSearchTerm(e)){this.clearDecorations();return}this.clearDecorations(!0);let s=[],r,l=this._engine.find(e,0,0,t);for(;l&&(r?.row!==l.row||r?.col!==l.col)&&!(s.length>=this._highlightLimit);){r=l,s.push(r);let o=this._terminal.cols,n=r.col+r.size,a=r.row;n>=o&&(a+=Math.floor(n/o),n=n%o),l=this._engine.find(e,a,n,t)}this._resultTracker.updateResults(s,this._highlightLimit),t.decorations&&this._decorationManager.createHighlightDecorations(s,t.decorations)}_findNextAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;let r=this._engine.findNextWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(r,t?.decorations,s?.noScroll)}findPrevious(e,t,s){if(!this._terminal||!this._engine)throw new Error("Cannot use addon until it has been loaded");this._onBeforeSearch.fire(),this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);let r=this._findPreviousAndSelect(e,t,s);return this._fireResults(t),this._state.cachedSearchTerm=e,this._onAfterSearch.fire(),r}_fireResults(e){this._resultTracker.fireResultsChanged(!!e?.decorations)}_findPreviousAndSelect(e,t,s){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;let r=this._engine.findPreviousWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(r,t?.decorations,s?.noScroll)}_selectResult(e,t,s){if(!this._terminal||!this._decorationManager)return!1;if(this._resultTracker.clearSelectedDecoration(),!e)return this._terminal.clearSelection(),!1;if(this._terminal.select(e.col,e.row,e.size),t){let r=this._decorationManager.createActiveDecoration(e,t);r&&(this._resultTracker.selectedDecoration=r)}if(!s&&(e.row>=this._terminal.buffer.active.viewportY+this._terminal.rows||e.row void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal async helpers for xterm.js core.\n */\n\nimport { DisposableStore, IDisposable, toDisposable } from './Lifecycle';\n\nexport function timeout(millis: number): Promise {\n return new Promise(resolve => setTimeout(resolve, millis));\n}\n\n/**\n * Creates a timeout that can be disposed using its returned value.\n * @param handler The timeout handler.\n * @param timeout An optional timeout in milliseconds.\n * @param store An optional {@link DisposableStore} that will have the timeout disposable managed\n * automatically.\n */\nexport function disposableTimeout(handler: () => void, timeout = 0, store?: DisposableStore): IDisposable {\n const timer = setTimeout(() => {\n handler();\n if (store) {\n disposable.dispose();\n }\n }, timeout);\n const disposable = toDisposable(() => {\n clearTimeout(timer);\n });\n store?.add(disposable);\n return disposable;\n}\n\nexport class TimeoutTimer implements IDisposable {\n private _token: any = -1;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n if (this._token !== -1) {\n clearTimeout(this._token);\n this._token = -1;\n }\n }\n\n public cancelAndSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed TimeoutTimer');\n }\n this.cancel();\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n\n public setIfNotSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling setIfNotSet on a disposed TimeoutTimer');\n }\n if (this._token !== -1) {\n return;\n }\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n}\n\n/**\n * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled\n * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not\n * run yet.\n */\nexport class MicrotaskTimer implements IDisposable {\n private _isScheduled = false;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n this._isScheduled = false;\n }\n\n public set(runner: () => void): void {\n if (this._isDisposed) {\n throw new Error('Calling set on a disposed MicrotaskTimer');\n }\n if (this._isScheduled) {\n return;\n }\n this._isScheduled = true;\n queueMicrotask(() => {\n if (!this._isScheduled) {\n return;\n }\n this._isScheduled = false;\n runner();\n });\n }\n}\n\nexport class IntervalTimer implements IDisposable {\n private _disposable: IDisposable | undefined;\n private _isDisposed = false;\n\n public cancel(): void {\n this._disposable?.dispose();\n this._disposable = undefined;\n }\n\n public cancelAndSet(runner: () => void, interval: number, context: Window | typeof globalThis = globalThis): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed IntervalTimer');\n }\n this.cancel();\n const handle = context.setInterval(() => {\n runner();\n }, interval);\n this._disposable = {\n dispose: () => {\n context.clearInterval(handle as any);\n this._disposable = undefined;\n }\n };\n }\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\n\nexport type LineCacheEntry = [\n /**\n * The string representation of a line (as opposed to the buffer cell representation).\n */\n lineAsString: string,\n /**\n * The offsets where each line starts when the entry describes a wrapped line.\n */\n lineOffsets: number[]\n];\n\n/**\n * Configuration constants for the search line cache functionality.\n */\nconst enum Constants {\n /**\n * Time-to-live for cached search results in milliseconds. After this duration, cached search\n * results will be invalidated to ensure they remain consistent with terminal content changes.\n */\n LINES_CACHE_TIME_TO_LIVE = 15000\n}\n\nexport class SearchLineCache extends Disposable {\n /**\n * translateBufferLineToStringWithWrap is a fairly expensive call.\n * We memoize the calls into an array that has a time based ttl.\n * _linesCache is also invalidated when the terminal cursor moves.\n */\n private _linesCache: LineCacheEntry[] | undefined;\n private _linesCacheTimeout = this._register(new MutableDisposable());\n private _linesCacheDisposables = this._register(new MutableDisposable());\n // Track access to avoid recreating a timeout on every init call which occurs once per search\n // result (findNext/findPrevious -> _highlightAllMatches -> find loop).\n private _lastAccessTimestamp = 0;\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this._destroyLinesCache()));\n }\n\n /**\n * Sets up a line cache with a ttl\n */\n public initLinesCache(): void {\n if (!this._linesCache) {\n this._linesCache = new Array(this._terminal.buffer.active.length);\n this._linesCacheDisposables.value = combinedDisposable(\n this._terminal.onLineFeed(() => this._destroyLinesCache()),\n this._terminal.onCursorMove(() => this._destroyLinesCache()),\n this._terminal.onResize(() => this._destroyLinesCache())\n );\n }\n\n this._lastAccessTimestamp = Date.now();\n if (!this._linesCacheTimeout.value) {\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);\n }\n }\n\n private _destroyLinesCache(): void {\n this._linesCache = undefined;\n this._lastAccessTimestamp = 0;\n this._linesCacheDisposables.clear();\n this._linesCacheTimeout.clear();\n }\n\n private _scheduleLinesCacheTimeout(delay: number): void {\n this._linesCacheTimeout.value = disposableTimeout(() => {\n if (!this._linesCache) {\n return;\n }\n const now = Date.now();\n const elapsed = now - this._lastAccessTimestamp;\n if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {\n this._destroyLinesCache();\n return;\n }\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);\n }, delay);\n }\n\n public getLineFromCache(row: number): LineCacheEntry | undefined {\n return this._linesCache?.[row];\n }\n\n public setLineInCache(row: number, entry: LineCacheEntry): void {\n if (this._linesCache) {\n this._linesCache[row] = entry;\n }\n }\n\n /**\n * Translates a buffer line to a string, including subsequent lines if they are wraps.\n * Wide characters will count as two columns in the resulting string. This\n * function is useful for getting the actual text underneath the raw selection\n * position.\n * @param lineIndex The index of the line being translated.\n * @param trimRight Whether to trim whitespace to the right.\n */\n public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {\n const strings = [];\n const lineOffsets = [0];\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (line) {\n const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);\n const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;\n let string = line.translateToString(!lineWrapsToNext && trimRight);\n if (lineWrapsToNext && nextLine) {\n const lastCell = line.getCell(line.length - 1);\n const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;\n // a wide character wrapped to the next line\n if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {\n string = string.slice(0, -1);\n }\n }\n strings.push(string);\n if (lineWrapsToNext) {\n lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);\n } else {\n break;\n }\n lineIndex++;\n line = nextLine;\n }\n return [strings.join(''), lineOffsets];\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchOptions } from '@xterm/addon-search';\n\n/**\n * Manages search state including cached search terms, options tracking, and validation.\n * This class provides a centralized way to handle search state consistency and option changes.\n */\nexport class SearchState {\n private _cachedSearchTerm: string | undefined;\n private _lastSearchOptions: ISearchOptions | undefined;\n\n /**\n * Gets the currently cached search term.\n */\n public get cachedSearchTerm(): string | undefined {\n return this._cachedSearchTerm;\n }\n\n /**\n * Sets the cached search term.\n */\n public set cachedSearchTerm(term: string | undefined) {\n this._cachedSearchTerm = term;\n }\n\n /**\n * Gets the last search options used.\n */\n public get lastSearchOptions(): ISearchOptions | undefined {\n return this._lastSearchOptions;\n }\n\n /**\n * Sets the last search options used.\n */\n public set lastSearchOptions(options: ISearchOptions | undefined) {\n this._lastSearchOptions = options;\n }\n\n /**\n * Validates a search term to ensure it's not empty or invalid.\n * @param term The search term to validate.\n * @returns true if the term is valid for searching.\n */\n public isValidSearchTerm(term: string): boolean {\n return !!(term && term.length > 0);\n }\n\n /**\n * Determines if search options have changed compared to the last search.\n * @param newOptions The new search options to compare.\n * @returns true if the options have changed.\n */\n public didOptionsChange(newOptions?: ISearchOptions): boolean {\n if (!this._lastSearchOptions) {\n return true;\n }\n if (!newOptions) {\n return false;\n }\n if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {\n return true;\n }\n if (this._lastSearchOptions.regex !== newOptions.regex) {\n return true;\n }\n if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {\n return true;\n }\n return false;\n }\n\n /**\n * Determines if a new search should trigger highlighting updates.\n * @param term The search term.\n * @param options The search options.\n * @returns true if highlighting should be updated.\n */\n public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {\n if (!options?.decorations) {\n return false;\n }\n return this._cachedSearchTerm === undefined ||\n term !== this._cachedSearchTerm ||\n this.didOptionsChange(options);\n }\n\n /**\n * Clears the cached search term.\n */\n public clearCachedTerm(): void {\n this._cachedSearchTerm = undefined;\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this._cachedSearchTerm = undefined;\n this._lastSearchOptions = undefined;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport type { ISearchOptions } from '@xterm/addon-search';\nimport type { SearchLineCache } from './SearchLineCache';\n\n/**\n * Represents the position to start a search from.\n */\ninterface ISearchPosition {\n startCol: number;\n startRow: number;\n}\n\n/**\n * Represents a search result with its position and content.\n */\nexport interface ISearchResult {\n term: string;\n col: number;\n row: number;\n size: number;\n}\n\n/**\n * Configuration constants for the search engine functionality.\n */\nconst enum Constants {\n /**\n * Characters that are considered non-word characters for search boundary detection. These\n * characters are used to determine word boundaries when performing whole-word searches. Includes\n * common punctuation, symbols, and whitespace characters.\n */\n NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\\\;:\"\\',./<>?'\n}\n\n/**\n * Core search engine that handles finding text within terminal content.\n * This class is responsible for the actual search algorithms and position calculations.\n */\nexport class SearchEngine {\n constructor(\n private readonly _terminal: Terminal,\n private readonly _lineCache: SearchLineCache\n ) {}\n\n /**\n * Find the first occurrence of a term starting from a specific position.\n * @param term The search term.\n * @param startRow The row to start searching from.\n * @param startCol The column to start searching from.\n * @param searchOptions Search options.\n * @returns The search result if found, undefined otherwise.\n */\n public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n if (startCol >= this._terminal.cols) {\n throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n return result;\n }\n\n /**\n * Find the next occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine incremental behavior.\n * @returns The search result if found, undefined otherwise.\n */\n public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startCol = 0;\n let startRow = 0;\n if (prevSelectedPos) {\n if (cachedSearchTerm === term) {\n startCol = prevSelectedPos.end.x;\n startRow = prevSelectedPos.end.y;\n } else {\n startCol = prevSelectedPos.start.x;\n startRow = prevSelectedPos.start.y;\n }\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n // If we hit the bottom and didn't search from the very top wrap back up\n if (!result && startRow !== 0) {\n for (let y = 0; y < startRow; y++) {\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n\n // If there is only one result, wrap back and return selection if it exists.\n if (!result && prevSelectedPos) {\n searchPosition.startRow = prevSelectedPos.start.y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n }\n\n return result;\n }\n\n /**\n * Find the previous occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine if expansion should occur.\n * @returns The search result if found, undefined otherwise.\n */\n public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;\n const startCol = this._terminal.cols;\n const isReverseSearch = true;\n\n this._lineCache.initLinesCache();\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n let result: ISearchResult | undefined;\n if (prevSelectedPos) {\n searchPosition.startRow = startRow = prevSelectedPos.start.y;\n searchPosition.startCol = prevSelectedPos.start.x;\n if (cachedSearchTerm !== term) {\n // Try to expand selection to right first.\n result = this._findInLine(term, searchPosition, searchOptions, false);\n if (!result) {\n // If selection was not able to be expanded to the right, then try reverse search\n searchPosition.startRow = startRow = prevSelectedPos.end.y;\n searchPosition.startCol = prevSelectedPos.end.x;\n }\n }\n }\n\n result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n\n // Search from startRow - 1 to top\n if (!result) {\n searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);\n for (let y = startRow - 1; y >= 0; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n // If we hit the top and didn't search from the very bottom wrap back down\n if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {\n for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n\n return result;\n }\n\n /**\n * A found substring is a whole word if it doesn't have an alphanumeric character directly\n * adjacent to it.\n * @param searchIndex starting index of the potential whole word substring\n * @param line entire string in which the potential whole word was found\n * @param term the substring that starts at searchIndex\n */\n private _isWholeWord(searchIndex: number, line: string, term: string): boolean {\n return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&\n (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));\n }\n\n /**\n * Searches a line for a search term. Takes the provided terminal line and searches the text line,\n * which may contain subsequent terminal lines if the text is wrapped. If the provided line number\n * is part of a wrapped text line that started on an earlier line then it is skipped since it will\n * be properly searched when the terminal line that the text starts on is searched.\n * @param term The search term.\n * @param searchPosition The position to start the search.\n * @param searchOptions Search options.\n * @param isReverseSearch Whether the search should start from the right side of the terminal and\n * search to the left.\n * @returns The search result if it was found.\n */\n private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {\n const row = searchPosition.startRow;\n const col = searchPosition.startCol;\n\n // Ignore wrapped lines, only consider on unwrapped line (first row of command string).\n const firstLine = this._terminal.buffer.active.getLine(row);\n if (firstLine?.isWrapped) {\n if (isReverseSearch) {\n searchPosition.startCol += this._terminal.cols;\n return;\n }\n\n // This will iterate until we find the line start.\n // When we find it, we will search using the calculated start column.\n searchPosition.startRow--;\n searchPosition.startCol += this._terminal.cols;\n return this._findInLine(term, searchPosition, searchOptions);\n }\n let cache = this._lineCache.getLineFromCache(row);\n if (!cache) {\n cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);\n this._lineCache.setLineInCache(row, cache);\n }\n const [stringLine, offsets] = cache;\n\n const offset = this._bufferColsToStringOffset(row, col);\n let searchTerm = term;\n let searchStringLine = stringLine;\n if (!searchOptions.regex) {\n searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();\n searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();\n }\n\n let resultIndex = -1;\n if (searchOptions.regex) {\n const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');\n let foundTerm: RegExpExecArray | null;\n if (isReverseSearch) {\n // This loop will get the resultIndex of the _last_ regex match in the range 0..offset\n while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {\n resultIndex = searchRegex.lastIndex - foundTerm[0].length;\n term = foundTerm[0];\n searchRegex.lastIndex -= (term.length - 1);\n }\n } else {\n foundTerm = searchRegex.exec(searchStringLine.slice(offset));\n if (foundTerm && foundTerm[0].length > 0) {\n resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);\n term = foundTerm[0];\n }\n }\n } else {\n if (isReverseSearch) {\n if (offset - searchTerm.length >= 0) {\n resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);\n }\n } else {\n resultIndex = searchStringLine.indexOf(searchTerm, offset);\n }\n }\n\n if (resultIndex >= 0) {\n if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {\n return;\n }\n\n // Adjust the row number and search index if needed since a \"line\" of text can span multiple\n // rows\n let startRowOffset = 0;\n while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {\n startRowOffset++;\n }\n let endRowOffset = startRowOffset;\n while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {\n endRowOffset++;\n }\n const startColOffset = resultIndex - offsets[startRowOffset];\n const endColOffset = resultIndex + term.length - offsets[endRowOffset];\n const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);\n const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);\n const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);\n\n return {\n term,\n col: startColIndex,\n row: row + startRowOffset,\n size\n };\n }\n }\n\n private _stringLengthToBufferSize(row: number, offset: number): number {\n const line = this._terminal.buffer.active.getLine(row);\n if (!line) {\n return 0;\n }\n for (let i = 0; i < offset; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n // Adjust the searchIndex to normalize emoji into single chars\n const char = cell.getChars();\n if (char.length > 1) {\n offset -= char.length - 1;\n }\n // Adjust the searchIndex for empty characters following wide unicode\n // chars (eg. CJK)\n const nextCell = line.getCell(i + 1);\n if (nextCell && nextCell.getWidth() === 0) {\n offset++;\n }\n }\n return offset;\n }\n\n private _bufferColsToStringOffset(startRow: number, cols: number): number {\n let lineIndex = startRow;\n let offset = 0;\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (cols > 0 && line) {\n for (let i = 0; i < cols && i < this._terminal.cols; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n if (cell.getWidth()) {\n // Treat null characters as whitespace to align with the translateToString API\n offset += cell.getCode() === 0 ? 1 : cell.getChars().length;\n }\n }\n lineIndex++;\n line = this._terminal.buffer.active.getLine(lineIndex);\n if (line && !line.isWrapped) {\n break;\n }\n cols -= this._terminal.cols;\n }\n return offset;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';\nimport type { ISearchDecorationOptions } from '@xterm/addon-search';\nimport { dispose, Disposable, toDisposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a highlight decoration.\n */\ninterface IHighlight extends IDisposable {\n decoration: IDecoration;\n match: ISearchResult;\n}\n\n/**\n * Interface for managing multiple decorations for a single match.\n */\ninterface IMultiHighlight extends IDisposable {\n decorations: IDecoration[];\n match: ISearchResult;\n}\n\n/**\n * Manages visual decorations for search results including highlighting and active selection\n * indicators. This class handles the creation, styling, and disposal of search-related decorations.\n */\nexport class DecorationManager extends Disposable {\n private _highlightDecorations: IHighlight[] = [];\n private _highlightedLines: Set = new Set();\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this.clearHighlightDecorations()));\n }\n\n /**\n * Creates decorations for all provided search results.\n * @param results The search results to create decorations for.\n * @param options The decoration options.\n */\n public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {\n this.clearHighlightDecorations();\n\n for (const match of results) {\n const decorations = this._createResultDecorations(match, options, false);\n if (decorations) {\n for (const decoration of decorations) {\n this._storeDecoration(decoration, match);\n }\n }\n }\n }\n\n /**\n * Creates decorations for the currently active search result.\n * @param result The active search result.\n * @param options The decoration options.\n * @returns The multi-highlight decoration or undefined if creation failed.\n */\n public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {\n const decorations = this._createResultDecorations(result, options, true);\n if (decorations) {\n return { decorations, match: result, dispose() { dispose(decorations); } };\n }\n return undefined;\n }\n\n /**\n * Clears all highlight decorations.\n */\n public clearHighlightDecorations(): void {\n dispose(this._highlightDecorations);\n this._highlightDecorations = [];\n this._highlightedLines.clear();\n }\n\n /**\n * Stores a decoration and tracks it for management.\n * @param decoration The decoration to store.\n * @param match The search result this decoration represents.\n */\n private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {\n this._highlightedLines.add(decoration.marker.line);\n this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });\n }\n\n /**\n * Applies styles to the decoration when it is rendered.\n * @param element The decoration's element.\n * @param borderColor The border color to apply.\n * @param isActiveResult Whether the element is part of the active search result.\n */\n private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {\n if (!element.classList.contains('xterm-find-result-decoration')) {\n element.classList.add('xterm-find-result-decoration');\n if (borderColor) {\n element.style.outline = `1px solid ${borderColor}`;\n }\n }\n if (isActiveResult) {\n element.classList.add('xterm-find-active-result-decoration');\n }\n }\n\n /**\n * Creates a decoration for the result and applies styles\n * @param result the search result for which to create the decoration\n * @param options the options for the decoration\n * @param isActiveResult whether this is the currently active result\n * @returns the decorations or undefined if the marker has already been disposed of\n */\n private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {\n // Gather decoration ranges for this match as it could wrap\n const decorationRanges: [number, number, number][] = [];\n let currentCol = result.col;\n let remainingSize = result.size;\n let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;\n while (remainingSize > 0) {\n const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);\n decorationRanges.push([markerOffset, currentCol, amountThisRow]);\n currentCol = 0;\n remainingSize -= amountThisRow;\n markerOffset++;\n }\n\n // Create the decorations\n const decorations: IDecoration[] = [];\n for (const range of decorationRanges) {\n const marker = this._terminal.registerMarker(range[0]);\n const decoration = this._terminal.registerDecoration({\n marker,\n x: range[1],\n width: range[2],\n layer: isActiveResult ? 'top' : 'bottom',\n backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,\n overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {\n color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,\n position: 'center'\n }\n });\n if (decoration) {\n const disposables: IDisposable[] = [];\n disposables.push(marker);\n disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));\n disposables.push(decoration.onDispose(() => dispose(disposables)));\n decorations.push(decoration);\n }\n }\n\n return decorations.length === 0 ? undefined : decorations;\n }\n}\n\n\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchResultChangeEvent } from '@xterm/addon-search';\nimport type { IDisposable } from '@xterm/xterm';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a currently selected decoration.\n */\ninterface ISelectedDecoration extends IDisposable {\n match: ISearchResult;\n}\n\n/**\n * Tracks search results, manages result indexing, and fires events when results change.\n * This class provides centralized management of search result state and notifications.\n */\nexport class SearchResultTracker extends Disposable {\n private _searchResults: ISearchResult[] = [];\n private _selectedDecoration: ISelectedDecoration | undefined;\n\n private readonly _onDidChangeResults = this._register(new Emitter());\n public get onDidChangeResults(): IEvent { return this._onDidChangeResults.event; }\n\n /**\n * Gets the current search results.\n */\n public get searchResults(): ReadonlyArray {\n return this._searchResults;\n }\n\n /**\n * Gets the currently selected decoration.\n */\n public get selectedDecoration(): ISelectedDecoration | undefined {\n return this._selectedDecoration;\n }\n\n /**\n * Sets the currently selected decoration.\n */\n public set selectedDecoration(decoration: ISelectedDecoration | undefined) {\n this._selectedDecoration = decoration;\n }\n\n /**\n * Updates the search results with a new set of results.\n * @param results The new search results.\n * @param maxResults The maximum number of results to track.\n */\n public updateResults(results: ISearchResult[], maxResults: number): void {\n this._searchResults = results.slice(0, maxResults);\n }\n\n /**\n * Clears all search results.\n */\n public clearResults(): void {\n this._searchResults = [];\n }\n\n /**\n * Clears the selected decoration.\n */\n public clearSelectedDecoration(): void {\n if (this._selectedDecoration) {\n this._selectedDecoration.dispose();\n this._selectedDecoration = undefined;\n }\n }\n\n /**\n * Finds the index of a result in the current results array.\n * @param result The result to find.\n * @returns The index of the result, or -1 if not found.\n */\n public findResultIndex(result: ISearchResult): number {\n for (let i = 0; i < this._searchResults.length; i++) {\n const match = this._searchResults[i];\n if (match.row === result.row && match.col === result.col && match.size === result.size) {\n return i;\n }\n }\n return -1;\n }\n\n /**\n * Fires a result change event with the current state.\n * @param hasDecorations Whether decorations are enabled.\n */\n public fireResultsChanged(hasDecorations: boolean): void {\n if (!hasDecorations) {\n return;\n }\n\n let resultIndex = -1;\n if (this._selectedDecoration) {\n resultIndex = this.findResultIndex(this._selectedDecoration.match);\n }\n\n this._onDidChangeResults.fire({\n resultIndex,\n resultCount: this._searchResults.length\n });\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this.clearSelectedDecoration();\n this.clearResults();\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';\nimport type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\nimport { SearchLineCache } from './SearchLineCache';\nimport { SearchState } from './SearchState';\nimport { SearchEngine, type ISearchResult } from './SearchEngine';\nimport { DecorationManager } from './DecorationManager';\nimport { SearchResultTracker } from './SearchResultTracker';\n\ninterface IInternalSearchOptions {\n noScroll: boolean;\n}\n\n/**\n * Configuration constants for the search addon functionality.\n */\nconst enum Constants {\n /**\n * Default maximum number of search results to highlight simultaneously. This limit prevents\n * performance degradation when searching for very common terms that would result in excessive\n * highlighting decorations.\n */\n DEFAULT_HIGHLIGHT_LIMIT = 1000\n}\n\nexport class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {\n private _terminal: Terminal | undefined;\n private _highlightLimit: number;\n private _highlightTimeout = this._register(new MutableDisposable());\n private _lineCache = this._register(new MutableDisposable());\n\n // Component instances\n private _state = new SearchState();\n private _engine: SearchEngine | undefined;\n private _decorationManager: DecorationManager | undefined;\n private _resultTracker = this._register(new SearchResultTracker());\n\n private readonly _onAfterSearch = this._register(new Emitter());\n public readonly onAfterSearch = this._onAfterSearch.event;\n private readonly _onBeforeSearch = this._register(new Emitter());\n public readonly onBeforeSearch = this._onBeforeSearch.event;\n\n public get onDidChangeResults(): IEvent {\n return this._resultTracker.onDidChangeResults;\n }\n\n constructor(options?: Partial) {\n super();\n\n this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;\n }\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n this._lineCache.value = new SearchLineCache(terminal);\n this._engine = new SearchEngine(terminal, this._lineCache.value);\n this._decorationManager = new DecorationManager(terminal);\n this._register(this._terminal.onWriteParsed(() => this._updateMatches()));\n this._register(this._terminal.onResize(() => this._updateMatches()));\n this._register(toDisposable(() => this.clearDecorations()));\n }\n\n private _updateMatches(): void {\n this._highlightTimeout.clear();\n if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {\n this._highlightTimeout.value = disposableTimeout(() => {\n const term = this._state.cachedSearchTerm;\n this._state.clearCachedTerm();\n this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });\n }, 200);\n }\n }\n\n public clearDecorations(retainCachedSearchTerm?: boolean): void {\n this._resultTracker.clearSelectedDecoration();\n this._decorationManager?.clearHighlightDecorations();\n this._resultTracker.clearResults();\n if (!retainCachedSearchTerm) {\n this._state.clearCachedTerm();\n }\n }\n\n public clearActiveDecoration(): void {\n this._resultTracker.clearSelectedDecoration();\n }\n\n /**\n * Find the next instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {\n if (!this._terminal || !this._engine || !this._decorationManager) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n if (!this._state.isValidSearchTerm(term)) {\n this.clearDecorations();\n return;\n }\n\n // new search, clear out the old decorations\n this.clearDecorations(true);\n\n const results: ISearchResult[] = [];\n let prevResult: ISearchResult | undefined = undefined;\n let result = this._engine.find(term, 0, 0, searchOptions);\n\n while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {\n if (results.length >= this._highlightLimit) {\n break;\n }\n prevResult = result;\n results.push(prevResult);\n const cols = this._terminal.cols;\n let nextCol = prevResult.col + prevResult.size;\n let nextRow = prevResult.row;\n if (nextCol >= cols) {\n nextRow += Math.floor(nextCol / cols);\n nextCol = nextCol % cols;\n }\n result = this._engine.find(term, nextRow, nextCol, searchOptions);\n }\n\n this._resultTracker.updateResults(results, this._highlightLimit);\n if (searchOptions.decorations) {\n this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);\n }\n }\n\n private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Find the previous instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _fireResults(searchOptions?: ISearchOptions): void {\n this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);\n }\n\n private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Selects and scrolls to a result.\n * @param result The result to select.\n * @returns Whether a result was selected.\n */\n private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {\n if (!this._terminal || !this._decorationManager) {\n return false;\n }\n\n this._resultTracker.clearSelectedDecoration();\n if (!result) {\n this._terminal.clearSelection();\n return false;\n }\n\n this._terminal.select(result.col, result.row, result.size);\n if (options) {\n const activeDecoration = this._decorationManager.createActiveDecoration(result, options);\n if (activeDecoration) {\n this._resultTracker.selectedDecoration = activeDecoration;\n }\n }\n\n if (!noScroll) {\n // If it is not in the viewport then we scroll else it just gets selected\n if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {\n let scroll = result.row - this._terminal.buffer.active.viewportY;\n scroll -= Math.floor(this._terminal.rows / 2);\n this._terminal.scrollLines(scroll);\n }\n }\n return true;\n }\n}\n"], +- "mappings": ";;;;;;;;;;;;;;;;AAYO,SAASA,EAAaC,EAA6B,CACxD,MAAO,CAAE,QAASA,CAAG,CACvB,CAKO,SAASC,EAA+BC,EAA+C,CAC5F,GAAI,CAACA,EACH,OAAOA,EAET,GAAI,MAAM,QAAQA,CAAG,EAAG,CACtB,QAAWC,KAAKD,EACdC,EAAE,QAAQ,EAEZ,MAAO,CAAC,CACV,CACA,OAAAD,EAAI,QAAQ,EACLA,CACT,CAEO,SAASE,KAAsBC,EAAyC,CAC7E,OAAON,EAAa,IAAME,EAAQI,CAAW,CAAC,CAChD,CAEO,IAAMC,EAAN,KAA6C,CAA7C,cACL,KAAiB,aAAe,IAAI,IACpC,KAAQ,YAAc,GAEtB,IAAW,YAAsB,CAC/B,OAAO,KAAK,WACd,CAEO,IAA2BC,EAAS,CACzC,OAAI,KAAK,YACPA,EAAE,QAAQ,EAEV,KAAK,aAAa,IAAIA,CAAC,EAElBA,CACT,CAEO,SAAgB,CACrB,GAAI,MAAK,YAGT,MAAK,YAAc,GACnB,QAAWJ,KAAK,KAAK,aACnBA,EAAE,QAAQ,EAEZ,KAAK,aAAa,MAAM,EAC1B,CAEO,OAAc,CACnB,QAAWA,KAAK,KAAK,aACnBA,EAAE,QAAQ,EAEZ,KAAK,aAAa,MAAM,CAC1B,CACF,EAEsBK,EAAf,KAAiD,CAAjD,cAGL,KAAmB,OAAS,IAAIF,EAEzB,SAAgB,CACrB,KAAK,OAAO,QAAQ,CACtB,CAEU,UAAiCC,EAAS,CAClD,OAAO,KAAK,OAAO,IAAIA,CAAC,CAC1B,CACF,EAZsBC,EACG,KAAoB,OAAO,OAAO,CAAE,SAAU,CAAE,CAAE,CAAC,EAarE,IAAMC,EAAN,KAAsE,CAAtE,cAEL,KAAQ,YAAc,GAEtB,IAAW,OAAuB,CAChC,OAAO,KAAK,YAAc,OAAY,KAAK,MAC7C,CAEA,IAAW,MAAMC,EAAsB,CACjC,KAAK,aAAeA,IAAU,KAAK,SAGvC,KAAK,QAAQ,QAAQ,EACrB,KAAK,OAASA,EAChB,CAEO,OAAc,CACnB,KAAK,MAAQ,MACf,CAEO,SAAgB,CACrB,KAAK,YAAc,GACnB,KAAK,QAAQ,QAAQ,EACrB,KAAK,OAAS,MAChB,CACF,EClGO,IAAMC,EAAN,KAAiB,CAAjB,cACL,KAAQ,WAAqD,CAAC,EAC9D,KAAQ,UAAY,GAGpB,IAAW,OAAmB,CAC5B,OAAI,KAAK,OACA,KAAK,QAEd,KAAK,OAAS,CAACC,EAAyBC,EAAgBC,IAAkD,CACxG,GAAI,KAAK,UACP,OAAOC,EAAa,IAAM,CAAC,CAAC,EAG9B,IAAMC,EAAQ,CAAE,GAAIJ,EAAU,SAAAC,CAAS,EACvC,KAAK,WAAa,KAAK,WAAW,MAAM,EACxC,KAAK,WAAW,KAAKG,CAAK,EAE1B,IAAMC,EAASF,EAAa,IAAM,CAChC,IAAMG,EAAM,KAAK,WAAW,QAAQF,CAAK,EACrCE,IAAQ,KACV,KAAK,WAAa,KAAK,WAAW,MAAM,EACxC,KAAK,WAAW,OAAOA,EAAK,CAAC,EAEjC,CAAC,EAED,OAAIJ,IACE,MAAM,QAAQA,CAAW,EAC3BA,EAAY,KAAKG,CAAM,EAEvBH,EAAY,IAAIG,CAAM,GAInBA,CACT,EACO,KAAK,OACd,CAEO,KAAKE,EAAgB,CAC1B,GAAI,KAAK,WAAa,CAAC,KAAK,WAAW,OACrC,OAEF,GAAI,KAAK,WAAW,SAAW,EAAG,CAChC,KAAK,WAAW,CAAC,EAAE,GAAG,KAAK,KAAK,WAAW,CAAC,EAAE,SAAUA,CAAK,EAC7D,MACF,CACA,IAAMC,EAAY,KAAK,WACvB,QAASC,EAAI,EAAGC,EAAMF,EAAU,OAAQC,EAAIC,EAAK,EAAED,EACjDD,EAAUC,CAAC,EAAE,GAAG,KAAKD,EAAUC,CAAC,EAAE,SAAUF,CAAK,CAErD,CAEO,SAAgB,CACjB,KAAK,YAGT,KAAK,UAAY,GACjB,KAAK,WAAW,OAAS,EAC3B,CACF,EAEiBI,MAAV,CACE,SAASC,EAAWC,EAAiBC,EAA6B,CACvE,OAAOD,EAAKE,GAAKD,EAAG,KAAKC,CAAC,CAAC,CAC7B,CAFOJ,EAAS,QAAAC,EAIT,SAASI,EAAUT,EAAkBS,EAA6B,CACvE,MAAO,CAAChB,EAAyBC,EAAgBC,IACxCK,EAAME,GAAKT,EAAS,KAAKC,EAAUe,EAAIP,CAAC,CAAC,EAAG,OAAWP,CAAW,CAE7E,CAJOS,EAAS,IAAAK,EAQT,SAASC,KAAUC,EAAgC,CACxD,MAAO,CAAClB,EAAyBC,EAAgBC,IAAkD,CACjG,IAAMiB,EAAQ,IAAIC,EAClB,QAAWb,KAASW,EAClBC,EAAM,IAAIZ,EAAMQ,GAAKf,EAAS,KAAKC,EAAUc,CAAC,CAAC,CAAC,EAElD,OAAIb,IACE,MAAM,QAAQA,CAAW,EAC3BA,EAAY,KAAKiB,CAAK,EAEtBjB,EAAY,IAAIiB,CAAK,GAGlBA,CACT,CACF,CAfOR,EAAS,IAAAM,EAmBT,SAASI,EAAmBd,EAAkBe,EAAqCC,EAA0B,CAClH,OAAAD,EAAQC,CAAO,EACRhB,EAAMQ,GAAKO,EAAQP,CAAC,CAAC,CAC9B,CAHOJ,EAAS,gBAAAU,IAhCDV,IAAA,ICxDV,SAASa,EAAkBC,EAAqBC,EAAU,EAAGC,EAAsC,CACxG,IAAMC,EAAQ,WAAW,IAAM,CAC7BH,EAAQ,EACJE,GACFE,EAAW,QAAQ,CAEvB,EAAGH,CAAO,EACJG,EAAaC,EAAa,IAAM,CACpC,aAAaF,CAAK,CACpB,CAAC,EACD,OAAAD,GAAO,IAAIE,CAAU,EACdA,CACT,CCDO,IAAME,EAAN,cAA8BC,CAAW,CAa9C,YAA6BC,EAAqB,CAChD,MAAM,EADqB,eAAAA,EAN7B,KAAQ,mBAAqB,KAAK,UAAU,IAAIC,CAAmB,EACnE,KAAQ,uBAAyB,KAAK,UAAU,IAAIA,CAAmB,EAGvE,KAAQ,qBAAuB,EAI7B,KAAK,UAAUC,EAAa,IAAM,KAAK,mBAAmB,CAAC,CAAC,CAC9D,CAKO,gBAAuB,CACvB,KAAK,cACR,KAAK,YAAc,IAAI,MAAM,KAAK,UAAU,OAAO,OAAO,MAAM,EAChE,KAAK,uBAAuB,MAAQC,EAClC,KAAK,UAAU,WAAW,IAAM,KAAK,mBAAmB,CAAC,EACzD,KAAK,UAAU,aAAa,IAAM,KAAK,mBAAmB,CAAC,EAC3D,KAAK,UAAU,SAAS,IAAM,KAAK,mBAAmB,CAAC,CACzD,GAGF,KAAK,qBAAuB,KAAK,IAAI,EAChC,KAAK,mBAAmB,OAC3B,KAAK,2BAA2B,IAAkC,CAEtE,CAEQ,oBAA2B,CACjC,KAAK,YAAc,OACnB,KAAK,qBAAuB,EAC5B,KAAK,uBAAuB,MAAM,EAClC,KAAK,mBAAmB,MAAM,CAChC,CAEQ,2BAA2BC,EAAqB,CACtD,KAAK,mBAAmB,MAAQC,EAAkB,IAAM,CACtD,GAAI,CAAC,KAAK,YACR,OAGF,IAAMC,EADM,KAAK,IAAI,EACC,KAAK,qBAC3B,GAAIA,GAAW,KAAoC,CACjD,KAAK,mBAAmB,EACxB,MACF,CACA,KAAK,2BAA2B,KAAqCA,CAAO,CAC9E,EAAGF,CAAK,CACV,CAEO,iBAAiBG,EAAyC,CAC/D,OAAO,KAAK,cAAcA,CAAG,CAC/B,CAEO,eAAeA,EAAaC,EAA6B,CAC1D,KAAK,cACP,KAAK,YAAYD,CAAG,EAAIC,EAE5B,CAUO,oCAAoCC,EAAmBC,EAAoC,CAChG,IAAMC,EAAU,CAAC,EACXC,EAAc,CAAC,CAAC,EAClBC,EAAO,KAAK,UAAU,OAAO,OAAO,QAAQJ,CAAS,EACzD,KAAOI,GAAM,CACX,IAAMC,EAAW,KAAK,UAAU,OAAO,OAAO,QAAQL,EAAY,CAAC,EAC7DM,EAAkBD,EAAWA,EAAS,UAAY,GACpDE,EAASH,EAAK,kBAAkB,CAACE,GAAmBL,CAAS,EACjE,GAAIK,GAAmBD,EAAU,CAC/B,IAAMG,EAAWJ,EAAK,QAAQA,EAAK,OAAS,CAAC,EACtBI,GAAYA,EAAS,QAAQ,IAAM,GAAKA,EAAS,SAAS,IAAM,GAEjEH,EAAS,QAAQ,CAAC,GAAG,SAAS,IAAM,IACxDE,EAASA,EAAO,MAAM,EAAG,EAAE,EAE/B,CAEA,GADAL,EAAQ,KAAKK,CAAM,EACfD,EACFH,EAAY,KAAKA,EAAYA,EAAY,OAAS,CAAC,EAAII,EAAO,MAAM,MAEpE,OAEFP,IACAI,EAAOC,CACT,CACA,MAAO,CAACH,EAAQ,KAAK,EAAE,EAAGC,CAAW,CACvC,CACF,EC5HO,IAAMM,EAAN,KAAkB,CAOvB,IAAW,kBAAuC,CAChD,OAAO,KAAK,iBACd,CAKA,IAAW,iBAAiBC,EAA0B,CACpD,KAAK,kBAAoBA,CAC3B,CAKA,IAAW,mBAAgD,CACzD,OAAO,KAAK,kBACd,CAKA,IAAW,kBAAkBC,EAAqC,CAChE,KAAK,mBAAqBA,CAC5B,CAOO,kBAAkBD,EAAuB,CAC9C,MAAO,CAAC,EAAEA,GAAQA,EAAK,OAAS,EAClC,CAOO,iBAAiBE,EAAsC,CAC5D,OAAK,KAAK,mBAGLA,EAGD,KAAK,mBAAmB,gBAAkBA,EAAW,eAGrD,KAAK,mBAAmB,QAAUA,EAAW,OAG7C,KAAK,mBAAmB,YAAcA,EAAW,UAR5C,GAHA,EAeX,CAQO,yBAAyBF,EAAcC,EAAmC,CAC/E,OAAKA,GAAS,YAGP,KAAK,oBAAsB,QAC3BD,IAAS,KAAK,mBACd,KAAK,iBAAiBC,CAAO,EAJ3B,EAKX,CAKO,iBAAwB,CAC7B,KAAK,kBAAoB,MAC3B,CAKO,OAAc,CACnB,KAAK,kBAAoB,OACzB,KAAK,mBAAqB,MAC5B,CACF,EC9DO,IAAME,EAAN,KAAmB,CACxB,YACmBC,EACAC,EACjB,CAFiB,eAAAD,EACA,gBAAAC,CAChB,CAUI,KAAKC,EAAcC,EAAkBC,EAAkBC,EAA2D,CACvH,GAAI,CAACH,GAAQA,EAAK,SAAW,EAAG,CAC9B,KAAK,UAAU,eAAe,EAC9B,MACF,CACA,GAAIE,GAAY,KAAK,UAAU,KAC7B,MAAM,IAAI,MAAM,gBAAgBA,CAAQ,6BAA6B,KAAK,UAAU,IAAI,OAAO,EAGjG,KAAK,WAAW,eAAe,EAE/B,IAAME,EAAkC,CACtC,SAAAH,EACA,SAAAC,CACF,EAGIG,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,EAEjE,GAAI,CAACE,EACH,QAASC,EAAIL,EAAW,EAAGK,EAAI,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,OACjFF,EAAe,SAAWE,EAC1BF,EAAe,SAAW,EAC1BC,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,EACzD,CAAAE,GAJmFC,IAIvF,CAKJ,OAAOD,CACT,CASO,sBAAsBL,EAAcG,EAAgCI,EAAsD,CAC/H,GAAI,CAACP,GAAQA,EAAK,SAAW,EAAG,CAC9B,KAAK,UAAU,eAAe,EAC9B,MACF,CAEA,IAAMQ,EAAkB,KAAK,UAAU,qBAAqB,EAC5D,KAAK,UAAU,eAAe,EAE9B,IAAIN,EAAW,EACXD,EAAW,EACXO,IACED,IAAqBP,GACvBE,EAAWM,EAAgB,IAAI,EAC/BP,EAAWO,EAAgB,IAAI,IAE/BN,EAAWM,EAAgB,MAAM,EACjCP,EAAWO,EAAgB,MAAM,IAIrC,KAAK,WAAW,eAAe,EAE/B,IAAMJ,EAAkC,CACtC,SAAAH,EACA,SAAAC,CACF,EAGIG,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,EAEjE,GAAI,CAACE,EACH,QAASC,EAAIL,EAAW,EAAGK,EAAI,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,OACjFF,EAAe,SAAWE,EAC1BF,EAAe,SAAW,EAC1BC,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,EACzD,CAAAE,GAJmFC,IAIvF,CAMJ,GAAI,CAACD,GAAUJ,IAAa,EAC1B,QAASK,EAAI,EAAGA,EAAIL,IAClBG,EAAe,SAAWE,EAC1BF,EAAe,SAAW,EAC1BC,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,EACzD,CAAAE,GAJwBC,IAI5B,CAOJ,MAAI,CAACD,GAAUG,IACbJ,EAAe,SAAWI,EAAgB,MAAM,EAChDJ,EAAe,SAAW,EAC1BC,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,GAGxDE,CACT,CASO,0BAA0BL,EAAcG,EAAgCI,EAAsD,CACnI,GAAI,CAACP,GAAQA,EAAK,SAAW,EAAG,CAC9B,KAAK,UAAU,eAAe,EAC9B,MACF,CAEA,IAAMQ,EAAkB,KAAK,UAAU,qBAAqB,EAC5D,KAAK,UAAU,eAAe,EAE9B,IAAIP,EAAW,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,KAAO,EACpEC,EAAW,KAAK,UAAU,KAC1BO,EAAkB,GAExB,KAAK,WAAW,eAAe,EAC/B,IAAML,EAAkC,CACtC,SAAAH,EACA,SAAAC,CACF,EAEIG,EAkBJ,GAjBIG,IACFJ,EAAe,SAAWH,EAAWO,EAAgB,MAAM,EAC3DJ,EAAe,SAAWI,EAAgB,MAAM,EAC5CD,IAAqBP,IAEvBK,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,EAAe,EAAK,EAC/DE,IAEHD,EAAe,SAAWH,EAAWO,EAAgB,IAAI,EACzDJ,EAAe,SAAWI,EAAgB,IAAI,KAKpDH,IAAW,KAAK,YAAYL,EAAMI,EAAgBD,EAAeM,CAAe,EAG5E,CAACJ,EAAQ,CACXD,EAAe,SAAW,KAAK,IAAIA,EAAe,SAAU,KAAK,UAAU,IAAI,EAC/E,QAASE,EAAIL,EAAW,EAAGK,GAAK,IAC9BF,EAAe,SAAWE,EAC1BD,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,EAAeM,CAAe,EAC1E,CAAAJ,GAH6BC,IAGjC,CAIJ,CAEA,GAAI,CAACD,GAAUJ,IAAc,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,KAAO,EACtF,QAASK,EAAK,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,KAAO,EAAIA,GAAKL,IAChFG,EAAe,SAAWE,EAC1BD,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,EAAeM,CAAe,EAC1E,CAAAJ,GAHsFC,IAG1F,CAMJ,OAAOD,CACT,CASQ,aAAaK,EAAqBC,EAAcX,EAAuB,CAC7E,OAASU,IAAgB,GAAO,qCAA8B,SAASC,EAAKD,EAAc,CAAC,CAAC,KACvFA,EAAcV,EAAK,SAAYW,EAAK,QAAY,qCAA8B,SAASA,EAAKD,EAAcV,EAAK,MAAM,CAAC,EAC7H,CAcQ,YAAYA,EAAcI,EAAiCD,EAAgC,CAAC,EAAGM,EAA2B,GAAkC,CAClK,IAAMG,EAAMR,EAAe,SACrBS,EAAMT,EAAe,SAI3B,GADkB,KAAK,UAAU,OAAO,OAAO,QAAQQ,CAAG,GAC3C,UAAW,CACxB,GAAIH,EAAiB,CACnBL,EAAe,UAAY,KAAK,UAAU,KAC1C,MACF,CAIA,OAAAA,EAAe,WACfA,EAAe,UAAY,KAAK,UAAU,KACnC,KAAK,YAAYJ,EAAMI,EAAgBD,CAAa,CAC7D,CACA,IAAIW,EAAQ,KAAK,WAAW,iBAAiBF,CAAG,EAC3CE,IACHA,EAAQ,KAAK,WAAW,oCAAoCF,EAAK,EAAI,EACrE,KAAK,WAAW,eAAeA,EAAKE,CAAK,GAE3C,GAAM,CAACC,EAAYC,CAAO,EAAIF,EAExBG,EAAS,KAAK,0BAA0BL,EAAKC,CAAG,EAClDK,EAAalB,EACbmB,EAAmBJ,EAClBZ,EAAc,QACjBe,EAAaf,EAAc,cAAgBH,EAAOA,EAAK,YAAY,EACnEmB,EAAmBhB,EAAc,cAAgBY,EAAaA,EAAW,YAAY,GAGvF,IAAIK,EAAc,GAClB,GAAIjB,EAAc,MAAO,CACvB,IAAMkB,EAAc,OAAOH,EAAYf,EAAc,cAAgB,IAAM,IAAI,EAC3EmB,EACJ,GAAIb,EAEF,KAAOa,EAAYD,EAAY,KAAKF,EAAiB,MAAM,EAAGF,CAAM,CAAC,GACnEG,EAAcC,EAAY,UAAYC,EAAU,CAAC,EAAE,OACnDtB,EAAOsB,EAAU,CAAC,EAClBD,EAAY,WAAcrB,EAAK,OAAS,OAG1CsB,EAAYD,EAAY,KAAKF,EAAiB,MAAMF,CAAM,CAAC,EACvDK,GAAaA,EAAU,CAAC,EAAE,OAAS,IACrCF,EAAcH,GAAUI,EAAY,UAAYC,EAAU,CAAC,EAAE,QAC7DtB,EAAOsB,EAAU,CAAC,EAGxB,MACMb,EACEQ,EAASC,EAAW,QAAU,IAChCE,EAAcD,EAAiB,YAAYD,EAAYD,EAASC,EAAW,MAAM,GAGnFE,EAAcD,EAAiB,QAAQD,EAAYD,CAAM,EAI7D,GAAIG,GAAe,EAAG,CACpB,GAAIjB,EAAc,WAAa,CAAC,KAAK,aAAaiB,EAAaD,EAAkBnB,CAAI,EACnF,OAKF,IAAIuB,EAAiB,EACrB,KAAOA,EAAiBP,EAAQ,OAAS,GAAKI,GAAeJ,EAAQO,EAAiB,CAAC,GACrFA,IAEF,IAAIC,EAAeD,EACnB,KAAOC,EAAeR,EAAQ,OAAS,GAAKI,EAAcpB,EAAK,QAAUgB,EAAQQ,EAAe,CAAC,GAC/FA,IAEF,IAAMC,EAAiBL,EAAcJ,EAAQO,CAAc,EACrDG,EAAeN,EAAcpB,EAAK,OAASgB,EAAQQ,CAAY,EAC/DG,EAAgB,KAAK,0BAA0Bf,EAAMW,EAAgBE,CAAc,EAEnFG,EADc,KAAK,0BAA0BhB,EAAMY,EAAcE,CAAY,EACxDC,EAAgB,KAAK,UAAU,MAAQH,EAAeD,GAEjF,MAAO,CACL,KAAAvB,EACA,IAAK2B,EACL,IAAKf,EAAMW,EACX,KAAAK,CACF,CACF,CACF,CAEQ,0BAA0BhB,EAAaK,EAAwB,CACrE,IAAMN,EAAO,KAAK,UAAU,OAAO,OAAO,QAAQC,CAAG,EACrD,GAAI,CAACD,EACH,MAAO,GAET,QAASkB,EAAI,EAAGA,EAAIZ,EAAQY,IAAK,CAC/B,IAAMC,EAAOnB,EAAK,QAAQkB,CAAC,EAC3B,GAAI,CAACC,EACH,MAGF,IAAMC,EAAOD,EAAK,SAAS,EACvBC,EAAK,OAAS,IAChBd,GAAUc,EAAK,OAAS,GAI1B,IAAMC,EAAWrB,EAAK,QAAQkB,EAAI,CAAC,EAC/BG,GAAYA,EAAS,SAAS,IAAM,GACtCf,GAEJ,CACA,OAAOA,CACT,CAEQ,0BAA0BhB,EAAkBgC,EAAsB,CACxE,IAAIC,EAAYjC,EACZgB,EAAS,EACTN,EAAO,KAAK,UAAU,OAAO,OAAO,QAAQuB,CAAS,EACzD,KAAOD,EAAO,GAAKtB,GAAM,CACvB,QAASkB,EAAI,EAAGA,EAAII,GAAQJ,EAAI,KAAK,UAAU,KAAMA,IAAK,CACxD,IAAMC,EAAOnB,EAAK,QAAQkB,CAAC,EAC3B,GAAI,CAACC,EACH,MAEEA,EAAK,SAAS,IAEhBb,GAAUa,EAAK,QAAQ,IAAM,EAAI,EAAIA,EAAK,SAAS,EAAE,OAEzD,CAGA,GAFAI,IACAvB,EAAO,KAAK,UAAU,OAAO,OAAO,QAAQuB,CAAS,EACjDvB,GAAQ,CAACA,EAAK,UAChB,MAEFsB,GAAQ,KAAK,UAAU,IACzB,CACA,OAAOhB,CACT,CACF,ECzWO,IAAMkB,EAAN,cAAgCC,CAAW,CAIhD,YAA6BC,EAAqB,CAChD,MAAM,EADqB,eAAAA,EAH7B,KAAQ,sBAAsC,CAAC,EAC/C,KAAQ,kBAAiC,IAAI,IAI3C,KAAK,UAAUC,EAAa,IAAM,KAAK,0BAA0B,CAAC,CAAC,CACrE,CAOO,2BAA2BC,EAA0BC,EAAyC,CACnG,KAAK,0BAA0B,EAE/B,QAAWC,KAASF,EAAS,CAC3B,IAAMG,EAAc,KAAK,yBAAyBD,EAAOD,EAAS,EAAK,EACvE,GAAIE,EACF,QAAWC,KAAcD,EACvB,KAAK,iBAAiBC,EAAYF,CAAK,CAG7C,CACF,CAQO,uBAAuBG,EAAuBJ,EAAgE,CACnH,IAAME,EAAc,KAAK,yBAAyBE,EAAQJ,EAAS,EAAI,EACvE,GAAIE,EACF,MAAO,CAAE,YAAAA,EAAa,MAAOE,EAAQ,SAAU,CAAEC,EAAQH,CAAW,CAAG,CAAE,CAG7E,CAKO,2BAAkC,CACvCG,EAAQ,KAAK,qBAAqB,EAClC,KAAK,sBAAwB,CAAC,EAC9B,KAAK,kBAAkB,MAAM,CAC/B,CAOQ,iBAAiBF,EAAyBF,EAA4B,CAC5E,KAAK,kBAAkB,IAAIE,EAAW,OAAO,IAAI,EACjD,KAAK,sBAAsB,KAAK,CAAE,WAAAA,EAAY,MAAAF,EAAO,SAAU,CAAEE,EAAW,QAAQ,CAAG,CAAE,CAAC,CAC5F,CAQQ,aAAaG,EAAsBC,EAAiCC,EAA+B,CACpGF,EAAQ,UAAU,SAAS,8BAA8B,IAC5DA,EAAQ,UAAU,IAAI,8BAA8B,EAChDC,IACFD,EAAQ,MAAM,QAAU,aAAaC,CAAW,KAGhDC,GACFF,EAAQ,UAAU,IAAI,qCAAqC,CAE/D,CASQ,yBAAyBF,EAAuBJ,EAAmCQ,EAAoD,CAE7I,IAAMC,EAA+C,CAAC,EAClDC,EAAaN,EAAO,IACpBO,EAAgBP,EAAO,KACvBQ,EAAe,CAAC,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,OAAO,OAAO,QAAUR,EAAO,IACvG,KAAOO,EAAgB,GAAG,CACxB,IAAME,EAAgB,KAAK,IAAI,KAAK,UAAU,KAAOH,EAAYC,CAAa,EAC9EF,EAAiB,KAAK,CAACG,EAAcF,EAAYG,CAAa,CAAC,EAC/DH,EAAa,EACbC,GAAiBE,EACjBD,GACF,CAGA,IAAMV,EAA6B,CAAC,EACpC,QAAWY,KAASL,EAAkB,CACpC,IAAMM,EAAS,KAAK,UAAU,eAAeD,EAAM,CAAC,CAAC,EAC/CX,EAAa,KAAK,UAAU,mBAAmB,CACnD,OAAAY,EACA,EAAGD,EAAM,CAAC,EACV,MAAOA,EAAM,CAAC,EACd,MAAON,EAAiB,MAAQ,SAChC,gBAAiBA,EAAiBR,EAAQ,sBAAwBA,EAAQ,gBAC1E,qBAAsB,KAAK,kBAAkB,IAAIe,EAAO,IAAI,EAAI,OAAY,CAC1E,MAAOP,EAAiBR,EAAQ,8BAAgCA,EAAQ,mBACxE,SAAU,QACZ,CACF,CAAC,EACD,GAAIG,EAAY,CACd,IAAMa,EAA6B,CAAC,EACpCA,EAAY,KAAKD,CAAM,EACvBC,EAAY,KAAKb,EAAW,SAAUc,GAAM,KAAK,aAAaA,EAAGT,EAAiBR,EAAQ,kBAAoBA,EAAQ,YAAa,EAAK,CAAC,CAAC,EAC1IgB,EAAY,KAAKb,EAAW,UAAU,IAAME,EAAQW,CAAW,CAAC,CAAC,EACjEd,EAAY,KAAKC,CAAU,CAC7B,CACF,CAEA,OAAOD,EAAY,SAAW,EAAI,OAAYA,CAChD,CACF,ECrIO,IAAMgB,EAAN,cAAkCC,CAAW,CAA7C,kCACL,KAAQ,eAAkC,CAAC,EAG3C,KAAiB,oBAAsB,KAAK,UAAU,IAAIC,CAAmC,EAC7F,IAAW,oBAAuD,CAAE,OAAO,KAAK,oBAAoB,KAAO,CAK3G,IAAW,eAA8C,CACvD,OAAO,KAAK,cACd,CAKA,IAAW,oBAAsD,CAC/D,OAAO,KAAK,mBACd,CAKA,IAAW,mBAAmBC,EAA6C,CACzE,KAAK,oBAAsBA,CAC7B,CAOO,cAAcC,EAA0BC,EAA0B,CACvE,KAAK,eAAiBD,EAAQ,MAAM,EAAGC,CAAU,CACnD,CAKO,cAAqB,CAC1B,KAAK,eAAiB,CAAC,CACzB,CAKO,yBAAgC,CACjC,KAAK,sBACP,KAAK,oBAAoB,QAAQ,EACjC,KAAK,oBAAsB,OAE/B,CAOO,gBAAgBC,EAA+B,CACpD,QAASC,EAAI,EAAGA,EAAI,KAAK,eAAe,OAAQA,IAAK,CACnD,IAAMC,EAAQ,KAAK,eAAeD,CAAC,EACnC,GAAIC,EAAM,MAAQF,EAAO,KAAOE,EAAM,MAAQF,EAAO,KAAOE,EAAM,OAASF,EAAO,KAChF,OAAOC,CAEX,CACA,MAAO,EACT,CAMO,mBAAmBE,EAA+B,CACvD,GAAI,CAACA,EACH,OAGF,IAAIC,EAAc,GACd,KAAK,sBACPA,EAAc,KAAK,gBAAgB,KAAK,oBAAoB,KAAK,GAGnE,KAAK,oBAAoB,KAAK,CAC5B,YAAAA,EACA,YAAa,KAAK,eAAe,MACnC,CAAC,CACH,CAKO,OAAc,CACnB,KAAK,wBAAwB,EAC7B,KAAK,aAAa,CACpB,CACF,ECtFO,IAAMC,EAAN,cAA0BC,CAAiD,CAqBhF,YAAYC,EAAwC,CAClD,MAAM,EAnBR,KAAQ,kBAAoB,KAAK,UAAU,IAAIC,CAAgC,EAC/E,KAAQ,WAAa,KAAK,UAAU,IAAIA,CAAoC,EAG5E,KAAQ,OAAS,IAAIC,EAGrB,KAAQ,eAAiB,KAAK,UAAU,IAAIC,CAAqB,EAEjE,KAAiB,eAAiB,KAAK,UAAU,IAAIC,CAAe,EACpE,KAAgB,cAAgB,KAAK,eAAe,MACpD,KAAiB,gBAAkB,KAAK,UAAU,IAAIA,CAAe,EACrE,KAAgB,eAAiB,KAAK,gBAAgB,MASpD,KAAK,gBAAkBJ,GAAS,gBAAkB,GACpD,CARA,IAAW,oBAAuD,CAChE,OAAO,KAAK,eAAe,kBAC7B,CAQO,SAASK,EAA0B,CACxC,KAAK,UAAYA,EACjB,KAAK,WAAW,MAAQ,IAAIC,EAAgBD,CAAQ,EACpD,KAAK,QAAU,IAAIE,EAAaF,EAAU,KAAK,WAAW,KAAK,EAC/D,KAAK,mBAAqB,IAAIG,EAAkBH,CAAQ,EACxD,KAAK,UAAU,KAAK,UAAU,cAAc,IAAM,KAAK,eAAe,CAAC,CAAC,EACxE,KAAK,UAAU,KAAK,UAAU,SAAS,IAAM,KAAK,eAAe,CAAC,CAAC,EACnE,KAAK,UAAUI,EAAa,IAAM,KAAK,iBAAiB,CAAC,CAAC,CAC5D,CAEQ,gBAAuB,CAC7B,KAAK,kBAAkB,MAAM,EACzB,KAAK,OAAO,kBAAoB,KAAK,OAAO,mBAAmB,cACjE,KAAK,kBAAkB,MAAQC,EAAkB,IAAM,CACrD,IAAMC,EAAO,KAAK,OAAO,iBACzB,KAAK,OAAO,gBAAgB,EAC5B,KAAK,aAAaA,EAAO,CAAE,GAAG,KAAK,OAAO,kBAAmB,YAAa,EAAK,EAAG,CAAE,SAAU,EAAK,CAAC,CACtG,EAAG,GAAG,EAEV,CAEO,iBAAiBC,EAAwC,CAC9D,KAAK,eAAe,wBAAwB,EAC5C,KAAK,oBAAoB,0BAA0B,EACnD,KAAK,eAAe,aAAa,EAC5BA,GACH,KAAK,OAAO,gBAAgB,CAEhC,CAEO,uBAA8B,CACnC,KAAK,eAAe,wBAAwB,CAC9C,CASO,SAASD,EAAcE,EAAgCC,EAAyD,CACrH,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,QAC3B,MAAM,IAAI,MAAM,2CAA2C,EAG7D,KAAK,gBAAgB,KAAK,EAE1B,KAAK,OAAO,kBAAoBD,EAE5B,KAAK,OAAO,yBAAyBF,EAAME,CAAa,GAC1D,KAAK,qBAAqBF,EAAME,CAAc,EAGhD,IAAME,EAAQ,KAAK,mBAAmBJ,EAAME,EAAeC,CAAqB,EAChF,YAAK,aAAaD,CAAa,EAC/B,KAAK,OAAO,iBAAmBF,EAE/B,KAAK,eAAe,KAAK,EAElBI,CACT,CAEQ,qBAAqBJ,EAAcE,EAAqC,CAC9E,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,SAAW,CAAC,KAAK,mBAC5C,MAAM,IAAI,MAAM,2CAA2C,EAE7D,GAAI,CAAC,KAAK,OAAO,kBAAkBF,CAAI,EAAG,CACxC,KAAK,iBAAiB,EACtB,MACF,CAGA,KAAK,iBAAiB,EAAI,EAE1B,IAAMK,EAA2B,CAAC,EAC9BC,EACAC,EAAS,KAAK,QAAQ,KAAKP,EAAM,EAAG,EAAGE,CAAa,EAExD,KAAOK,IAAWD,GAAY,MAAQC,EAAO,KAAOD,GAAY,MAAQC,EAAO,MACzE,EAAAF,EAAQ,QAAU,KAAK,kBADwD,CAInFC,EAAaC,EACbF,EAAQ,KAAKC,CAAU,EACvB,IAAME,EAAO,KAAK,UAAU,KACxBC,EAAUH,EAAW,IAAMA,EAAW,KACtCI,EAAUJ,EAAW,IACrBG,GAAWD,IACbE,GAAW,KAAK,MAAMD,EAAUD,CAAI,EACpCC,EAAUA,EAAUD,GAEtBD,EAAS,KAAK,QAAQ,KAAKP,EAAMU,EAASD,EAASP,CAAa,CAClE,CAEA,KAAK,eAAe,cAAcG,EAAS,KAAK,eAAe,EAC3DH,EAAc,aAChB,KAAK,mBAAmB,2BAA2BG,EAASH,EAAc,WAAW,CAEzF,CAEQ,mBAAmBF,EAAcE,EAAgCC,EAAyD,CAChI,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,QAC3B,MAAO,GAET,GAAI,CAAC,KAAK,OAAO,kBAAkBH,CAAI,EACrC,YAAK,UAAU,eAAe,EAC9B,KAAK,iBAAiB,EACf,GAGT,IAAMO,EAAS,KAAK,QAAQ,sBAAsBP,EAAME,EAAe,KAAK,OAAO,gBAAgB,EACnG,OAAO,KAAK,cAAcK,EAAQL,GAAe,YAAaC,GAAuB,QAAQ,CAC/F,CASO,aAAaH,EAAcE,EAAgCC,EAAyD,CACzH,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,QAC3B,MAAM,IAAI,MAAM,2CAA2C,EAG7D,KAAK,gBAAgB,KAAK,EAE1B,KAAK,OAAO,kBAAoBD,EAE5B,KAAK,OAAO,yBAAyBF,EAAME,CAAa,GAC1D,KAAK,qBAAqBF,EAAME,CAAc,EAGhD,IAAME,EAAQ,KAAK,uBAAuBJ,EAAME,EAAeC,CAAqB,EACpF,YAAK,aAAaD,CAAa,EAC/B,KAAK,OAAO,iBAAmBF,EAE/B,KAAK,eAAe,KAAK,EAElBI,CACT,CAEQ,aAAaF,EAAsC,CACzD,KAAK,eAAe,mBAAmB,CAAC,CAACA,GAAe,WAAW,CACrE,CAEQ,uBAAuBF,EAAcE,EAAgCC,EAAyD,CACpI,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,QAC3B,MAAO,GAET,GAAI,CAAC,KAAK,OAAO,kBAAkBH,CAAI,EACrC,YAAK,UAAU,eAAe,EAC9B,KAAK,iBAAiB,EACf,GAGT,IAAMO,EAAS,KAAK,QAAQ,0BAA0BP,EAAME,EAAe,KAAK,OAAO,gBAAgB,EACvG,OAAO,KAAK,cAAcK,EAAQL,GAAe,YAAaC,GAAuB,QAAQ,CAC/F,CAOQ,cAAcI,EAAmClB,EAAoCsB,EAA6B,CACxH,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,mBAC3B,MAAO,GAIT,GADA,KAAK,eAAe,wBAAwB,EACxC,CAACJ,EACH,YAAK,UAAU,eAAe,EACvB,GAIT,GADA,KAAK,UAAU,OAAOA,EAAO,IAAKA,EAAO,IAAKA,EAAO,IAAI,EACrDlB,EAAS,CACX,IAAMuB,EAAmB,KAAK,mBAAmB,uBAAuBL,EAAQlB,CAAO,EACnFuB,IACF,KAAK,eAAe,mBAAqBA,EAE7C,CAEA,GAAI,CAACD,IAECJ,EAAO,KAAQ,KAAK,UAAU,OAAO,OAAO,UAAY,KAAK,UAAU,MAASA,EAAO,IAAM,KAAK,UAAU,OAAO,OAAO,WAAW,CACvI,IAAIM,EAASN,EAAO,IAAM,KAAK,UAAU,OAAO,OAAO,UACvDM,GAAU,KAAK,MAAM,KAAK,UAAU,KAAO,CAAC,EAC5C,KAAK,UAAU,YAAYA,CAAM,CACnC,CAEF,MAAO,EACT,CACF", +- "names": ["toDisposable", "fn", "dispose", "arg", "d", "combinedDisposable", "disposables", "DisposableStore", "o", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "i", "len", "EventUtils", "forward", "from", "to", "e", "map", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "disposableTimeout", "handler", "timeout", "store", "timer", "disposable", "toDisposable", "SearchLineCache", "Disposable", "_terminal", "MutableDisposable", "toDisposable", "combinedDisposable", "delay", "disposableTimeout", "elapsed", "row", "entry", "lineIndex", "trimRight", "strings", "lineOffsets", "line", "nextLine", "lineWrapsToNext", "string", "lastCell", "SearchState", "term", "options", "newOptions", "SearchEngine", "_terminal", "_lineCache", "term", "startRow", "startCol", "searchOptions", "searchPosition", "result", "y", "cachedSearchTerm", "prevSelectedPos", "isReverseSearch", "searchIndex", "line", "row", "col", "cache", "stringLine", "offsets", "offset", "searchTerm", "searchStringLine", "resultIndex", "searchRegex", "foundTerm", "startRowOffset", "endRowOffset", "startColOffset", "endColOffset", "startColIndex", "size", "i", "cell", "char", "nextCell", "cols", "lineIndex", "DecorationManager", "Disposable", "_terminal", "toDisposable", "results", "options", "match", "decorations", "decoration", "result", "dispose", "element", "borderColor", "isActiveResult", "decorationRanges", "currentCol", "remainingSize", "markerOffset", "amountThisRow", "range", "marker", "disposables", "e", "SearchResultTracker", "Disposable", "Emitter", "decoration", "results", "maxResults", "result", "i", "match", "hasDecorations", "resultIndex", "SearchAddon", "Disposable", "options", "MutableDisposable", "SearchState", "SearchResultTracker", "Emitter", "terminal", "SearchLineCache", "SearchEngine", "DecorationManager", "toDisposable", "disposableTimeout", "term", "retainCachedSearchTerm", "searchOptions", "internalSearchOptions", "found", "results", "prevResult", "result", "cols", "nextCol", "nextRow", "noScroll", "activeDecoration", "scroll"] ++ "sourcesContent": ["/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal lifecycle utilities for xterm.js core.\n * Simplified from VS Code's lifecycle.ts - no tracking/leak detection.\n */\n\nexport interface IDisposable {\n dispose(): void;\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n return { dispose: fn };\n}\n\nexport function dispose(disposable: T): T;\nexport function dispose(disposable: T | undefined): T | undefined;\nexport function dispose(disposables: T[]): T[];\nexport function dispose(arg: T | T[] | undefined): T | T[] | undefined {\n if (!arg) {\n return arg;\n }\n if (Array.isArray(arg)) {\n for (const d of arg) {\n d.dispose();\n }\n return [];\n }\n arg.dispose();\n return arg;\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n return toDisposable(() => dispose(disposables));\n}\n\nexport class DisposableStore implements IDisposable {\n private readonly _disposables = new Set();\n private _isDisposed = false;\n\n public get isDisposed(): boolean {\n return this._isDisposed;\n }\n\n public add(o: T): T {\n if (this._isDisposed) {\n o.dispose();\n } else {\n this._disposables.add(o);\n }\n return o;\n }\n\n public dispose(): void {\n if (this._isDisposed) {\n return;\n }\n this._isDisposed = true;\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n\n public clear(): void {\n for (const d of this._disposables) {\n d.dispose();\n }\n this._disposables.clear();\n }\n}\n\nexport abstract class Disposable implements IDisposable {\n public static readonly None: IDisposable = Object.freeze({ dispose() { } });\n\n protected readonly _store = new DisposableStore();\n\n public dispose(): void {\n this._store.dispose();\n }\n\n protected _register(o: T): T {\n return this._store.add(o);\n }\n}\n\nexport class MutableDisposable implements IDisposable {\n private _value: T | undefined;\n private _isDisposed = false;\n\n public get value(): T | undefined {\n return this._isDisposed ? undefined : this._value;\n }\n\n public set value(value: T | undefined) {\n if (this._isDisposed || value === this._value) {\n return;\n }\n this._value?.dispose();\n this._value = value;\n }\n\n public clear(): void {\n this.value = undefined;\n }\n\n public dispose(): void {\n this._isDisposed = true;\n this._value?.dispose();\n this._value = undefined;\n }\n}\n", "/**\n * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal event utilities for xterm.js core.\n * Simplified from VS Code's event.ts - no leak detection/profiling.\n */\n\nimport { IDisposable, DisposableStore, toDisposable } from './Lifecycle';\n\nexport interface IEvent {\n (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable;\n}\n\nexport class Emitter {\n private _listeners: { fn: (e: T) => any, thisArgs: any }[] = [];\n private _disposed = false;\n private _event: IEvent | undefined;\n\n public get event(): IEvent {\n if (this._event) {\n return this._event;\n }\n this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n if (this._disposed) {\n return toDisposable(() => {});\n }\n\n const entry = { fn: listener, thisArgs };\n this._listeners = this._listeners.slice();\n this._listeners.push(entry);\n\n const result = toDisposable(() => {\n const idx = this._listeners.indexOf(entry);\n if (idx !== -1) {\n this._listeners = this._listeners.slice();\n this._listeners.splice(idx, 1);\n }\n });\n\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(result);\n } else {\n disposables.add(result);\n }\n }\n\n return result;\n };\n return this._event;\n }\n\n public fire(event: T): void {\n if (this._disposed || !this._listeners.length) {\n return;\n }\n if (this._listeners.length === 1) {\n this._listeners[0].fn.call(this._listeners[0].thisArgs, event);\n return;\n }\n const listeners = this._listeners;\n for (let i = 0, len = listeners.length; i < len; ++i) {\n listeners[i].fn.call(listeners[i].thisArgs, event);\n }\n }\n\n public dispose(): void {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n this._listeners.length = 0;\n }\n}\n\nexport namespace EventUtils {\n export function forward(from: IEvent, to: Emitter): IDisposable {\n return from(e => to.fire(e));\n }\n\n export function map(event: IEvent, map: (i: I) => O): IEvent {\n return (listener: (e: O) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n return event(i => listener.call(thisArgs, map(i)), undefined, disposables);\n };\n }\n\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent;\n export function any(...events: IEvent[]): IEvent {\n return (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => {\n const store = new DisposableStore();\n for (const event of events) {\n store.add(event(e => listener.call(thisArgs, e)));\n }\n if (disposables) {\n if (Array.isArray(disposables)) {\n disposables.push(store);\n } else {\n disposables.add(store);\n }\n }\n return store;\n };\n }\n\n export function runAndSubscribe(event: IEvent, handler: (e: T) => void, initial: T): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void): IDisposable;\n export function runAndSubscribe(event: IEvent, handler: (e: T | undefined) => void, initial?: T): IDisposable {\n handler(initial);\n return event(e => handler(e));\n }\n}\n", "/**\n * Copyright (c) 2026 The xterm.js authors. All rights reserved.\n * @license MIT\n *\n * Minimal async helpers for xterm.js core.\n */\n\nimport { DisposableStore, IDisposable, toDisposable } from './Lifecycle';\n\nexport function timeout(millis: number): Promise {\n return new Promise(resolve => setTimeout(resolve, millis));\n}\n\n/**\n * Creates a timeout that can be disposed using its returned value.\n * @param handler The timeout handler.\n * @param timeout An optional timeout in milliseconds.\n * @param store An optional {@link DisposableStore} that will have the timeout disposable managed\n * automatically.\n */\nexport function disposableTimeout(handler: () => void, timeout = 0, store?: DisposableStore): IDisposable {\n const timer = setTimeout(() => {\n handler();\n if (store) {\n disposable.dispose();\n }\n }, timeout);\n const disposable = toDisposable(() => {\n clearTimeout(timer);\n });\n store?.add(disposable);\n return disposable;\n}\n\nexport class TimeoutTimer implements IDisposable {\n private _token: any = -1;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n if (this._token !== -1) {\n clearTimeout(this._token);\n this._token = -1;\n }\n }\n\n public cancelAndSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed TimeoutTimer');\n }\n this.cancel();\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n\n public setIfNotSet(runner: () => void, timeout: number): void {\n if (this._isDisposed) {\n throw new Error('Calling setIfNotSet on a disposed TimeoutTimer');\n }\n if (this._token !== -1) {\n return;\n }\n this._token = setTimeout(() => {\n this._token = -1;\n runner();\n }, timeout);\n }\n}\n\n/**\n * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled\n * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not\n * run yet.\n */\nexport class MicrotaskTimer implements IDisposable {\n private _isScheduled = false;\n private _isDisposed = false;\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n\n public cancel(): void {\n this._isScheduled = false;\n }\n\n public set(runner: () => void): void {\n if (this._isDisposed) {\n throw new Error('Calling set on a disposed MicrotaskTimer');\n }\n if (this._isScheduled) {\n return;\n }\n this._isScheduled = true;\n queueMicrotask(() => {\n if (!this._isScheduled) {\n return;\n }\n this._isScheduled = false;\n runner();\n });\n }\n}\n\nexport class IntervalTimer implements IDisposable {\n private _disposable: IDisposable | undefined;\n private _isDisposed = false;\n\n public cancel(): void {\n this._disposable?.dispose();\n this._disposable = undefined;\n }\n\n public cancelAndSet(runner: () => void, interval: number, context: Window | typeof globalThis = globalThis): void {\n if (this._isDisposed) {\n throw new Error('Calling cancelAndSet on a disposed IntervalTimer');\n }\n this.cancel();\n const handle = context.setInterval(() => {\n runner();\n }, interval);\n this._disposable = {\n dispose: () => {\n context.clearInterval(handle as any);\n this._disposable = undefined;\n }\n };\n }\n\n public dispose(): void {\n this.cancel();\n this._isDisposed = true;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\n\nexport type LineCacheEntry = [\n /**\n * The string representation of a line (as opposed to the buffer cell representation).\n */\n lineAsString: string,\n /**\n * The offsets where each line starts when the entry describes a wrapped line.\n */\n lineOffsets: number[]\n];\n\n/**\n * Configuration constants for the search line cache functionality.\n */\nconst enum Constants {\n /**\n * Time-to-live for cached search results in milliseconds. After this duration, cached search\n * results will be invalidated to ensure they remain consistent with terminal content changes.\n */\n LINES_CACHE_TIME_TO_LIVE = 15000\n}\n\nexport class SearchLineCache extends Disposable {\n /**\n * translateBufferLineToStringWithWrap is a fairly expensive call.\n * We memoize the calls into an array that has a time based ttl.\n * _linesCache is also invalidated when the terminal cursor moves.\n */\n private _linesCache: LineCacheEntry[] | undefined;\n private _linesCacheTimeout = this._register(new MutableDisposable());\n private _linesCacheDisposables = this._register(new MutableDisposable());\n // Track access to avoid recreating a timeout on every init call which occurs once per search\n // result (findNext/findPrevious -> _highlightAllMatches -> find loop).\n private _lastAccessTimestamp = 0;\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this._destroyLinesCache()));\n }\n\n /**\n * Sets up a line cache with a ttl\n */\n public initLinesCache(): void {\n if (!this._linesCache) {\n this._linesCache = new Array(this._terminal.buffer.active.length);\n this._linesCacheDisposables.value = combinedDisposable(\n this._terminal.onLineFeed(() => this._destroyLinesCache()),\n this._terminal.onCursorMove(() => this._destroyLinesCache()),\n this._terminal.onResize(() => this._destroyLinesCache())\n );\n }\n\n this._lastAccessTimestamp = Date.now();\n if (!this._linesCacheTimeout.value) {\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);\n }\n }\n\n private _destroyLinesCache(): void {\n this._linesCache = undefined;\n this._lastAccessTimestamp = 0;\n this._linesCacheDisposables.clear();\n this._linesCacheTimeout.clear();\n }\n\n private _scheduleLinesCacheTimeout(delay: number): void {\n this._linesCacheTimeout.value = disposableTimeout(() => {\n if (!this._linesCache) {\n return;\n }\n const now = Date.now();\n const elapsed = now - this._lastAccessTimestamp;\n if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {\n this._destroyLinesCache();\n return;\n }\n this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);\n }, delay);\n }\n\n public getLineFromCache(row: number): LineCacheEntry | undefined {\n return this._linesCache?.[row];\n }\n\n public setLineInCache(row: number, entry: LineCacheEntry): void {\n if (this._linesCache) {\n this._linesCache[row] = entry;\n }\n }\n\n /**\n * Translates a buffer line to a string, including subsequent lines if they are wraps.\n * Wide characters will count as two columns in the resulting string. This\n * function is useful for getting the actual text underneath the raw selection\n * position.\n * @param lineIndex The index of the line being translated.\n * @param trimRight Whether to trim whitespace to the right.\n */\n public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {\n const strings = [];\n const lineOffsets = [0];\n // A single line longer than the whole scrollback leaves every buffer row wrapped, and the\n // buffer's ring answers an out-of-range row by cycling back to the start, so an unbounded walk\n // never reaches an unwrapped line.\n const bufferLength = this._terminal.buffer.active.length;\n let line = this._terminal.buffer.active.getLine(lineIndex);\n while (line) {\n const nextLine = lineIndex + 1 < bufferLength ? this._terminal.buffer.active.getLine(lineIndex + 1) : undefined;\n const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;\n let string = line.translateToString(!lineWrapsToNext && trimRight);\n if (lineWrapsToNext && nextLine) {\n const lastCell = line.getCell(line.length - 1);\n const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;\n // a wide character wrapped to the next line\n if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {\n string = string.slice(0, -1);\n }\n }\n strings.push(string);\n if (lineWrapsToNext) {\n lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);\n } else {\n break;\n }\n lineIndex++;\n line = nextLine;\n }\n return [strings.join(''), lineOffsets];\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchOptions } from '@xterm/addon-search';\n\n/**\n * Manages search state including cached search terms, options tracking, and validation.\n * This class provides a centralized way to handle search state consistency and option changes.\n */\nexport class SearchState {\n private _cachedSearchTerm: string | undefined;\n private _lastSearchOptions: ISearchOptions | undefined;\n\n /**\n * Gets the currently cached search term.\n */\n public get cachedSearchTerm(): string | undefined {\n return this._cachedSearchTerm;\n }\n\n /**\n * Sets the cached search term.\n */\n public set cachedSearchTerm(term: string | undefined) {\n this._cachedSearchTerm = term;\n }\n\n /**\n * Gets the last search options used.\n */\n public get lastSearchOptions(): ISearchOptions | undefined {\n return this._lastSearchOptions;\n }\n\n /**\n * Sets the last search options used.\n */\n public set lastSearchOptions(options: ISearchOptions | undefined) {\n this._lastSearchOptions = options;\n }\n\n /**\n * Validates a search term to ensure it's not empty or invalid.\n * @param term The search term to validate.\n * @returns true if the term is valid for searching.\n */\n public isValidSearchTerm(term: string): boolean {\n return !!(term && term.length > 0);\n }\n\n /**\n * Determines if search options have changed compared to the last search.\n * @param newOptions The new search options to compare.\n * @returns true if the options have changed.\n */\n public didOptionsChange(newOptions?: ISearchOptions): boolean {\n if (!this._lastSearchOptions) {\n return true;\n }\n if (!newOptions) {\n return false;\n }\n if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {\n return true;\n }\n if (this._lastSearchOptions.regex !== newOptions.regex) {\n return true;\n }\n if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {\n return true;\n }\n return false;\n }\n\n /**\n * Determines if a new search should trigger highlighting updates.\n * @param term The search term.\n * @param options The search options.\n * @returns true if highlighting should be updated.\n */\n public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {\n if (!options?.decorations) {\n return false;\n }\n return this._cachedSearchTerm === undefined ||\n term !== this._cachedSearchTerm ||\n this.didOptionsChange(options);\n }\n\n /**\n * Clears the cached search term.\n */\n public clearCachedTerm(): void {\n this._cachedSearchTerm = undefined;\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this._cachedSearchTerm = undefined;\n this._lastSearchOptions = undefined;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal } from '@xterm/xterm';\nimport type { ISearchOptions } from '@xterm/addon-search';\nimport type { SearchLineCache } from './SearchLineCache';\n\n/**\n * Represents the position to start a search from.\n */\ninterface ISearchPosition {\n startCol: number;\n startRow: number;\n}\n\n/**\n * Represents a search result with its position and content.\n */\nexport interface ISearchResult {\n term: string;\n col: number;\n row: number;\n size: number;\n}\n\n/**\n * Configuration constants for the search engine functionality.\n */\nconst enum Constants {\n /**\n * Characters that are considered non-word characters for search boundary detection. These\n * characters are used to determine word boundaries when performing whole-word searches. Includes\n * common punctuation, symbols, and whitespace characters.\n */\n NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\\\;:\"\\',./<>?'\n}\n\n/**\n * Core search engine that handles finding text within terminal content.\n * This class is responsible for the actual search algorithms and position calculations.\n */\nexport class SearchEngine {\n constructor(\n private readonly _terminal: Terminal,\n private readonly _lineCache: SearchLineCache\n ) {}\n\n /**\n * Find the first occurrence of a term starting from a specific position.\n * @param term The search term.\n * @param startRow The row to start searching from.\n * @param startCol The column to start searching from.\n * @param searchOptions Search options.\n * @returns The search result if found, undefined otherwise.\n */\n public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n if (startCol >= this._terminal.cols) {\n throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n if (this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n return result;\n }\n\n /**\n * Find the next occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine incremental behavior.\n * @returns The search result if found, undefined otherwise.\n */\n public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startCol = 0;\n let startRow = 0;\n if (prevSelectedPos) {\n if (cachedSearchTerm === term) {\n startCol = prevSelectedPos.end.x;\n startRow = prevSelectedPos.end.y;\n } else {\n startCol = prevSelectedPos.start.x;\n startRow = prevSelectedPos.start.y;\n }\n }\n\n this._lineCache.initLinesCache();\n\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n // Search startRow\n let result = this._findInLine(term, searchPosition, searchOptions);\n // Search from startRow + 1 to end\n if (!result) {\n for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {\n if (this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n // If we hit the bottom and didn't search from the very top wrap back up\n if (!result && startRow !== 0) {\n for (let y = 0; y < startRow; y++) {\n // Row 0 is never skipped: it can be a continuation whose line start was trimmed from the\n // scrollback, and nothing earlier in this loop has searched it.\n if (y > 0 && this._isRowCoveredByEarlierSearch(y)) {\n continue;\n }\n searchPosition.startRow = y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n if (result) {\n break;\n }\n }\n }\n\n // If there is only one result, wrap back and return selection if it exists.\n if (!result && prevSelectedPos) {\n searchPosition.startRow = prevSelectedPos.start.y;\n searchPosition.startCol = 0;\n result = this._findInLine(term, searchPosition, searchOptions);\n }\n\n return result;\n }\n\n /**\n * Find the previous occurrence of a term with wrapping and selection management.\n * @param term The search term.\n * @param searchOptions Search options.\n * @param cachedSearchTerm The cached search term to determine if expansion should occur.\n * @returns The search result if found, undefined otherwise.\n */\n public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {\n if (!term || term.length === 0) {\n this._terminal.clearSelection();\n return undefined;\n }\n\n const prevSelectedPos = this._terminal.getSelectionPosition();\n this._terminal.clearSelection();\n\n let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;\n const startCol = this._terminal.cols;\n const isReverseSearch = true;\n\n this._lineCache.initLinesCache();\n const searchPosition: ISearchPosition = {\n startRow,\n startCol\n };\n\n let result: ISearchResult | undefined;\n if (prevSelectedPos) {\n searchPosition.startRow = startRow = prevSelectedPos.start.y;\n searchPosition.startCol = prevSelectedPos.start.x;\n if (cachedSearchTerm !== term) {\n // Try to expand selection to right first.\n result = this._findInLine(term, searchPosition, searchOptions, false);\n if (!result) {\n // If selection was not able to be expanded to the right, then try reverse search\n searchPosition.startRow = startRow = prevSelectedPos.end.y;\n searchPosition.startCol = prevSelectedPos.end.x;\n }\n }\n }\n\n result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n\n // Search from startRow - 1 to top\n if (!result) {\n searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);\n for (let y = startRow - 1; y >= 0; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n // If we hit the top and didn't search from the very bottom wrap back down\n if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {\n for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {\n searchPosition.startRow = y;\n result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);\n if (result) {\n break;\n }\n }\n }\n\n return result;\n }\n\n /**\n * A found substring is a whole word if it doesn't have an alphanumeric character directly\n * adjacent to it.\n * @param searchIndex starting index of the potential whole word substring\n * @param line entire string in which the potential whole word was found\n * @param term the substring that starts at searchIndex\n */\n private _isWholeWord(searchIndex: number, line: string, term: string): boolean {\n return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&\n (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));\n }\n\n /** `_isWholeWord` gated on the option, so a rejected hit can be stepped past instead of ending the scan. */\n private _satisfiesWholeWord(searchIndex: number, line: string, term: string, searchOptions: ISearchOptions): boolean {\n return !searchOptions.wholeWord || this._isWholeWord(searchIndex, line, term);\n }\n\n /**\n * Whether an earlier `_findInLine` in this same call already scanned this row's line from an\n * equal or lower offset, which makes rescanning it pure O(rows^2) work on one long line. Sound\n * for every option because `_findInLine` returns the first accepted match at or after its\n * offset, which is monotone in that offset. Only valid once such a search has happened \u2014 the\n * wrap-around loop starts at row 0, whose line start may have been trimmed from the scrollback.\n */\n private _isRowCoveredByEarlierSearch(row: number): boolean {\n return this._terminal.buffer.active.getLine(row)?.isWrapped === true;\n }\n\n /**\n * Searches a line for a search term. Takes the provided terminal line and searches the text line,\n * which may contain subsequent terminal lines if the text is wrapped. If the provided line number\n * is part of a wrapped text line that started on an earlier line then it is skipped since it will\n * be properly searched when the terminal line that the text starts on is searched.\n * @param term The search term.\n * @param searchPosition The position to start the search.\n * @param searchOptions Search options.\n * @param isReverseSearch Whether the search should start from the right side of the terminal and\n * search to the left.\n * @returns The search result if it was found.\n */\n private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {\n // Ignore wrapped lines, only consider on unwrapped line (first row of command string).\n if (isReverseSearch) {\n // Reverse search never rewinds: its caller carries startCol down the rows of the line. Row 0\n // is searched even when wrapped, since its line start may have been trimmed from the scrollback.\n if (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {\n searchPosition.startCol += this._terminal.cols;\n return;\n }\n } else {\n // A loop rather than recursion: one frame per wrapped row overflows the stack on a line long\n // enough to fill the scrollback. Bounded at row 0 because after a reflow the buffer's ring\n // holds stale entries at negative indices, so `getLine(-1)` answers with a wrapped line.\n while (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {\n searchPosition.startRow--;\n searchPosition.startCol += this._terminal.cols;\n }\n }\n const row = searchPosition.startRow;\n const col = searchPosition.startCol;\n\n let cache = this._lineCache.getLineFromCache(row);\n if (!cache) {\n cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);\n this._lineCache.setLineInCache(row, cache);\n }\n const [stringLine, offsets] = cache;\n\n const offset = this._bufferColsToStringOffset(row, col, offsets);\n let searchTerm = term;\n let searchStringLine = stringLine;\n if (!searchOptions.regex) {\n searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();\n searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();\n }\n\n let resultIndex = -1;\n if (searchOptions.regex) {\n const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');\n let foundTerm: RegExpExecArray | null;\n if (isReverseSearch) {\n // This loop will get the resultIndex of the _last_ regex match in the range 0..offset\n while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {\n const matchIndex = searchRegex.lastIndex - foundTerm[0].length;\n if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) {\n resultIndex = matchIndex;\n term = foundTerm[0];\n }\n searchRegex.lastIndex = matchIndex + 1;\n }\n } else {\n // Driven over the whole line from `offset` rather than over `slice(offset)`: a slice\n // re-anchors ^ and \\b at whatever column the row happened to wrap at, and only\n // first-accepted-match-at-or-after-offset is monotone in `offset`, which is what lets\n // `_isRowCoveredByEarlierSearch` skip a wrapped row an earlier scan already covered.\n searchRegex.lastIndex = offset;\n while (foundTerm = searchRegex.exec(searchStringLine)) {\n const matchIndex = searchRegex.lastIndex - foundTerm[0].length;\n if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) {\n resultIndex = matchIndex;\n term = foundTerm[0];\n break;\n }\n // A zero-length or rejected match would otherwise repeat forever.\n searchRegex.lastIndex = matchIndex + 1;\n }\n }\n } else if (isReverseSearch) {\n let matchIndex = offset - searchTerm.length >= 0 ? searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length) : -1;\n // `lastIndexOf` clamps a negative fromIndex to 0, so index 0 has to end the walk.\n while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) {\n matchIndex = matchIndex > 0 ? searchStringLine.lastIndexOf(searchTerm, matchIndex - 1) : -1;\n }\n resultIndex = matchIndex;\n } else {\n let matchIndex = searchStringLine.indexOf(searchTerm, offset);\n while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) {\n matchIndex = searchStringLine.indexOf(searchTerm, matchIndex + 1);\n }\n resultIndex = matchIndex;\n }\n\n if (resultIndex >= 0) {\n // Adjust the row number and search index if needed since a \"line\" of text can span multiple\n // rows\n let startRowOffset = 0;\n while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {\n startRowOffset++;\n }\n let endRowOffset = startRowOffset;\n while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {\n endRowOffset++;\n }\n const startColOffset = resultIndex - offsets[startRowOffset];\n const endColOffset = resultIndex + term.length - offsets[endRowOffset];\n const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);\n const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);\n const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);\n\n return {\n term,\n col: startColIndex,\n row: row + startRowOffset,\n size\n };\n }\n }\n\n private _stringLengthToBufferSize(row: number, offset: number): number {\n const line = this._terminal.buffer.active.getLine(row);\n if (!line) {\n return 0;\n }\n for (let i = 0; i < offset; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n // Adjust the searchIndex to normalize emoji into single chars\n const char = cell.getChars();\n if (char.length > 1) {\n offset -= char.length - 1;\n }\n // Adjust the searchIndex for empty characters following wide unicode\n // chars (eg. CJK)\n const nextCell = line.getCell(i + 1);\n if (nextCell && nextCell.getWidth() === 0) {\n offset++;\n }\n }\n return offset;\n }\n\n /**\n * `cols` counts from the start of the logical line, so summing the cells of every row before the\n * resume point costs O(line) per call and the highlight-all pass makes one call per match.\n * `lineOffsets` already holds the string offset each wrapped row starts at \u2014 the same map used\n * above to turn a match index back into a row \u2014 so only the last, partial row needs cells. It is\n * also the map the row a match lands on is read from, which the cell sum disagreed with by one\n * for a row whose trailing cell is the null placeholder of a wide character that wrapped.\n */\n private _bufferColsToStringOffset(startRow: number, cols: number, lineOffsets: number[]): number {\n const rowsBack = Math.min(Math.floor(cols / this._terminal.cols), lineOffsets.length - 1);\n let offset = lineOffsets[rowsBack];\n const line = this._terminal.buffer.active.getLine(startRow + rowsBack);\n if (line) {\n const colsInRow = Math.min(cols - rowsBack * this._terminal.cols, this._terminal.cols);\n for (let i = 0; i < colsInRow; i++) {\n const cell = line.getCell(i);\n if (!cell) {\n break;\n }\n if (cell.getWidth()) {\n // Treat null characters as whitespace to align with the translateToString API\n offset += cell.getCode() === 0 ? 1 : cell.getChars().length;\n }\n }\n }\n return offset;\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';\nimport type { ISearchDecorationOptions } from '@xterm/addon-search';\nimport { dispose, Disposable, toDisposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a highlight decoration.\n */\ninterface IHighlight extends IDisposable {\n decoration: IDecoration;\n match: ISearchResult;\n}\n\n/**\n * Interface for managing multiple decorations for a single match.\n */\ninterface IMultiHighlight extends IDisposable {\n decorations: IDecoration[];\n match: ISearchResult;\n}\n\n/**\n * Manages visual decorations for search results including highlighting and active selection\n * indicators. This class handles the creation, styling, and disposal of search-related decorations.\n */\nexport class DecorationManager extends Disposable {\n private _highlightDecorations: IHighlight[] = [];\n private _highlightedLines: Set = new Set();\n\n constructor(private readonly _terminal: Terminal) {\n super();\n this._register(toDisposable(() => this.clearHighlightDecorations()));\n }\n\n /**\n * Creates decorations for all provided search results.\n * @param results The search results to create decorations for.\n * @param options The decoration options.\n */\n public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {\n this.clearHighlightDecorations();\n\n for (const match of results) {\n const decorations = this._createResultDecorations(match, options, false);\n if (decorations) {\n for (const decoration of decorations) {\n this._storeDecoration(decoration, match);\n }\n }\n }\n }\n\n /**\n * Creates decorations for the currently active search result.\n * @param result The active search result.\n * @param options The decoration options.\n * @returns The multi-highlight decoration or undefined if creation failed.\n */\n public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {\n const decorations = this._createResultDecorations(result, options, true);\n if (decorations) {\n return { decorations, match: result, dispose() { dispose(decorations); } };\n }\n return undefined;\n }\n\n /**\n * Clears all highlight decorations.\n */\n public clearHighlightDecorations(): void {\n dispose(this._highlightDecorations);\n this._highlightDecorations = [];\n this._highlightedLines.clear();\n }\n\n /**\n * Stores a decoration and tracks it for management.\n * @param decoration The decoration to store.\n * @param match The search result this decoration represents.\n */\n private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {\n this._highlightedLines.add(decoration.marker.line);\n this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });\n }\n\n /**\n * Applies styles to the decoration when it is rendered.\n * @param element The decoration's element.\n * @param borderColor The border color to apply.\n * @param isActiveResult Whether the element is part of the active search result.\n */\n private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {\n if (!element.classList.contains('xterm-find-result-decoration')) {\n element.classList.add('xterm-find-result-decoration');\n if (borderColor) {\n element.style.outline = `1px solid ${borderColor}`;\n }\n }\n if (isActiveResult) {\n element.classList.add('xterm-find-active-result-decoration');\n }\n }\n\n /**\n * Creates a decoration for the result and applies styles\n * @param result the search result for which to create the decoration\n * @param options the options for the decoration\n * @param isActiveResult whether this is the currently active result\n * @returns the decorations or undefined if the marker has already been disposed of\n */\n private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {\n // Gather decoration ranges for this match as it could wrap\n const decorationRanges: [number, number, number][] = [];\n let currentCol = result.col;\n let remainingSize = result.size;\n let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;\n while (remainingSize > 0) {\n const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);\n decorationRanges.push([markerOffset, currentCol, amountThisRow]);\n currentCol = 0;\n remainingSize -= amountThisRow;\n markerOffset++;\n }\n\n // Create the decorations\n const decorations: IDecoration[] = [];\n for (const range of decorationRanges) {\n const marker = this._terminal.registerMarker(range[0]);\n const decoration = this._terminal.registerDecoration({\n marker,\n x: range[1],\n width: range[2],\n layer: isActiveResult ? 'top' : 'bottom',\n backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,\n overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {\n color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,\n position: 'center'\n }\n });\n if (decoration) {\n const disposables: IDisposable[] = [];\n disposables.push(marker);\n disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));\n disposables.push(decoration.onDispose(() => dispose(disposables)));\n decorations.push(decoration);\n }\n }\n\n return decorations.length === 0 ? undefined : decorations;\n }\n}\n\n\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { ISearchResultChangeEvent } from '@xterm/addon-search';\nimport type { IDisposable } from '@xterm/xterm';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable } from 'common/Lifecycle';\nimport type { ISearchResult } from './SearchEngine';\n\n/**\n * Interface for managing a currently selected decoration.\n */\ninterface ISelectedDecoration extends IDisposable {\n match: ISearchResult;\n}\n\n/**\n * Tracks search results, manages result indexing, and fires events when results change.\n * This class provides centralized management of search result state and notifications.\n */\nexport class SearchResultTracker extends Disposable {\n private _searchResults: ISearchResult[] = [];\n private _selectedDecoration: ISelectedDecoration | undefined;\n\n private readonly _onDidChangeResults = this._register(new Emitter());\n public get onDidChangeResults(): IEvent { return this._onDidChangeResults.event; }\n\n /**\n * Gets the current search results.\n */\n public get searchResults(): ReadonlyArray {\n return this._searchResults;\n }\n\n /**\n * Gets the currently selected decoration.\n */\n public get selectedDecoration(): ISelectedDecoration | undefined {\n return this._selectedDecoration;\n }\n\n /**\n * Sets the currently selected decoration.\n */\n public set selectedDecoration(decoration: ISelectedDecoration | undefined) {\n this._selectedDecoration = decoration;\n }\n\n /**\n * Updates the search results with a new set of results.\n * @param results The new search results.\n * @param maxResults The maximum number of results to track.\n */\n public updateResults(results: ISearchResult[], maxResults: number): void {\n this._searchResults = results.slice(0, maxResults);\n }\n\n /**\n * Clears all search results.\n */\n public clearResults(): void {\n this._searchResults = [];\n }\n\n /**\n * Clears the selected decoration.\n */\n public clearSelectedDecoration(): void {\n if (this._selectedDecoration) {\n this._selectedDecoration.dispose();\n this._selectedDecoration = undefined;\n }\n }\n\n /**\n * Finds the index of a result in the current results array.\n * @param result The result to find.\n * @returns The index of the result, or -1 if not found.\n */\n public findResultIndex(result: ISearchResult): number {\n for (let i = 0; i < this._searchResults.length; i++) {\n const match = this._searchResults[i];\n if (match.row === result.row && match.col === result.col && match.size === result.size) {\n return i;\n }\n }\n return -1;\n }\n\n /**\n * Fires a result change event with the current state.\n * @param hasDecorations Whether decorations are enabled.\n */\n public fireResultsChanged(hasDecorations: boolean): void {\n if (!hasDecorations) {\n return;\n }\n\n let resultIndex = -1;\n if (this._selectedDecoration) {\n resultIndex = this.findResultIndex(this._selectedDecoration.match);\n }\n\n this._onDidChangeResults.fire({\n resultIndex,\n resultCount: this._searchResults.length\n });\n }\n\n /**\n * Resets all state.\n */\n public reset(): void {\n this.clearSelectedDecoration();\n this.clearResults();\n }\n}\n", "/**\n * Copyright (c) 2017 The xterm.js authors. All rights reserved.\n * @license MIT\n */\n\nimport type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';\nimport type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';\nimport { Emitter, type IEvent } from 'common/Event';\nimport { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';\nimport { disposableTimeout } from 'common/Async';\nimport { SearchLineCache } from './SearchLineCache';\nimport { SearchState } from './SearchState';\nimport { SearchEngine, type ISearchResult } from './SearchEngine';\nimport { DecorationManager } from './DecorationManager';\nimport { SearchResultTracker } from './SearchResultTracker';\n\ninterface IInternalSearchOptions {\n noScroll: boolean;\n}\n\n/**\n * Configuration constants for the search addon functionality.\n */\nconst enum Constants {\n /**\n * Default maximum number of search results to highlight simultaneously. This limit prevents\n * performance degradation when searching for very common terms that would result in excessive\n * highlighting decorations.\n */\n DEFAULT_HIGHLIGHT_LIMIT = 1000\n}\n\nexport class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {\n private _terminal: Terminal | undefined;\n private _highlightLimit: number;\n private _highlightTimeout = this._register(new MutableDisposable());\n private _lineCache = this._register(new MutableDisposable());\n\n // Component instances\n private _state = new SearchState();\n private _engine: SearchEngine | undefined;\n private _decorationManager: DecorationManager | undefined;\n private _resultTracker = this._register(new SearchResultTracker());\n\n private readonly _onAfterSearch = this._register(new Emitter());\n public readonly onAfterSearch = this._onAfterSearch.event;\n private readonly _onBeforeSearch = this._register(new Emitter());\n public readonly onBeforeSearch = this._onBeforeSearch.event;\n\n public get onDidChangeResults(): IEvent {\n return this._resultTracker.onDidChangeResults;\n }\n\n constructor(options?: Partial) {\n super();\n\n this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;\n }\n\n public activate(terminal: Terminal): void {\n this._terminal = terminal;\n this._lineCache.value = new SearchLineCache(terminal);\n this._engine = new SearchEngine(terminal, this._lineCache.value);\n this._decorationManager = new DecorationManager(terminal);\n this._register(this._terminal.onWriteParsed(() => this._updateMatches()));\n this._register(this._terminal.onResize(() => this._updateMatches()));\n this._register(toDisposable(() => this.clearDecorations()));\n }\n\n private _updateMatches(): void {\n this._highlightTimeout.clear();\n if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {\n this._highlightTimeout.value = disposableTimeout(() => {\n const term = this._state.cachedSearchTerm;\n this._state.clearCachedTerm();\n this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });\n }, 200);\n }\n }\n\n public clearDecorations(retainCachedSearchTerm?: boolean): void {\n this._resultTracker.clearSelectedDecoration();\n this._decorationManager?.clearHighlightDecorations();\n this._resultTracker.clearResults();\n if (!retainCachedSearchTerm) {\n this._state.clearCachedTerm();\n }\n }\n\n public clearActiveDecoration(): void {\n this._resultTracker.clearSelectedDecoration();\n }\n\n /**\n * Find the next instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {\n if (!this._terminal || !this._engine || !this._decorationManager) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n if (!this._state.isValidSearchTerm(term)) {\n this.clearDecorations();\n return;\n }\n\n // new search, clear out the old decorations\n this.clearDecorations(true);\n\n const results: ISearchResult[] = [];\n let prevResult: ISearchResult | undefined = undefined;\n let result = this._engine.find(term, 0, 0, searchOptions);\n\n while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {\n if (results.length >= this._highlightLimit) {\n break;\n }\n prevResult = result;\n results.push(prevResult);\n const cols = this._terminal.cols;\n let nextCol = prevResult.col + prevResult.size;\n let nextRow = prevResult.row;\n if (nextCol >= cols) {\n nextRow += Math.floor(nextCol / cols);\n nextCol = nextCol % cols;\n }\n result = this._engine.find(term, nextRow, nextCol, searchOptions);\n }\n\n this._resultTracker.updateResults(results, this._highlightLimit);\n if (searchOptions.decorations) {\n this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);\n }\n }\n\n private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Find the previous instance of the term, then scroll to and select it. If it\n * doesn't exist, do nothing.\n * @param term The search term.\n * @param searchOptions Search options.\n * @returns Whether a result was found.\n */\n public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n throw new Error('Cannot use addon until it has been loaded');\n }\n\n this._onBeforeSearch.fire();\n\n this._state.lastSearchOptions = searchOptions;\n\n if (this._state.shouldUpdateHighlighting(term, searchOptions)) {\n this._highlightAllMatches(term, searchOptions!);\n }\n\n const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);\n this._fireResults(searchOptions);\n this._state.cachedSearchTerm = term;\n\n this._onAfterSearch.fire();\n\n return found;\n }\n\n private _fireResults(searchOptions?: ISearchOptions): void {\n this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);\n }\n\n private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {\n if (!this._terminal || !this._engine) {\n return false;\n }\n if (!this._state.isValidSearchTerm(term)) {\n this._terminal.clearSelection();\n this.clearDecorations();\n return false;\n }\n\n const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);\n return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);\n }\n\n /**\n * Selects and scrolls to a result.\n * @param result The result to select.\n * @returns Whether a result was selected.\n */\n private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {\n if (!this._terminal || !this._decorationManager) {\n return false;\n }\n\n this._resultTracker.clearSelectedDecoration();\n if (!result) {\n this._terminal.clearSelection();\n return false;\n }\n\n this._terminal.select(result.col, result.row, result.size);\n if (options) {\n const activeDecoration = this._decorationManager.createActiveDecoration(result, options);\n if (activeDecoration) {\n this._resultTracker.selectedDecoration = activeDecoration;\n }\n }\n\n if (!noScroll) {\n // If it is not in the viewport then we scroll else it just gets selected\n if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {\n let scroll = result.row - this._terminal.buffer.active.viewportY;\n scroll -= Math.floor(this._terminal.rows / 2);\n this._terminal.scrollLines(scroll);\n }\n }\n return true;\n }\n}\n"], ++ "mappings": ";;;;;;;;;;;;;;;;AAYO,SAASA,EAAaC,EAA6B,CACxD,MAAO,CAAE,QAASA,CAAG,CACvB,CAKO,SAASC,EAA+BC,EAA+C,CAC5F,GAAI,CAACA,EACH,OAAOA,EAET,GAAI,MAAM,QAAQA,CAAG,EAAG,CACtB,QAAWC,KAAKD,EACdC,EAAE,QAAQ,EAEZ,MAAO,CAAC,CACV,CACA,OAAAD,EAAI,QAAQ,EACLA,CACT,CAEO,SAASE,KAAsBC,EAAyC,CAC7E,OAAON,EAAa,IAAME,EAAQI,CAAW,CAAC,CAChD,CAEO,IAAMC,EAAN,KAA6C,CAA7C,cACL,KAAiB,aAAe,IAAI,IACpC,KAAQ,YAAc,GAEtB,IAAW,YAAsB,CAC/B,OAAO,KAAK,WACd,CAEO,IAA2BC,EAAS,CACzC,OAAI,KAAK,YACPA,EAAE,QAAQ,EAEV,KAAK,aAAa,IAAIA,CAAC,EAElBA,CACT,CAEO,SAAgB,CACrB,GAAI,MAAK,YAGT,MAAK,YAAc,GACnB,QAAWJ,KAAK,KAAK,aACnBA,EAAE,QAAQ,EAEZ,KAAK,aAAa,MAAM,EAC1B,CAEO,OAAc,CACnB,QAAWA,KAAK,KAAK,aACnBA,EAAE,QAAQ,EAEZ,KAAK,aAAa,MAAM,CAC1B,CACF,EAEsBK,EAAf,KAAiD,CAAjD,cAGL,KAAmB,OAAS,IAAIF,EAEzB,SAAgB,CACrB,KAAK,OAAO,QAAQ,CACtB,CAEU,UAAiCC,EAAS,CAClD,OAAO,KAAK,OAAO,IAAIA,CAAC,CAC1B,CACF,EAZsBC,EACG,KAAoB,OAAO,OAAO,CAAE,SAAU,CAAE,CAAE,CAAC,EAarE,IAAMC,EAAN,KAAsE,CAAtE,cAEL,KAAQ,YAAc,GAEtB,IAAW,OAAuB,CAChC,OAAO,KAAK,YAAc,OAAY,KAAK,MAC7C,CAEA,IAAW,MAAMC,EAAsB,CACjC,KAAK,aAAeA,IAAU,KAAK,SAGvC,KAAK,QAAQ,QAAQ,EACrB,KAAK,OAASA,EAChB,CAEO,OAAc,CACnB,KAAK,MAAQ,MACf,CAEO,SAAgB,CACrB,KAAK,YAAc,GACnB,KAAK,QAAQ,QAAQ,EACrB,KAAK,OAAS,MAChB,CACF,EClGO,IAAMC,EAAN,KAAiB,CAAjB,cACL,KAAQ,WAAqD,CAAC,EAC9D,KAAQ,UAAY,GAGpB,IAAW,OAAmB,CAC5B,OAAI,KAAK,OACA,KAAK,QAEd,KAAK,OAAS,CAACC,EAAyBC,EAAgBC,IAAkD,CACxG,GAAI,KAAK,UACP,OAAOC,EAAa,IAAM,CAAC,CAAC,EAG9B,IAAMC,EAAQ,CAAE,GAAIJ,EAAU,SAAAC,CAAS,EACvC,KAAK,WAAa,KAAK,WAAW,MAAM,EACxC,KAAK,WAAW,KAAKG,CAAK,EAE1B,IAAMC,EAASF,EAAa,IAAM,CAChC,IAAMG,EAAM,KAAK,WAAW,QAAQF,CAAK,EACrCE,IAAQ,KACV,KAAK,WAAa,KAAK,WAAW,MAAM,EACxC,KAAK,WAAW,OAAOA,EAAK,CAAC,EAEjC,CAAC,EAED,OAAIJ,IACE,MAAM,QAAQA,CAAW,EAC3BA,EAAY,KAAKG,CAAM,EAEvBH,EAAY,IAAIG,CAAM,GAInBA,CACT,EACO,KAAK,OACd,CAEO,KAAKE,EAAgB,CAC1B,GAAI,KAAK,WAAa,CAAC,KAAK,WAAW,OACrC,OAEF,GAAI,KAAK,WAAW,SAAW,EAAG,CAChC,KAAK,WAAW,CAAC,EAAE,GAAG,KAAK,KAAK,WAAW,CAAC,EAAE,SAAUA,CAAK,EAC7D,MACF,CACA,IAAMC,EAAY,KAAK,WACvB,QAASC,EAAI,EAAGC,EAAMF,EAAU,OAAQC,EAAIC,EAAK,EAAED,EACjDD,EAAUC,CAAC,EAAE,GAAG,KAAKD,EAAUC,CAAC,EAAE,SAAUF,CAAK,CAErD,CAEO,SAAgB,CACjB,KAAK,YAGT,KAAK,UAAY,GACjB,KAAK,WAAW,OAAS,EAC3B,CACF,EAEiBI,MAAV,CACE,SAASC,EAAWC,EAAiBC,EAA6B,CACvE,OAAOD,EAAKE,GAAKD,EAAG,KAAKC,CAAC,CAAC,CAC7B,CAFOJ,EAAS,QAAAC,EAIT,SAASI,EAAUT,EAAkBS,EAA6B,CACvE,MAAO,CAAChB,EAAyBC,EAAgBC,IACxCK,EAAME,GAAKT,EAAS,KAAKC,EAAUe,EAAIP,CAAC,CAAC,EAAG,OAAWP,CAAW,CAE7E,CAJOS,EAAS,IAAAK,EAQT,SAASC,KAAUC,EAAgC,CACxD,MAAO,CAAClB,EAAyBC,EAAgBC,IAAkD,CACjG,IAAMiB,EAAQ,IAAIC,EAClB,QAAWb,KAASW,EAClBC,EAAM,IAAIZ,EAAMQ,GAAKf,EAAS,KAAKC,EAAUc,CAAC,CAAC,CAAC,EAElD,OAAIb,IACE,MAAM,QAAQA,CAAW,EAC3BA,EAAY,KAAKiB,CAAK,EAEtBjB,EAAY,IAAIiB,CAAK,GAGlBA,CACT,CACF,CAfOR,EAAS,IAAAM,EAmBT,SAASI,EAAmBd,EAAkBe,EAAqCC,EAA0B,CAClH,OAAAD,EAAQC,CAAO,EACRhB,EAAMQ,GAAKO,EAAQP,CAAC,CAAC,CAC9B,CAHOJ,EAAS,gBAAAU,IAhCDV,IAAA,ICxDV,SAASa,EAAkBC,EAAqBC,EAAU,EAAGC,EAAsC,CACxG,IAAMC,EAAQ,WAAW,IAAM,CAC7BH,EAAQ,EACJE,GACFE,EAAW,QAAQ,CAEvB,EAAGH,CAAO,EACJG,EAAaC,EAAa,IAAM,CACpC,aAAaF,CAAK,CACpB,CAAC,EACD,OAAAD,GAAO,IAAIE,CAAU,EACdA,CACT,CCDO,IAAME,EAAN,cAA8BC,CAAW,CAa9C,YAA6BC,EAAqB,CAChD,MAAM,EADqB,eAAAA,EAN7B,KAAQ,mBAAqB,KAAK,UAAU,IAAIC,CAAmB,EACnE,KAAQ,uBAAyB,KAAK,UAAU,IAAIA,CAAmB,EAGvE,KAAQ,qBAAuB,EAI7B,KAAK,UAAUC,EAAa,IAAM,KAAK,mBAAmB,CAAC,CAAC,CAC9D,CAKO,gBAAuB,CACvB,KAAK,cACR,KAAK,YAAc,IAAI,MAAM,KAAK,UAAU,OAAO,OAAO,MAAM,EAChE,KAAK,uBAAuB,MAAQC,EAClC,KAAK,UAAU,WAAW,IAAM,KAAK,mBAAmB,CAAC,EACzD,KAAK,UAAU,aAAa,IAAM,KAAK,mBAAmB,CAAC,EAC3D,KAAK,UAAU,SAAS,IAAM,KAAK,mBAAmB,CAAC,CACzD,GAGF,KAAK,qBAAuB,KAAK,IAAI,EAChC,KAAK,mBAAmB,OAC3B,KAAK,2BAA2B,IAAkC,CAEtE,CAEQ,oBAA2B,CACjC,KAAK,YAAc,OACnB,KAAK,qBAAuB,EAC5B,KAAK,uBAAuB,MAAM,EAClC,KAAK,mBAAmB,MAAM,CAChC,CAEQ,2BAA2BC,EAAqB,CACtD,KAAK,mBAAmB,MAAQC,EAAkB,IAAM,CACtD,GAAI,CAAC,KAAK,YACR,OAGF,IAAMC,EADM,KAAK,IAAI,EACC,KAAK,qBAC3B,GAAIA,GAAW,KAAoC,CACjD,KAAK,mBAAmB,EACxB,MACF,CACA,KAAK,2BAA2B,KAAqCA,CAAO,CAC9E,EAAGF,CAAK,CACV,CAEO,iBAAiBG,EAAyC,CAC/D,OAAO,KAAK,cAAcA,CAAG,CAC/B,CAEO,eAAeA,EAAaC,EAA6B,CAC1D,KAAK,cACP,KAAK,YAAYD,CAAG,EAAIC,EAE5B,CAUO,oCAAoCC,EAAmBC,EAAoC,CAChG,IAAMC,EAAU,CAAC,EACXC,EAAc,CAAC,CAAC,EAIhBC,EAAe,KAAK,UAAU,OAAO,OAAO,OAC9CC,EAAO,KAAK,UAAU,OAAO,OAAO,QAAQL,CAAS,EACzD,KAAOK,GAAM,CACX,IAAMC,EAAWN,EAAY,EAAII,EAAe,KAAK,UAAU,OAAO,OAAO,QAAQJ,EAAY,CAAC,EAAI,OAChGO,EAAkBD,EAAWA,EAAS,UAAY,GACpDE,EAASH,EAAK,kBAAkB,CAACE,GAAmBN,CAAS,EACjE,GAAIM,GAAmBD,EAAU,CAC/B,IAAMG,EAAWJ,EAAK,QAAQA,EAAK,OAAS,CAAC,EACtBI,GAAYA,EAAS,QAAQ,IAAM,GAAKA,EAAS,SAAS,IAAM,GAEjEH,EAAS,QAAQ,CAAC,GAAG,SAAS,IAAM,IACxDE,EAASA,EAAO,MAAM,EAAG,EAAE,EAE/B,CAEA,GADAN,EAAQ,KAAKM,CAAM,EACfD,EACFJ,EAAY,KAAKA,EAAYA,EAAY,OAAS,CAAC,EAAIK,EAAO,MAAM,MAEpE,OAEFR,IACAK,EAAOC,CACT,CACA,MAAO,CAACJ,EAAQ,KAAK,EAAE,EAAGC,CAAW,CACvC,CACF,EChIO,IAAMO,EAAN,KAAkB,CAOvB,IAAW,kBAAuC,CAChD,OAAO,KAAK,iBACd,CAKA,IAAW,iBAAiBC,EAA0B,CACpD,KAAK,kBAAoBA,CAC3B,CAKA,IAAW,mBAAgD,CACzD,OAAO,KAAK,kBACd,CAKA,IAAW,kBAAkBC,EAAqC,CAChE,KAAK,mBAAqBA,CAC5B,CAOO,kBAAkBD,EAAuB,CAC9C,MAAO,CAAC,EAAEA,GAAQA,EAAK,OAAS,EAClC,CAOO,iBAAiBE,EAAsC,CAC5D,OAAK,KAAK,mBAGLA,EAGD,KAAK,mBAAmB,gBAAkBA,EAAW,eAGrD,KAAK,mBAAmB,QAAUA,EAAW,OAG7C,KAAK,mBAAmB,YAAcA,EAAW,UAR5C,GAHA,EAeX,CAQO,yBAAyBF,EAAcC,EAAmC,CAC/E,OAAKA,GAAS,YAGP,KAAK,oBAAsB,QAC3BD,IAAS,KAAK,mBACd,KAAK,iBAAiBC,CAAO,EAJ3B,EAKX,CAKO,iBAAwB,CAC7B,KAAK,kBAAoB,MAC3B,CAKO,OAAc,CACnB,KAAK,kBAAoB,OACzB,KAAK,mBAAqB,MAC5B,CACF,EC9DO,IAAME,EAAN,KAAmB,CACxB,YACmBC,EACAC,EACjB,CAFiB,eAAAD,EACA,gBAAAC,CAChB,CAUI,KAAKC,EAAcC,EAAkBC,EAAkBC,EAA2D,CACvH,GAAI,CAACH,GAAQA,EAAK,SAAW,EAAG,CAC9B,KAAK,UAAU,eAAe,EAC9B,MACF,CACA,GAAIE,GAAY,KAAK,UAAU,KAC7B,MAAM,IAAI,MAAM,gBAAgBA,CAAQ,6BAA6B,KAAK,UAAU,IAAI,OAAO,EAGjG,KAAK,WAAW,eAAe,EAE/B,IAAME,EAAkC,CACtC,SAAAH,EACA,SAAAC,CACF,EAGIG,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,EAEjE,GAAI,CAACE,EACH,QAASC,EAAIL,EAAW,EAAGK,EAAI,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,MAC7E,QAAK,6BAA6BA,CAAC,IAGvCF,EAAe,SAAWE,EAC1BF,EAAe,SAAW,EAC1BC,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,EACzDE,IAPmFC,IACvF,CAWJ,OAAOD,CACT,CASO,sBAAsBL,EAAcG,EAAgCI,EAAsD,CAC/H,GAAI,CAACP,GAAQA,EAAK,SAAW,EAAG,CAC9B,KAAK,UAAU,eAAe,EAC9B,MACF,CAEA,IAAMQ,EAAkB,KAAK,UAAU,qBAAqB,EAC5D,KAAK,UAAU,eAAe,EAE9B,IAAIN,EAAW,EACXD,EAAW,EACXO,IACED,IAAqBP,GACvBE,EAAWM,EAAgB,IAAI,EAC/BP,EAAWO,EAAgB,IAAI,IAE/BN,EAAWM,EAAgB,MAAM,EACjCP,EAAWO,EAAgB,MAAM,IAIrC,KAAK,WAAW,eAAe,EAE/B,IAAMJ,EAAkC,CACtC,SAAAH,EACA,SAAAC,CACF,EAGIG,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,EAEjE,GAAI,CAACE,EACH,QAASC,EAAIL,EAAW,EAAGK,EAAI,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,MAC7E,QAAK,6BAA6BA,CAAC,IAGvCF,EAAe,SAAWE,EAC1BF,EAAe,SAAW,EAC1BC,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,EACzDE,IAPmFC,IACvF,CAYJ,GAAI,CAACD,GAAUJ,IAAa,EAC1B,QAASK,EAAI,EAAGA,EAAIL,GAGd,IAAAK,EAAI,GAAK,KAAK,6BAA6BA,CAAC,KAGhDF,EAAe,SAAWE,EAC1BF,EAAe,SAAW,EAC1BC,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,EACzDE,IATwBC,IAG5B,CAaJ,MAAI,CAACD,GAAUG,IACbJ,EAAe,SAAWI,EAAgB,MAAM,EAChDJ,EAAe,SAAW,EAC1BC,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,CAAa,GAGxDE,CACT,CASO,0BAA0BL,EAAcG,EAAgCI,EAAsD,CACnI,GAAI,CAACP,GAAQA,EAAK,SAAW,EAAG,CAC9B,KAAK,UAAU,eAAe,EAC9B,MACF,CAEA,IAAMQ,EAAkB,KAAK,UAAU,qBAAqB,EAC5D,KAAK,UAAU,eAAe,EAE9B,IAAIP,EAAW,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,KAAO,EACpEC,EAAW,KAAK,UAAU,KAC1BO,EAAkB,GAExB,KAAK,WAAW,eAAe,EAC/B,IAAML,EAAkC,CACtC,SAAAH,EACA,SAAAC,CACF,EAEIG,EAkBJ,GAjBIG,IACFJ,EAAe,SAAWH,EAAWO,EAAgB,MAAM,EAC3DJ,EAAe,SAAWI,EAAgB,MAAM,EAC5CD,IAAqBP,IAEvBK,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,EAAe,EAAK,EAC/DE,IAEHD,EAAe,SAAWH,EAAWO,EAAgB,IAAI,EACzDJ,EAAe,SAAWI,EAAgB,IAAI,KAKpDH,IAAW,KAAK,YAAYL,EAAMI,EAAgBD,EAAeM,CAAe,EAG5E,CAACJ,EAAQ,CACXD,EAAe,SAAW,KAAK,IAAIA,EAAe,SAAU,KAAK,UAAU,IAAI,EAC/E,QAASE,EAAIL,EAAW,EAAGK,GAAK,IAC9BF,EAAe,SAAWE,EAC1BD,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,EAAeM,CAAe,EAC1E,CAAAJ,GAH6BC,IAGjC,CAIJ,CAEA,GAAI,CAACD,GAAUJ,IAAc,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,KAAO,EACtF,QAASK,EAAK,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,KAAO,EAAIA,GAAKL,IAChFG,EAAe,SAAWE,EAC1BD,EAAS,KAAK,YAAYL,EAAMI,EAAgBD,EAAeM,CAAe,EAC1E,CAAAJ,GAHsFC,IAG1F,CAMJ,OAAOD,CACT,CASQ,aAAaK,EAAqBC,EAAcX,EAAuB,CAC7E,OAASU,IAAgB,GAAO,qCAA8B,SAASC,EAAKD,EAAc,CAAC,CAAC,KACvFA,EAAcV,EAAK,SAAYW,EAAK,QAAY,qCAA8B,SAASA,EAAKD,EAAcV,EAAK,MAAM,CAAC,EAC7H,CAGQ,oBAAoBU,EAAqBC,EAAcX,EAAcG,EAAwC,CACnH,MAAO,CAACA,EAAc,WAAa,KAAK,aAAaO,EAAaC,EAAMX,CAAI,CAC9E,CASQ,6BAA6BY,EAAsB,CACzD,OAAO,KAAK,UAAU,OAAO,OAAO,QAAQA,CAAG,GAAG,YAAc,EAClE,CAcQ,YAAYZ,EAAcI,EAAiCD,EAAgC,CAAC,EAAGM,EAA2B,GAAkC,CAElK,GAAIA,GAGF,GAAIL,EAAe,SAAW,GAAK,KAAK,UAAU,OAAO,OAAO,QAAQA,EAAe,QAAQ,GAAG,UAAW,CAC3GA,EAAe,UAAY,KAAK,UAAU,KAC1C,MACF,MAKA,MAAOA,EAAe,SAAW,GAAK,KAAK,UAAU,OAAO,OAAO,QAAQA,EAAe,QAAQ,GAAG,WACnGA,EAAe,WACfA,EAAe,UAAY,KAAK,UAAU,KAG9C,IAAMQ,EAAMR,EAAe,SACrBS,EAAMT,EAAe,SAEvBU,EAAQ,KAAK,WAAW,iBAAiBF,CAAG,EAC3CE,IACHA,EAAQ,KAAK,WAAW,oCAAoCF,EAAK,EAAI,EACrE,KAAK,WAAW,eAAeA,EAAKE,CAAK,GAE3C,GAAM,CAACC,EAAYC,CAAO,EAAIF,EAExBG,EAAS,KAAK,0BAA0BL,EAAKC,EAAKG,CAAO,EAC3DE,EAAalB,EACbmB,EAAmBJ,EAClBZ,EAAc,QACjBe,EAAaf,EAAc,cAAgBH,EAAOA,EAAK,YAAY,EACnEmB,EAAmBhB,EAAc,cAAgBY,EAAaA,EAAW,YAAY,GAGvF,IAAIK,EAAc,GAClB,GAAIjB,EAAc,MAAO,CACvB,IAAMkB,EAAc,OAAOH,EAAYf,EAAc,cAAgB,IAAM,IAAI,EAC3EmB,EACJ,GAAIb,EAEF,KAAOa,EAAYD,EAAY,KAAKF,EAAiB,MAAM,EAAGF,CAAM,CAAC,GAAG,CACtE,IAAMM,EAAaF,EAAY,UAAYC,EAAU,CAAC,EAAE,OACpDA,EAAU,CAAC,EAAE,OAAS,GAAK,KAAK,oBAAoBC,EAAYJ,EAAkBG,EAAU,CAAC,EAAGnB,CAAa,IAC/GiB,EAAcG,EACdvB,EAAOsB,EAAU,CAAC,GAEpBD,EAAY,UAAYE,EAAa,CACvC,KAOA,KADAF,EAAY,UAAYJ,EACjBK,EAAYD,EAAY,KAAKF,CAAgB,GAAG,CACrD,IAAMI,EAAaF,EAAY,UAAYC,EAAU,CAAC,EAAE,OACxD,GAAIA,EAAU,CAAC,EAAE,OAAS,GAAK,KAAK,oBAAoBC,EAAYJ,EAAkBG,EAAU,CAAC,EAAGnB,CAAa,EAAG,CAClHiB,EAAcG,EACdvB,EAAOsB,EAAU,CAAC,EAClB,KACF,CAEAD,EAAY,UAAYE,EAAa,CACvC,CAEJ,SAAWd,EAAiB,CAC1B,IAAIc,EAAaN,EAASC,EAAW,QAAU,EAAIC,EAAiB,YAAYD,EAAYD,EAASC,EAAW,MAAM,EAAI,GAE1H,KAAOK,GAAc,GAAK,CAAC,KAAK,oBAAoBA,EAAYJ,EAAkBD,EAAYf,CAAa,GACzGoB,EAAaA,EAAa,EAAIJ,EAAiB,YAAYD,EAAYK,EAAa,CAAC,EAAI,GAE3FH,EAAcG,CAChB,KAAO,CACL,IAAIA,EAAaJ,EAAiB,QAAQD,EAAYD,CAAM,EAC5D,KAAOM,GAAc,GAAK,CAAC,KAAK,oBAAoBA,EAAYJ,EAAkBD,EAAYf,CAAa,GACzGoB,EAAaJ,EAAiB,QAAQD,EAAYK,EAAa,CAAC,EAElEH,EAAcG,CAChB,CAEA,GAAIH,GAAe,EAAG,CAGpB,IAAII,EAAiB,EACrB,KAAOA,EAAiBR,EAAQ,OAAS,GAAKI,GAAeJ,EAAQQ,EAAiB,CAAC,GACrFA,IAEF,IAAIC,EAAeD,EACnB,KAAOC,EAAeT,EAAQ,OAAS,GAAKI,EAAcpB,EAAK,QAAUgB,EAAQS,EAAe,CAAC,GAC/FA,IAEF,IAAMC,EAAiBN,EAAcJ,EAAQQ,CAAc,EACrDG,EAAeP,EAAcpB,EAAK,OAASgB,EAAQS,CAAY,EAC/DG,EAAgB,KAAK,0BAA0BhB,EAAMY,EAAgBE,CAAc,EAEnFG,EADc,KAAK,0BAA0BjB,EAAMa,EAAcE,CAAY,EACxDC,EAAgB,KAAK,UAAU,MAAQH,EAAeD,GAEjF,MAAO,CACL,KAAAxB,EACA,IAAK4B,EACL,IAAKhB,EAAMY,EACX,KAAAK,CACF,CACF,CACF,CAEQ,0BAA0BjB,EAAaK,EAAwB,CACrE,IAAMN,EAAO,KAAK,UAAU,OAAO,OAAO,QAAQC,CAAG,EACrD,GAAI,CAACD,EACH,MAAO,GAET,QAASmB,EAAI,EAAGA,EAAIb,EAAQa,IAAK,CAC/B,IAAMC,EAAOpB,EAAK,QAAQmB,CAAC,EAC3B,GAAI,CAACC,EACH,MAGF,IAAMC,EAAOD,EAAK,SAAS,EACvBC,EAAK,OAAS,IAChBf,GAAUe,EAAK,OAAS,GAI1B,IAAMC,EAAWtB,EAAK,QAAQmB,EAAI,CAAC,EAC/BG,GAAYA,EAAS,SAAS,IAAM,GACtChB,GAEJ,CACA,OAAOA,CACT,CAUQ,0BAA0BhB,EAAkBiC,EAAcC,EAA+B,CAC/F,IAAMC,EAAW,KAAK,IAAI,KAAK,MAAMF,EAAO,KAAK,UAAU,IAAI,EAAGC,EAAY,OAAS,CAAC,EACpFlB,EAASkB,EAAYC,CAAQ,EAC3BzB,EAAO,KAAK,UAAU,OAAO,OAAO,QAAQV,EAAWmC,CAAQ,EACrE,GAAIzB,EAAM,CACR,IAAM0B,EAAY,KAAK,IAAIH,EAAOE,EAAW,KAAK,UAAU,KAAM,KAAK,UAAU,IAAI,EACrF,QAASN,EAAI,EAAGA,EAAIO,EAAWP,IAAK,CAClC,IAAMC,EAAOpB,EAAK,QAAQmB,CAAC,EAC3B,GAAI,CAACC,EACH,MAEEA,EAAK,SAAS,IAEhBd,GAAUc,EAAK,QAAQ,IAAM,EAAI,EAAIA,EAAK,SAAS,EAAE,OAEzD,CACF,CACA,OAAOd,CACT,CACF,ECxZO,IAAMqB,EAAN,cAAgCC,CAAW,CAIhD,YAA6BC,EAAqB,CAChD,MAAM,EADqB,eAAAA,EAH7B,KAAQ,sBAAsC,CAAC,EAC/C,KAAQ,kBAAiC,IAAI,IAI3C,KAAK,UAAUC,EAAa,IAAM,KAAK,0BAA0B,CAAC,CAAC,CACrE,CAOO,2BAA2BC,EAA0BC,EAAyC,CACnG,KAAK,0BAA0B,EAE/B,QAAWC,KAASF,EAAS,CAC3B,IAAMG,EAAc,KAAK,yBAAyBD,EAAOD,EAAS,EAAK,EACvE,GAAIE,EACF,QAAWC,KAAcD,EACvB,KAAK,iBAAiBC,EAAYF,CAAK,CAG7C,CACF,CAQO,uBAAuBG,EAAuBJ,EAAgE,CACnH,IAAME,EAAc,KAAK,yBAAyBE,EAAQJ,EAAS,EAAI,EACvE,GAAIE,EACF,MAAO,CAAE,YAAAA,EAAa,MAAOE,EAAQ,SAAU,CAAEC,EAAQH,CAAW,CAAG,CAAE,CAG7E,CAKO,2BAAkC,CACvCG,EAAQ,KAAK,qBAAqB,EAClC,KAAK,sBAAwB,CAAC,EAC9B,KAAK,kBAAkB,MAAM,CAC/B,CAOQ,iBAAiBF,EAAyBF,EAA4B,CAC5E,KAAK,kBAAkB,IAAIE,EAAW,OAAO,IAAI,EACjD,KAAK,sBAAsB,KAAK,CAAE,WAAAA,EAAY,MAAAF,EAAO,SAAU,CAAEE,EAAW,QAAQ,CAAG,CAAE,CAAC,CAC5F,CAQQ,aAAaG,EAAsBC,EAAiCC,EAA+B,CACpGF,EAAQ,UAAU,SAAS,8BAA8B,IAC5DA,EAAQ,UAAU,IAAI,8BAA8B,EAChDC,IACFD,EAAQ,MAAM,QAAU,aAAaC,CAAW,KAGhDC,GACFF,EAAQ,UAAU,IAAI,qCAAqC,CAE/D,CASQ,yBAAyBF,EAAuBJ,EAAmCQ,EAAoD,CAE7I,IAAMC,EAA+C,CAAC,EAClDC,EAAaN,EAAO,IACpBO,EAAgBP,EAAO,KACvBQ,EAAe,CAAC,KAAK,UAAU,OAAO,OAAO,MAAQ,KAAK,UAAU,OAAO,OAAO,QAAUR,EAAO,IACvG,KAAOO,EAAgB,GAAG,CACxB,IAAME,EAAgB,KAAK,IAAI,KAAK,UAAU,KAAOH,EAAYC,CAAa,EAC9EF,EAAiB,KAAK,CAACG,EAAcF,EAAYG,CAAa,CAAC,EAC/DH,EAAa,EACbC,GAAiBE,EACjBD,GACF,CAGA,IAAMV,EAA6B,CAAC,EACpC,QAAWY,KAASL,EAAkB,CACpC,IAAMM,EAAS,KAAK,UAAU,eAAeD,EAAM,CAAC,CAAC,EAC/CX,EAAa,KAAK,UAAU,mBAAmB,CACnD,OAAAY,EACA,EAAGD,EAAM,CAAC,EACV,MAAOA,EAAM,CAAC,EACd,MAAON,EAAiB,MAAQ,SAChC,gBAAiBA,EAAiBR,EAAQ,sBAAwBA,EAAQ,gBAC1E,qBAAsB,KAAK,kBAAkB,IAAIe,EAAO,IAAI,EAAI,OAAY,CAC1E,MAAOP,EAAiBR,EAAQ,8BAAgCA,EAAQ,mBACxE,SAAU,QACZ,CACF,CAAC,EACD,GAAIG,EAAY,CACd,IAAMa,EAA6B,CAAC,EACpCA,EAAY,KAAKD,CAAM,EACvBC,EAAY,KAAKb,EAAW,SAAUc,GAAM,KAAK,aAAaA,EAAGT,EAAiBR,EAAQ,kBAAoBA,EAAQ,YAAa,EAAK,CAAC,CAAC,EAC1IgB,EAAY,KAAKb,EAAW,UAAU,IAAME,EAAQW,CAAW,CAAC,CAAC,EACjEd,EAAY,KAAKC,CAAU,CAC7B,CACF,CAEA,OAAOD,EAAY,SAAW,EAAI,OAAYA,CAChD,CACF,ECrIO,IAAMgB,EAAN,cAAkCC,CAAW,CAA7C,kCACL,KAAQ,eAAkC,CAAC,EAG3C,KAAiB,oBAAsB,KAAK,UAAU,IAAIC,CAAmC,EAC7F,IAAW,oBAAuD,CAAE,OAAO,KAAK,oBAAoB,KAAO,CAK3G,IAAW,eAA8C,CACvD,OAAO,KAAK,cACd,CAKA,IAAW,oBAAsD,CAC/D,OAAO,KAAK,mBACd,CAKA,IAAW,mBAAmBC,EAA6C,CACzE,KAAK,oBAAsBA,CAC7B,CAOO,cAAcC,EAA0BC,EAA0B,CACvE,KAAK,eAAiBD,EAAQ,MAAM,EAAGC,CAAU,CACnD,CAKO,cAAqB,CAC1B,KAAK,eAAiB,CAAC,CACzB,CAKO,yBAAgC,CACjC,KAAK,sBACP,KAAK,oBAAoB,QAAQ,EACjC,KAAK,oBAAsB,OAE/B,CAOO,gBAAgBC,EAA+B,CACpD,QAASC,EAAI,EAAGA,EAAI,KAAK,eAAe,OAAQA,IAAK,CACnD,IAAMC,EAAQ,KAAK,eAAeD,CAAC,EACnC,GAAIC,EAAM,MAAQF,EAAO,KAAOE,EAAM,MAAQF,EAAO,KAAOE,EAAM,OAASF,EAAO,KAChF,OAAOC,CAEX,CACA,MAAO,EACT,CAMO,mBAAmBE,EAA+B,CACvD,GAAI,CAACA,EACH,OAGF,IAAIC,EAAc,GACd,KAAK,sBACPA,EAAc,KAAK,gBAAgB,KAAK,oBAAoB,KAAK,GAGnE,KAAK,oBAAoB,KAAK,CAC5B,YAAAA,EACA,YAAa,KAAK,eAAe,MACnC,CAAC,CACH,CAKO,OAAc,CACnB,KAAK,wBAAwB,EAC7B,KAAK,aAAa,CACpB,CACF,ECtFO,IAAMC,EAAN,cAA0BC,CAAiD,CAqBhF,YAAYC,EAAwC,CAClD,MAAM,EAnBR,KAAQ,kBAAoB,KAAK,UAAU,IAAIC,CAAgC,EAC/E,KAAQ,WAAa,KAAK,UAAU,IAAIA,CAAoC,EAG5E,KAAQ,OAAS,IAAIC,EAGrB,KAAQ,eAAiB,KAAK,UAAU,IAAIC,CAAqB,EAEjE,KAAiB,eAAiB,KAAK,UAAU,IAAIC,CAAe,EACpE,KAAgB,cAAgB,KAAK,eAAe,MACpD,KAAiB,gBAAkB,KAAK,UAAU,IAAIA,CAAe,EACrE,KAAgB,eAAiB,KAAK,gBAAgB,MASpD,KAAK,gBAAkBJ,GAAS,gBAAkB,GACpD,CARA,IAAW,oBAAuD,CAChE,OAAO,KAAK,eAAe,kBAC7B,CAQO,SAASK,EAA0B,CACxC,KAAK,UAAYA,EACjB,KAAK,WAAW,MAAQ,IAAIC,EAAgBD,CAAQ,EACpD,KAAK,QAAU,IAAIE,EAAaF,EAAU,KAAK,WAAW,KAAK,EAC/D,KAAK,mBAAqB,IAAIG,EAAkBH,CAAQ,EACxD,KAAK,UAAU,KAAK,UAAU,cAAc,IAAM,KAAK,eAAe,CAAC,CAAC,EACxE,KAAK,UAAU,KAAK,UAAU,SAAS,IAAM,KAAK,eAAe,CAAC,CAAC,EACnE,KAAK,UAAUI,EAAa,IAAM,KAAK,iBAAiB,CAAC,CAAC,CAC5D,CAEQ,gBAAuB,CAC7B,KAAK,kBAAkB,MAAM,EACzB,KAAK,OAAO,kBAAoB,KAAK,OAAO,mBAAmB,cACjE,KAAK,kBAAkB,MAAQC,EAAkB,IAAM,CACrD,IAAMC,EAAO,KAAK,OAAO,iBACzB,KAAK,OAAO,gBAAgB,EAC5B,KAAK,aAAaA,EAAO,CAAE,GAAG,KAAK,OAAO,kBAAmB,YAAa,EAAK,EAAG,CAAE,SAAU,EAAK,CAAC,CACtG,EAAG,GAAG,EAEV,CAEO,iBAAiBC,EAAwC,CAC9D,KAAK,eAAe,wBAAwB,EAC5C,KAAK,oBAAoB,0BAA0B,EACnD,KAAK,eAAe,aAAa,EAC5BA,GACH,KAAK,OAAO,gBAAgB,CAEhC,CAEO,uBAA8B,CACnC,KAAK,eAAe,wBAAwB,CAC9C,CASO,SAASD,EAAcE,EAAgCC,EAAyD,CACrH,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,QAC3B,MAAM,IAAI,MAAM,2CAA2C,EAG7D,KAAK,gBAAgB,KAAK,EAE1B,KAAK,OAAO,kBAAoBD,EAE5B,KAAK,OAAO,yBAAyBF,EAAME,CAAa,GAC1D,KAAK,qBAAqBF,EAAME,CAAc,EAGhD,IAAME,EAAQ,KAAK,mBAAmBJ,EAAME,EAAeC,CAAqB,EAChF,YAAK,aAAaD,CAAa,EAC/B,KAAK,OAAO,iBAAmBF,EAE/B,KAAK,eAAe,KAAK,EAElBI,CACT,CAEQ,qBAAqBJ,EAAcE,EAAqC,CAC9E,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,SAAW,CAAC,KAAK,mBAC5C,MAAM,IAAI,MAAM,2CAA2C,EAE7D,GAAI,CAAC,KAAK,OAAO,kBAAkBF,CAAI,EAAG,CACxC,KAAK,iBAAiB,EACtB,MACF,CAGA,KAAK,iBAAiB,EAAI,EAE1B,IAAMK,EAA2B,CAAC,EAC9BC,EACAC,EAAS,KAAK,QAAQ,KAAKP,EAAM,EAAG,EAAGE,CAAa,EAExD,KAAOK,IAAWD,GAAY,MAAQC,EAAO,KAAOD,GAAY,MAAQC,EAAO,MACzE,EAAAF,EAAQ,QAAU,KAAK,kBADwD,CAInFC,EAAaC,EACbF,EAAQ,KAAKC,CAAU,EACvB,IAAME,EAAO,KAAK,UAAU,KACxBC,EAAUH,EAAW,IAAMA,EAAW,KACtCI,EAAUJ,EAAW,IACrBG,GAAWD,IACbE,GAAW,KAAK,MAAMD,EAAUD,CAAI,EACpCC,EAAUA,EAAUD,GAEtBD,EAAS,KAAK,QAAQ,KAAKP,EAAMU,EAASD,EAASP,CAAa,CAClE,CAEA,KAAK,eAAe,cAAcG,EAAS,KAAK,eAAe,EAC3DH,EAAc,aAChB,KAAK,mBAAmB,2BAA2BG,EAASH,EAAc,WAAW,CAEzF,CAEQ,mBAAmBF,EAAcE,EAAgCC,EAAyD,CAChI,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,QAC3B,MAAO,GAET,GAAI,CAAC,KAAK,OAAO,kBAAkBH,CAAI,EACrC,YAAK,UAAU,eAAe,EAC9B,KAAK,iBAAiB,EACf,GAGT,IAAMO,EAAS,KAAK,QAAQ,sBAAsBP,EAAME,EAAe,KAAK,OAAO,gBAAgB,EACnG,OAAO,KAAK,cAAcK,EAAQL,GAAe,YAAaC,GAAuB,QAAQ,CAC/F,CASO,aAAaH,EAAcE,EAAgCC,EAAyD,CACzH,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,QAC3B,MAAM,IAAI,MAAM,2CAA2C,EAG7D,KAAK,gBAAgB,KAAK,EAE1B,KAAK,OAAO,kBAAoBD,EAE5B,KAAK,OAAO,yBAAyBF,EAAME,CAAa,GAC1D,KAAK,qBAAqBF,EAAME,CAAc,EAGhD,IAAME,EAAQ,KAAK,uBAAuBJ,EAAME,EAAeC,CAAqB,EACpF,YAAK,aAAaD,CAAa,EAC/B,KAAK,OAAO,iBAAmBF,EAE/B,KAAK,eAAe,KAAK,EAElBI,CACT,CAEQ,aAAaF,EAAsC,CACzD,KAAK,eAAe,mBAAmB,CAAC,CAACA,GAAe,WAAW,CACrE,CAEQ,uBAAuBF,EAAcE,EAAgCC,EAAyD,CACpI,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,QAC3B,MAAO,GAET,GAAI,CAAC,KAAK,OAAO,kBAAkBH,CAAI,EACrC,YAAK,UAAU,eAAe,EAC9B,KAAK,iBAAiB,EACf,GAGT,IAAMO,EAAS,KAAK,QAAQ,0BAA0BP,EAAME,EAAe,KAAK,OAAO,gBAAgB,EACvG,OAAO,KAAK,cAAcK,EAAQL,GAAe,YAAaC,GAAuB,QAAQ,CAC/F,CAOQ,cAAcI,EAAmClB,EAAoCsB,EAA6B,CACxH,GAAI,CAAC,KAAK,WAAa,CAAC,KAAK,mBAC3B,MAAO,GAIT,GADA,KAAK,eAAe,wBAAwB,EACxC,CAACJ,EACH,YAAK,UAAU,eAAe,EACvB,GAIT,GADA,KAAK,UAAU,OAAOA,EAAO,IAAKA,EAAO,IAAKA,EAAO,IAAI,EACrDlB,EAAS,CACX,IAAMuB,EAAmB,KAAK,mBAAmB,uBAAuBL,EAAQlB,CAAO,EACnFuB,IACF,KAAK,eAAe,mBAAqBA,EAE7C,CAEA,GAAI,CAACD,IAECJ,EAAO,KAAQ,KAAK,UAAU,OAAO,OAAO,UAAY,KAAK,UAAU,MAASA,EAAO,IAAM,KAAK,UAAU,OAAO,OAAO,WAAW,CACvI,IAAIM,EAASN,EAAO,IAAM,KAAK,UAAU,OAAO,OAAO,UACvDM,GAAU,KAAK,MAAM,KAAK,UAAU,KAAO,CAAC,EAC5C,KAAK,UAAU,YAAYA,CAAM,CACnC,CAEF,MAAO,EACT,CACF", ++ "names": ["toDisposable", "fn", "dispose", "arg", "d", "combinedDisposable", "disposables", "DisposableStore", "o", "Disposable", "MutableDisposable", "value", "Emitter", "listener", "thisArgs", "disposables", "toDisposable", "entry", "result", "idx", "event", "listeners", "i", "len", "EventUtils", "forward", "from", "to", "e", "map", "any", "events", "store", "DisposableStore", "runAndSubscribe", "handler", "initial", "disposableTimeout", "handler", "timeout", "store", "timer", "disposable", "toDisposable", "SearchLineCache", "Disposable", "_terminal", "MutableDisposable", "toDisposable", "combinedDisposable", "delay", "disposableTimeout", "elapsed", "row", "entry", "lineIndex", "trimRight", "strings", "lineOffsets", "bufferLength", "line", "nextLine", "lineWrapsToNext", "string", "lastCell", "SearchState", "term", "options", "newOptions", "SearchEngine", "_terminal", "_lineCache", "term", "startRow", "startCol", "searchOptions", "searchPosition", "result", "y", "cachedSearchTerm", "prevSelectedPos", "isReverseSearch", "searchIndex", "line", "row", "col", "cache", "stringLine", "offsets", "offset", "searchTerm", "searchStringLine", "resultIndex", "searchRegex", "foundTerm", "matchIndex", "startRowOffset", "endRowOffset", "startColOffset", "endColOffset", "startColIndex", "size", "i", "cell", "char", "nextCell", "cols", "lineOffsets", "rowsBack", "colsInRow", "DecorationManager", "Disposable", "_terminal", "toDisposable", "results", "options", "match", "decorations", "decoration", "result", "dispose", "element", "borderColor", "isActiveResult", "decorationRanges", "currentCol", "remainingSize", "markerOffset", "amountThisRow", "range", "marker", "disposables", "e", "SearchResultTracker", "Disposable", "Emitter", "decoration", "results", "maxResults", "result", "i", "match", "hasDecorations", "resultIndex", "SearchAddon", "Disposable", "options", "MutableDisposable", "SearchState", "SearchResultTracker", "Emitter", "terminal", "SearchLineCache", "SearchEngine", "DecorationManager", "toDisposable", "disposableTimeout", "term", "retainCachedSearchTerm", "searchOptions", "internalSearchOptions", "found", "results", "prevResult", "result", "cols", "nextCol", "nextRow", "noScroll", "activeDecoration", "scroll"] + } +diff --git a/src/SearchEngine.ts b/src/SearchEngine.ts +index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..5b3c5cc5e861356b87e8a15c55797f45bac20a5c 100644 +--- a/src/SearchEngine.ts ++++ b/src/SearchEngine.ts +@@ -76,6 +76,9 @@ export class SearchEngine { + // Search from startRow + 1 to end + if (!result) { + for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) { ++ if (this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -127,6 +130,9 @@ export class SearchEngine { + // Search from startRow + 1 to end + if (!result) { + for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) { ++ if (this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -138,6 +144,11 @@ export class SearchEngine { + // If we hit the bottom and didn't search from the very top wrap back up + if (!result && startRow !== 0) { + for (let y = 0; y < startRow; y++) { ++ // Row 0 is never skipped: it can be a continuation whose line start was trimmed from the ++ // scrollback, and nothing earlier in this loop has searched it. ++ if (y > 0 && this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -237,6 +248,22 @@ export class SearchEngine { + (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length]))); + } + ++ /** `_isWholeWord` gated on the option, so a rejected hit can be stepped past instead of ending the scan. */ ++ private _satisfiesWholeWord(searchIndex: number, line: string, term: string, searchOptions: ISearchOptions): boolean { ++ return !searchOptions.wholeWord || this._isWholeWord(searchIndex, line, term); ++ } ++ ++ /** ++ * Whether an earlier `_findInLine` in this same call already scanned this row's line from an ++ * equal or lower offset, which makes rescanning it pure O(rows^2) work on one long line. Sound ++ * for every option because `_findInLine` returns the first accepted match at or after its ++ * offset, which is monotone in that offset. Only valid once such a search has happened — the ++ * wrap-around loop starts at row 0, whose line start may have been trimmed from the scrollback. ++ */ ++ private _isRowCoveredByEarlierSearch(row: number): boolean { ++ return this._terminal.buffer.active.getLine(row)?.isWrapped === true; ++ } ++ + /** + * Searches a line for a search term. Takes the provided terminal line and searches the text line, + * which may contain subsequent terminal lines if the text is wrapped. If the provided line number +@@ -250,23 +277,26 @@ export class SearchEngine { + * @returns The search result if it was found. + */ + private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined { +- const row = searchPosition.startRow; +- const col = searchPosition.startCol; +- + // Ignore wrapped lines, only consider on unwrapped line (first row of command string). +- const firstLine = this._terminal.buffer.active.getLine(row); +- if (firstLine?.isWrapped) { +- if (isReverseSearch) { ++ if (isReverseSearch) { ++ // Reverse search never rewinds: its caller carries startCol down the rows of the line. Row 0 ++ // is searched even when wrapped, since its line start may have been trimmed from the scrollback. ++ if (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) { + searchPosition.startCol += this._terminal.cols; + return; + } +- +- // This will iterate until we find the line start. +- // When we find it, we will search using the calculated start column. +- searchPosition.startRow--; +- searchPosition.startCol += this._terminal.cols; +- return this._findInLine(term, searchPosition, searchOptions); ++ } else { ++ // A loop rather than recursion: one frame per wrapped row overflows the stack on a line long ++ // enough to fill the scrollback. Bounded at row 0 because after a reflow the buffer's ring ++ // holds stale entries at negative indices, so `getLine(-1)` answers with a wrapped line. ++ while (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) { ++ searchPosition.startRow--; ++ searchPosition.startCol += this._terminal.cols; ++ } + } ++ const row = searchPosition.startRow; ++ const col = searchPosition.startCol; ++ + let cache = this._lineCache.getLineFromCache(row); + if (!cache) { + cache = this._lineCache.translateBufferLineToStringWithWrap(row, true); +@@ -274,7 +304,7 @@ export class SearchEngine { + } + const [stringLine, offsets] = cache; + +- const offset = this._bufferColsToStringOffset(row, col); ++ const offset = this._bufferColsToStringOffset(row, col, offsets); + let searchTerm = term; + let searchStringLine = stringLine; + if (!searchOptions.regex) { +@@ -289,32 +319,46 @@ export class SearchEngine { + if (isReverseSearch) { + // This loop will get the resultIndex of the _last_ regex match in the range 0..offset + while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) { +- resultIndex = searchRegex.lastIndex - foundTerm[0].length; +- term = foundTerm[0]; +- searchRegex.lastIndex -= (term.length - 1); ++ const matchIndex = searchRegex.lastIndex - foundTerm[0].length; ++ if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) { ++ resultIndex = matchIndex; ++ term = foundTerm[0]; ++ } ++ searchRegex.lastIndex = matchIndex + 1; + } + } else { +- foundTerm = searchRegex.exec(searchStringLine.slice(offset)); +- if (foundTerm && foundTerm[0].length > 0) { +- resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length); +- term = foundTerm[0]; ++ // Driven over the whole line from `offset` rather than over `slice(offset)`: a slice ++ // re-anchors ^ and \b at whatever column the row happened to wrap at, and only ++ // first-accepted-match-at-or-after-offset is monotone in `offset`, which is what lets ++ // `_isRowCoveredByEarlierSearch` skip a wrapped row an earlier scan already covered. ++ searchRegex.lastIndex = offset; ++ while (foundTerm = searchRegex.exec(searchStringLine)) { ++ const matchIndex = searchRegex.lastIndex - foundTerm[0].length; ++ if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) { ++ resultIndex = matchIndex; ++ term = foundTerm[0]; ++ break; ++ } ++ // A zero-length or rejected match would otherwise repeat forever. ++ searchRegex.lastIndex = matchIndex + 1; + } + } ++ } else if (isReverseSearch) { ++ let matchIndex = offset - searchTerm.length >= 0 ? searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length) : -1; ++ // `lastIndexOf` clamps a negative fromIndex to 0, so index 0 has to end the walk. ++ while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) { ++ matchIndex = matchIndex > 0 ? searchStringLine.lastIndexOf(searchTerm, matchIndex - 1) : -1; ++ } ++ resultIndex = matchIndex; + } else { +- if (isReverseSearch) { +- if (offset - searchTerm.length >= 0) { +- resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length); +- } +- } else { +- resultIndex = searchStringLine.indexOf(searchTerm, offset); ++ let matchIndex = searchStringLine.indexOf(searchTerm, offset); ++ while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) { ++ matchIndex = searchStringLine.indexOf(searchTerm, matchIndex + 1); + } ++ resultIndex = matchIndex; + } + + if (resultIndex >= 0) { +- if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) { +- return; +- } +- + // Adjust the row number and search index if needed since a "line" of text can span multiple + // rows + let startRowOffset = 0; +@@ -365,12 +409,21 @@ export class SearchEngine { + return offset; + } + +- private _bufferColsToStringOffset(startRow: number, cols: number): number { +- let lineIndex = startRow; +- let offset = 0; +- let line = this._terminal.buffer.active.getLine(lineIndex); +- while (cols > 0 && line) { +- for (let i = 0; i < cols && i < this._terminal.cols; i++) { ++ /** ++ * `cols` counts from the start of the logical line, so summing the cells of every row before the ++ * resume point costs O(line) per call and the highlight-all pass makes one call per match. ++ * `lineOffsets` already holds the string offset each wrapped row starts at — the same map used ++ * above to turn a match index back into a row — so only the last, partial row needs cells. It is ++ * also the map the row a match lands on is read from, which the cell sum disagreed with by one ++ * for a row whose trailing cell is the null placeholder of a wide character that wrapped. ++ */ ++ private _bufferColsToStringOffset(startRow: number, cols: number, lineOffsets: number[]): number { ++ const rowsBack = Math.min(Math.floor(cols / this._terminal.cols), lineOffsets.length - 1); ++ let offset = lineOffsets[rowsBack]; ++ const line = this._terminal.buffer.active.getLine(startRow + rowsBack); ++ if (line) { ++ const colsInRow = Math.min(cols - rowsBack * this._terminal.cols, this._terminal.cols); ++ for (let i = 0; i < colsInRow; i++) { + const cell = line.getCell(i); + if (!cell) { + break; +@@ -380,12 +433,6 @@ export class SearchEngine { + offset += cell.getCode() === 0 ? 1 : cell.getChars().length; + } + } +- lineIndex++; +- line = this._terminal.buffer.active.getLine(lineIndex); +- if (line && !line.isWrapped) { +- break; +- } +- cols -= this._terminal.cols; + } + return offset; + } +diff --git a/src/SearchLineCache.ts b/src/SearchLineCache.ts +index 526f4bfcc74a881bb39b400ec79a25d33d602303..19b22f2f70e50a6b01d07966e15727cc5271c776 100644 +--- a/src/SearchLineCache.ts ++++ b/src/SearchLineCache.ts +@@ -109,9 +109,13 @@ export class SearchLineCache extends Disposable { + public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry { + const strings = []; + const lineOffsets = [0]; ++ // A single line longer than the whole scrollback leaves every buffer row wrapped, and the ++ // buffer's ring answers an out-of-range row by cycling back to the start, so an unbounded walk ++ // never reaches an unwrapped line. ++ const bufferLength = this._terminal.buffer.active.length; + let line = this._terminal.buffer.active.getLine(lineIndex); + while (line) { +- const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1); ++ const nextLine = lineIndex + 1 < bufferLength ? this._terminal.buffer.active.getLine(lineIndex + 1) : undefined; + const lineWrapsToNext = nextLine ? nextLine.isWrapped : false; + let string = line.translateToString(!lineWrapsToNext && trimRight); + if (lineWrapsToNext && nextLine) { diff --git a/config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.300.src.patch b/config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.300.src.patch new file mode 100644 index 00000000000..c38f1d1278e --- /dev/null +++ b/config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.300.src.patch @@ -0,0 +1,231 @@ +diff --git a/src/SearchEngine.ts b/src/SearchEngine.ts +index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..5b3c5cc5e861356b87e8a15c55797f45bac20a5c 100644 +--- a/src/SearchEngine.ts ++++ b/src/SearchEngine.ts +@@ -76,6 +76,9 @@ export class SearchEngine { + // Search from startRow + 1 to end + if (!result) { + for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) { ++ if (this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -127,6 +130,9 @@ export class SearchEngine { + // Search from startRow + 1 to end + if (!result) { + for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) { ++ if (this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -138,6 +144,11 @@ export class SearchEngine { + // If we hit the bottom and didn't search from the very top wrap back up + if (!result && startRow !== 0) { + for (let y = 0; y < startRow; y++) { ++ // Row 0 is never skipped: it can be a continuation whose line start was trimmed from the ++ // scrollback, and nothing earlier in this loop has searched it. ++ if (y > 0 && this._isRowCoveredByEarlierSearch(y)) { ++ continue; ++ } + searchPosition.startRow = y; + searchPosition.startCol = 0; + result = this._findInLine(term, searchPosition, searchOptions); +@@ -237,6 +248,22 @@ export class SearchEngine { + (((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length]))); + } + ++ /** `_isWholeWord` gated on the option, so a rejected hit can be stepped past instead of ending the scan. */ ++ private _satisfiesWholeWord(searchIndex: number, line: string, term: string, searchOptions: ISearchOptions): boolean { ++ return !searchOptions.wholeWord || this._isWholeWord(searchIndex, line, term); ++ } ++ ++ /** ++ * Whether an earlier `_findInLine` in this same call already scanned this row's line from an ++ * equal or lower offset, which makes rescanning it pure O(rows^2) work on one long line. Sound ++ * for every option because `_findInLine` returns the first accepted match at or after its ++ * offset, which is monotone in that offset. Only valid once such a search has happened — the ++ * wrap-around loop starts at row 0, whose line start may have been trimmed from the scrollback. ++ */ ++ private _isRowCoveredByEarlierSearch(row: number): boolean { ++ return this._terminal.buffer.active.getLine(row)?.isWrapped === true; ++ } ++ + /** + * Searches a line for a search term. Takes the provided terminal line and searches the text line, + * which may contain subsequent terminal lines if the text is wrapped. If the provided line number +@@ -250,23 +277,26 @@ export class SearchEngine { + * @returns The search result if it was found. + */ + private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined { +- const row = searchPosition.startRow; +- const col = searchPosition.startCol; +- + // Ignore wrapped lines, only consider on unwrapped line (first row of command string). +- const firstLine = this._terminal.buffer.active.getLine(row); +- if (firstLine?.isWrapped) { +- if (isReverseSearch) { ++ if (isReverseSearch) { ++ // Reverse search never rewinds: its caller carries startCol down the rows of the line. Row 0 ++ // is searched even when wrapped, since its line start may have been trimmed from the scrollback. ++ if (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) { + searchPosition.startCol += this._terminal.cols; + return; + } +- +- // This will iterate until we find the line start. +- // When we find it, we will search using the calculated start column. +- searchPosition.startRow--; +- searchPosition.startCol += this._terminal.cols; +- return this._findInLine(term, searchPosition, searchOptions); ++ } else { ++ // A loop rather than recursion: one frame per wrapped row overflows the stack on a line long ++ // enough to fill the scrollback. Bounded at row 0 because after a reflow the buffer's ring ++ // holds stale entries at negative indices, so `getLine(-1)` answers with a wrapped line. ++ while (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) { ++ searchPosition.startRow--; ++ searchPosition.startCol += this._terminal.cols; ++ } + } ++ const row = searchPosition.startRow; ++ const col = searchPosition.startCol; ++ + let cache = this._lineCache.getLineFromCache(row); + if (!cache) { + cache = this._lineCache.translateBufferLineToStringWithWrap(row, true); +@@ -274,7 +304,7 @@ export class SearchEngine { + } + const [stringLine, offsets] = cache; + +- const offset = this._bufferColsToStringOffset(row, col); ++ const offset = this._bufferColsToStringOffset(row, col, offsets); + let searchTerm = term; + let searchStringLine = stringLine; + if (!searchOptions.regex) { +@@ -289,32 +319,46 @@ export class SearchEngine { + if (isReverseSearch) { + // This loop will get the resultIndex of the _last_ regex match in the range 0..offset + while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) { +- resultIndex = searchRegex.lastIndex - foundTerm[0].length; +- term = foundTerm[0]; +- searchRegex.lastIndex -= (term.length - 1); ++ const matchIndex = searchRegex.lastIndex - foundTerm[0].length; ++ if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) { ++ resultIndex = matchIndex; ++ term = foundTerm[0]; ++ } ++ searchRegex.lastIndex = matchIndex + 1; + } + } else { +- foundTerm = searchRegex.exec(searchStringLine.slice(offset)); +- if (foundTerm && foundTerm[0].length > 0) { +- resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length); +- term = foundTerm[0]; ++ // Driven over the whole line from `offset` rather than over `slice(offset)`: a slice ++ // re-anchors ^ and \b at whatever column the row happened to wrap at, and only ++ // first-accepted-match-at-or-after-offset is monotone in `offset`, which is what lets ++ // `_isRowCoveredByEarlierSearch` skip a wrapped row an earlier scan already covered. ++ searchRegex.lastIndex = offset; ++ while (foundTerm = searchRegex.exec(searchStringLine)) { ++ const matchIndex = searchRegex.lastIndex - foundTerm[0].length; ++ if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) { ++ resultIndex = matchIndex; ++ term = foundTerm[0]; ++ break; ++ } ++ // A zero-length or rejected match would otherwise repeat forever. ++ searchRegex.lastIndex = matchIndex + 1; + } + } ++ } else if (isReverseSearch) { ++ let matchIndex = offset - searchTerm.length >= 0 ? searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length) : -1; ++ // `lastIndexOf` clamps a negative fromIndex to 0, so index 0 has to end the walk. ++ while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) { ++ matchIndex = matchIndex > 0 ? searchStringLine.lastIndexOf(searchTerm, matchIndex - 1) : -1; ++ } ++ resultIndex = matchIndex; + } else { +- if (isReverseSearch) { +- if (offset - searchTerm.length >= 0) { +- resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length); +- } +- } else { +- resultIndex = searchStringLine.indexOf(searchTerm, offset); ++ let matchIndex = searchStringLine.indexOf(searchTerm, offset); ++ while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) { ++ matchIndex = searchStringLine.indexOf(searchTerm, matchIndex + 1); + } ++ resultIndex = matchIndex; + } + + if (resultIndex >= 0) { +- if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) { +- return; +- } +- + // Adjust the row number and search index if needed since a "line" of text can span multiple + // rows + let startRowOffset = 0; +@@ -365,12 +409,21 @@ export class SearchEngine { + return offset; + } + +- private _bufferColsToStringOffset(startRow: number, cols: number): number { +- let lineIndex = startRow; +- let offset = 0; +- let line = this._terminal.buffer.active.getLine(lineIndex); +- while (cols > 0 && line) { +- for (let i = 0; i < cols && i < this._terminal.cols; i++) { ++ /** ++ * `cols` counts from the start of the logical line, so summing the cells of every row before the ++ * resume point costs O(line) per call and the highlight-all pass makes one call per match. ++ * `lineOffsets` already holds the string offset each wrapped row starts at — the same map used ++ * above to turn a match index back into a row — so only the last, partial row needs cells. It is ++ * also the map the row a match lands on is read from, which the cell sum disagreed with by one ++ * for a row whose trailing cell is the null placeholder of a wide character that wrapped. ++ */ ++ private _bufferColsToStringOffset(startRow: number, cols: number, lineOffsets: number[]): number { ++ const rowsBack = Math.min(Math.floor(cols / this._terminal.cols), lineOffsets.length - 1); ++ let offset = lineOffsets[rowsBack]; ++ const line = this._terminal.buffer.active.getLine(startRow + rowsBack); ++ if (line) { ++ const colsInRow = Math.min(cols - rowsBack * this._terminal.cols, this._terminal.cols); ++ for (let i = 0; i < colsInRow; i++) { + const cell = line.getCell(i); + if (!cell) { + break; +@@ -380,12 +433,6 @@ export class SearchEngine { + offset += cell.getCode() === 0 ? 1 : cell.getChars().length; + } + } +- lineIndex++; +- line = this._terminal.buffer.active.getLine(lineIndex); +- if (line && !line.isWrapped) { +- break; +- } +- cols -= this._terminal.cols; + } + return offset; + } +diff --git a/src/SearchLineCache.ts b/src/SearchLineCache.ts +index 526f4bfcc74a881bb39b400ec79a25d33d602303..19b22f2f70e50a6b01d07966e15727cc5271c776 100644 +--- a/src/SearchLineCache.ts ++++ b/src/SearchLineCache.ts +@@ -109,9 +109,13 @@ export class SearchLineCache extends Disposable { + public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry { + const strings = []; + const lineOffsets = [0]; ++ // A single line longer than the whole scrollback leaves every buffer row wrapped, and the ++ // buffer's ring answers an out-of-range row by cycling back to the start, so an unbounded walk ++ // never reaches an unwrapped line. ++ const bufferLength = this._terminal.buffer.active.length; + let line = this._terminal.buffer.active.getLine(lineIndex); + while (line) { +- const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1); ++ const nextLine = lineIndex + 1 < bufferLength ? this._terminal.buffer.active.getLine(lineIndex + 1) : undefined; + const lineWrapsToNext = nextLine ? nextLine.isWrapped : false; + let string = line.translateToString(!lineWrapsToNext && trimRight); + if (lineWrapsToNext && nextLine) { diff --git a/config/patches/xterm-upstream.json b/config/patches/xterm-upstream.json index ec36f65c71d..89afe4fb1fb 100644 --- a/config/patches/xterm-upstream.json +++ b/config/patches/xterm-upstream.json @@ -59,6 +59,33 @@ } ] }, + { + "name": "@xterm/addon-search", + "version": "0.17.0-beta.300", + "packageDir": "addons/addon-search", + "$note": "No versionStampFile: publish.js stamps the addon's package.json, which overlayBuildOutput never patches. The root `build` is required because the addon's own tsgo -p . has empty files/include and only project references, so it emits nothing on its own; `package` is the addon's webpack (CJS half) and the root `esbuild-package` emits the ESM half.", + "$upstream": "Submitted as https://github.com/xtermjs/xterm.js/pull/6149 (issue #6148). Once a release ships it, bump the addon and drop this entry.", + "sourcePatch": "config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.300.src.patch", + "patch": "config/patches/@xterm__addon-search@0.17.0-beta.300.patch", + "generatedPaths": ["lib/"], + "build": [ + { + "cwd": "../..", + "command": "npm", + "args": ["run", "build"] + }, + { + "cwd": ".", + "command": "npm", + "args": ["run", "package"] + }, + { + "cwd": "../..", + "command": "npm", + "args": ["run", "esbuild-package"] + } + ] + }, { "name": "@xterm/addon-serialize", "version": "0.15.0-beta.300", diff --git a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs index f4f4f87619a..40bc0350d86 100644 --- a/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs +++ b/config/relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs @@ -1,16 +1,34 @@ /** - * Relay-side pty-master close-on-exec patch for node-pty 1.1.0 (#17915). + * Relay-side pty fd-leak patch for node-pty 1.1.0 (#17915). * * The app gets this through pnpm `patchedDependencies`; the relay installs stock - * node-pty from npm onto the host, where no pnpm patch reaches. Without it every - * later child of the relay -- pty children, git helpers, probes, agent CLIs -- - * inherits each live master fd and keeps its /dev/pts device alive for the life - * of the relay (#8362). + * node-pty from npm onto the host, where no pnpm patch reaches. Stock 1.1.0 leaks + * a pty fd on both Unix relay platforms, by two unrelated bugs on two code paths. * - * Linux only, deliberately: it is the only relay platform that takes forkpty()'s - * no-atomic-O_CLOEXEC path, and the only one that already compiles node-pty at - * install time, so the rebuild costs a second compile rather than a first one. - * macOS re-opens the tty through uv_tty_init's cloexec dup and Windows has no fds. + * Linux takes forkpty(), which has no atomic O_CLOEXEC, so every later child of + * the relay -- pty children, git helpers, probes, agent CLIs -- inherits each live + * master and keeps its /dev/pts device alive for the life of the relay (#8362). + * + * macOS takes pty_posix_spawn(), which opens up to three throwaway ptys to push + * the real master off fds 0-2 and then never closes them: the cleanup loop is + * `for (; count > 0; count--)`, but in any running process the first posix_openpt() + * already returns >= 2, so the loop breaks with count == 0 and its body never runs + * -- and where it does run it closes low_fds[count], never low_fds[0]. Measured on + * darwin-arm64: one orphaned /dev/ptmx fd per terminal, never returned. + * + * macOS does not inherit the master into spawned children today, but not because it + * is marked: FD_CLOEXEC is not set on it (`lsof +fg` shows R,W,NB, no CX). What + * closes it is POSIX_SPAWN_CLOEXEC_DEFAULT in pty_posix_spawn's spawn flags, an + * Apple-only flag that closes every fd in the child. That is one option away from + * gone -- setting uid/gid drops libuv back to fork()/exec(), which honors nothing + * but FD_CLOEXEC -- so the master is marked on the Apple path too, exactly as the + * app's pnpm patch marks it. Windows has no fds and is excluded. + * + * The compile it buys differs by platform. Linux relays already run node-gyp at + * install time (1.1.0 ships no linux prebuild), so this is a second compile on a + * path that already compiles. macOS runs the shipped darwin prebuild and has no + * build/ at all, so this is its first compile -- the price of the only fix there + * is, since the bug is in the source that prebuild was built from. * * Non-fatal by construction: the working build is moved aside before anything is * touched and moved back on any failure, and a failed attempt drops a skip marker @@ -31,7 +49,7 @@ const { dirname, join, resolve } = require('node:path') const EXPECTED_NODE_PTY_VERSION = '1.1.0' const ORIGINAL_SOURCE_SHA256 = '5e1005d6bdcfbe97b486ee415419fe7adae99035047f07340fbad36419e0bae6' -const PATCHED_SOURCE_SHA256 = '97dea52199216c01b62070758f0f38621ae53adc16c221271dd35ae2d8ee3482' +const PATCHED_SOURCE_SHA256 = '3e6bc1a688aae187d231687130cfc0a11781c672f5f616d73183d471ee8ee65c' const STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' const SKIP_MARKER_FILENAME = '.node-pty-cloexec-skip' @@ -97,7 +115,56 @@ const FORKPTY_CALL_SITE = [ ` ] -const REPLACEMENTS = [FORWARD_DECLARATION, DEFINITION, FORKPTY_CALL_SITE] +// Apple never reaches FORKPTY_CALL_SITE: `default:` sits in the `#else` arm of PtyFork's +// `#if defined(__APPLE__)`, so before this pair the asset patched nothing macOS executes. +const POSIX_SPAWN_CALL_SITE = [ + ` if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } +#else +`, + ` if (pty_nonblock(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } + if (pty_cloexec(master) == -1) { + throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec."); + } +#else +` +] + +// The throwaway ptys pty_posix_spawn opens to keep the real master off fds 0-2. Byte-identical to +// the app's pnpm patch, so both trees compile the same cleanup. +const LOW_FDS_DECLARATION = [ + ` int low_fds[3]; + size_t count = 0; +`, + ` int low_fds[3] = {-1, -1, -1}; + size_t count = 0; +` +] + +const LOW_FDS_CLEANUP = [ + ` for (; count > 0; count--) { + close(low_fds[count]); + } +`, + ` for (size_t i = 0; i <= count && i < 3; i++) { + if (low_fds[i] != -1) { + close(low_fds[i]); + } + } +` +] + +const REPLACEMENTS = [ + FORWARD_DECLARATION, + DEFINITION, + POSIX_SPAWN_CALL_SITE, + FORKPTY_CALL_SITE, + LOW_FDS_DECLARATION, + LOW_FDS_CLEANUP +] function sourceSha256(source) { return createHash('sha256').update(source).digest('hex') @@ -188,10 +255,12 @@ function rebuildNodePty(relayDir) { } } -// Why a child: a bad build can abort the process on require, which would strand the -// moved-aside working build. Why the reachability check: a host without /proc cannot -// show inheritance, and an unobservable flag is not evidence the rebuild was wrong. -const VERIFY_SCRIPT = ` +// Why a child, for both scripts below: a bad build can abort the process on require, which would +// strand the moved-aside working build. Why each ends in a reachability check: a host that cannot +// show its fds says nothing, and an unobservable flag is not evidence the rebuild was wrong. +// +// Linux's leak is inheritance, so the observation is a later plain child's /proc/self/fd. +const VERIFY_INHERITANCE_SCRIPT = ` const pty = require(process.argv[1]); const term = pty.spawn('/bin/sh', ['-c', 'exit 0'], { name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env @@ -200,13 +269,39 @@ const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'ls -l / try { term.kill() } catch {} const listing = probe.stdout || ''; if (probe.status !== 0 || !listing.includes('->')) { console.log('UNVERIFIED'); process.exit(0) } -console.log(listing.includes('ptmx') ? 'INHERITED' : 'ISOLATED'); +console.log(listing.includes('ptmx') ? 'LEAKED' : 'ISOLATED'); process.exit(0); ` -/** 'isolated' when a later plain child no longer inherits the master, 'unverified' when /proc cannot say. */ -function verifyMasterNotInheritedByLaterChild(relayDir) { - const result = spawnSync(process.execPath, ['-e', VERIFY_SCRIPT, nodePtyDir(relayDir)], { +// Apple's leak is self-held, not inherited, so the observation is this process's own fd table: +// N live ptys must account for exactly N /dev/ptmx rows. A stock build shows 2N -- the master plus +// the throwaway pty_posix_spawn opened and never closed. lsof, not /proc, because macOS has no +// /proc; a host without lsof cannot say, which is 'unverified', not a failed patch. +const VERIFY_SELF_FDS_SCRIPT = ` +const pty = require(process.argv[1]); +const terms = []; +for (let i = 0; i < 3; i++) { + terms.push(pty.spawn('/bin/sh', ['-c', 'sleep 30'], { + name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env + })); +} +const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'lsof -p ' + process.pid], { encoding: 'utf8', maxBuffer: 1 << 24 }); +for (const term of terms) { try { term.kill() } catch {} } +const rows = (probe.stdout || '').split('\\n').filter((line) => line.includes('/dev/ptmx')); +if (probe.status !== 0 || rows.length < terms.length) { console.log('UNVERIFIED'); process.exit(0) } +console.log(rows.length > terms.length ? 'LEAKED' : 'ISOLATED'); +process.exit(0); +` + +const LEAK_MESSAGE = { + darwin: 'rebuilt node-pty still leaks a throwaway pty fd per spawn', + linux: 'rebuilt node-pty still leaks the pty master into later children' +} + +/** 'isolated' when the platform's leak is gone, 'unverified' when the host cannot show it. */ +function verifyNoPtyFdLeak(relayDir, platform) { + const script = platform === 'darwin' ? VERIFY_SELF_FDS_SCRIPT : VERIFY_INHERITANCE_SCRIPT + const result = spawnSync(process.execPath, ['-e', script, nodePtyDir(relayDir)], { cwd: relayDir, encoding: 'utf8', timeout: VERIFY_TIMEOUT_MS, @@ -219,22 +314,53 @@ function verifyMasterNotInheritedByLaterChild(relayDir) { `rebuilt node-pty did not load: ${tail || result.error?.message || result.signal}` ) } - if (output.includes('INHERITED')) { - throw new Error('rebuilt node-pty still leaks the pty master into later children') + if (output.includes('LEAKED')) { + throw new Error(LEAK_MESSAGE[platform] || LEAK_MESSAGE.linux) } return output.includes('ISOLATED') ? 'isolated' : 'unverified' } -function rollback(relayDir, releaseDir, backupDir) { - rmSync(releaseDir, { recursive: true, force: true }) +/** + * What gets moved aside before the compile, and where the compile writes. + * + * Linux ships no prebuild, so `build/Release` is both the working build and the compile's output, + * and moving it aside only arms the rollback. macOS runs `prebuilds/darwin-` and has no + * `build/` at all, so the compile writes a new `build/Release` -- which node-pty's loader checks + * ahead of `prebuilds`. Moving `prebuilds` aside does double duty there: it arms the rollback and + * it is what makes node-pty's install script fall through from "prebuild found" to `node-gyp + * rebuild`. Deliberately not `npm_config_build_from_source`, which deletes the prebuilds outright + * and would leave nothing to roll back to. + */ +function buildLayout(relayDir, platform, arch) { + const ptyDir = nodePtyDir(relayDir) + const compiledDir = join(ptyDir, 'build', 'Release') + if (platform === 'darwin') { + const prebuildsDir = join(ptyDir, 'prebuilds') + return { + compiledDir, + movedDir: prebuildsDir, + workingBuildPath: join(prebuildsDir, `darwin-${arch}`, 'pty.node'), + missingStatus: 'skipped:no-prebuild' + } + } + return { + compiledDir, + movedDir: compiledDir, + workingBuildPath: join(compiledDir, 'pty.node'), + missingStatus: 'skipped:no-compiled-build' + } +} + +function rollback(relayDir, layout, backupDir) { + rmSync(layout.compiledDir, { recursive: true, force: true }) try { revertNodePtyMasterCloexecSource(relayDir) } catch { // The build that is about to be restored predates the patch either way. } if (existsSync(backupDir)) { - mkdirSync(dirname(releaseDir), { recursive: true }) - renameSync(backupDir, releaseDir) + mkdirSync(dirname(layout.movedDir), { recursive: true }) + renameSync(backupDir, layout.movedDir) } } @@ -244,16 +370,17 @@ function rollback(relayDir, releaseDir, backupDir) { */ function applyNodePtyMasterCloexecPatch(relayDir = process.cwd(), options = {}) { const platform = options.platform || process.platform + const arch = options.arch || process.arch const rebuild = options.rebuild || rebuildNodePty - const verify = options.verify || verifyMasterNotInheritedByLaterChild - if (platform !== 'linux') { - return 'skipped:not-linux' + const verify = options.verify || verifyNoPtyFdLeak + if (platform !== 'linux' && platform !== 'darwin') { + return 'skipped:unsupported-platform' } const skipMarkerPath = join(relayDir, SKIP_MARKER_FILENAME) if (existsSync(skipMarkerPath)) { return 'skipped:earlier-attempt-failed' } - const releaseDir = join(nodePtyDir(relayDir), 'build', 'Release') + const layout = buildLayout(relayDir, platform, arch) const backupDir = join(nodePtyDir(relayDir), BACKUP_DIRNAME) // A backup stranded by a connection that died mid-rebuild is stale by definition: // whatever repaired node-pty since built from the source now on disk. @@ -272,25 +399,28 @@ function applyNodePtyMasterCloexecPatch(relayDir = process.cwd(), options = {}) if (hash !== ORIGINAL_SOURCE_SHA256) { return 'skipped:unexpected-source' } - // No compiled build means the host runs a prebuild or nothing at all; rebuilding - // could only take away the artifact the probe just proved loadable. - if (!existsSync(join(releaseDir, 'pty.node'))) { - return 'skipped:no-compiled-build' + // Nothing to fall back on means the host runs neither a compile nor the prebuild + // this platform expects; rebuilding could only take away the artifact the probe + // just proved loadable. + if (!existsSync(layout.workingBuildPath)) { + return layout.missingStatus } try { - renameSync(releaseDir, backupDir) + renameSync(layout.movedDir, backupDir) } catch (err) { return `skipped:${err.message}` } try { patchNodePtyMasterCloexecSource(relayDir) rebuild(relayDir) - const verdict = verify(relayDir) + const verdict = verify(relayDir, platform) + // Discarded, not restored: a tree that gets published must hold no unpatched binary the + // loader could still fall back to. A later repair recompiles from the patched source. rmSync(backupDir, { recursive: true, force: true }) return verdict === 'isolated' ? 'patched' : 'patched-unverified' } catch (err) { - rollback(relayDir, releaseDir, backupDir) + rollback(relayDir, layout, backupDir) // Bounded on purpose: one compile attempt per relay directory, never a retry loop. try { writeFileSync(skipMarkerPath, `${new Date().toISOString()} ${err.message}\n`) diff --git a/config/scripts/bootstrap-locale-catalog.mjs b/config/scripts/bootstrap-locale-catalog.mjs index 05739b4da9c..e5c5fb69a81 100644 --- a/config/scripts/bootstrap-locale-catalog.mjs +++ b/config/scripts/bootstrap-locale-catalog.mjs @@ -34,6 +34,11 @@ const LOCALE_CONFIG = { targetLanguage: 'es', displayName: 'Spanish', cacheFile: '.es-catalog-cache.json' + }, + fr: { + targetLanguage: 'fr', + displayName: 'French', + cacheFile: '.fr-catalog-cache.json' } } diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index d2407776fa7..453d5702cb0 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -1,14 +1,18 @@ +import { readFileSync, readdirSync } from 'node:fs' import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) +const projectRoot = resolve(import.meta.dirname, '..', '..') const electronBuilderConfig = require('../electron-builder.config.cjs') const { createPackagedRuntimeNodeModuleResources, findAsarEntry, + isPackagedExternalSpecifier, + packageNameFromSpecifier, prunePackagedNodePty, prunePackagedParcelWatcher, prunePackagedSherpaOnnx, @@ -306,3 +310,91 @@ describe('packaged runtime resources', () => { } ) }) + +// Why source-anchored: the bundler renames a createRequire()'d require, so +// verifyPackagedMainRuntimeDeps' `require("x")` scan cannot see these specifiers — packaging +// stays green while the packaged app throws MODULE_NOT_FOUND the first time the path runs. +function collectLazyRequireSpecifiers(directory, found = new Map()) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + collectLazyRequireSpecifiers(entryPath, found) + continue + } + if (!entry.isFile() || !entry.name.endsWith('.ts') || entry.name.includes('.test.')) { + continue + } + const source = readFileSync(entryPath, 'utf8') + if (!source.includes('createRequire(')) { + continue + } + for (const match of source.matchAll(/\brequire[A-Za-z0-9_]*\(\s*'([^']+)'\s*\)/g)) { + if (isPackagedExternalSpecifier(match[1])) { + found.set(match[1], relative(projectRoot, entryPath).replaceAll('\\', '/')) + } + } + } + return found +} + +function packagedResourceDestinations(platform) { + return new Set( + (electronBuilderConfig[platform].extraResources ?? []).map((resource) => + String(resource.to).replaceAll('\\', '/') + ) + ) +} + +describe('lazily required packages reach Resources/node_modules', () => { + it('copies every createRequire specifier main uses into the packaged resource plan', () => { + const specifiers = collectLazyRequireSpecifiers(join(projectRoot, 'src', 'main')) + expect(specifiers.size).toBeGreaterThan(0) + + const destinations = { + win: packagedResourceDestinations('win'), + mac: packagedResourceDestinations('mac'), + linux: packagedResourceDestinations('linux') + } + for (const [specifier, source] of specifiers) { + const packageName = packageNameFromSpecifier(specifier) + const covered = (platform) => + destinations[platform].has(`node_modules/${packageName}`) || + destinations[platform].has(`node_modules/${specifier}`) + // Windows carries the full closure, so an uncovered specifier is uncovered everywhere. + expect( + covered('win'), + `${source} lazily requires '${specifier}', but nothing copies it to Resources/node_modules` + ).toBe(true) + if (covered('mac') && covered('linux')) { + continue + } + // Only the Windows-native loaders may be absent from the mac/linux plans. + expect(source, `'${specifier}' is packaged for Windows only`).toContain('windows') + } + }) + + it('resolves the copied emoji dataset the way the packaged main bundle does', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-lazy-require-')) + try { + const datasetPath = 'node_modules/emojibase-data/en/shortcodes/emojibase.json' + const entry = electronBuilderConfig.mac.extraResources.find( + (resource) => String(resource.to) === datasetPath + ) + expect(entry).toBeDefined() + const destination = join(resourcesDir, ...datasetPath.split('/')) + await mkdir(dirname(destination), { recursive: true }) + await cp(join(projectRoot, ...String(entry.from).split('/')), destination) + + // app.asar's parent is Resources, so main's bare require walks into Resources/node_modules. + const packagedMainDir = join(resourcesDir, 'app.asar', 'out', 'main') + await mkdir(packagedMainDir, { recursive: true }) + const probe = join(packagedMainDir, 'probe.cjs') + await writeFile(probe, 'module.exports = require', 'utf8') + + const dataset = require(probe)('emojibase-data/en/shortcodes/emojibase.json') + expect(Object.keys(dataset).length).toBeGreaterThan(1000) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/locale-key-overrides.mjs b/config/scripts/locale-key-overrides.mjs index ec2a0f7d7c2..5519f34f1b5 100644 --- a/config/scripts/locale-key-overrides.mjs +++ b/config/scripts/locale-key-overrides.mjs @@ -12,6 +12,9 @@ const BASE_LOCALE_KEY_OVERRIDES = { // Bare "Cursor" terminal/theme settings = on-screen カーソル, not the Cursor product. 'auto.components.settings.TerminalWindowSection.c9e1fdf42f': { ja: 'カーソル' }, 'auto.components.onboarding.ThemeStep.ab2a583a97': { ja: 'カーソル' }, + // File-row "Duplicate" is the action, and it sits beside "Copy" (复制) in the same menu; keyed + // because the skills-dialog chip shares the English string but reads as a noun. + 'auto.components.right.sidebar.FileExplorerRow.0fec99bfd7': { zh: '创建副本' }, 'menu.reportCrash': { ko: '크래시 신고...', zh: '报告崩溃...', ja: 'クラッシュを報告...' }, 'menu.showMobileButton': { ko: 'Orca 모바일 버튼 표시', diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index f368ecc3cbc..bf5f62d1fa5 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -492,7 +492,7 @@ "ko": "agent CLI를 찾지 못했습니다. 하나를 설치하거나 설정에서 기본 agent를 선택하세요." }, "auto.components.Terminal.7958465754": { - "ko": "실행 중인 프로세스가 있는 로컬 terminals이 있습니다. 그래도 창을 닫으시겠습니까?" + "ko": "실행 중인 프로세스가 있는 terminals이 있습니다. 그래도 창을 닫으시겠습니까?" }, "auto.components.Terminal.cdc9ac4b2d": { "ko": "편집기" diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index 9fd4350ee45..cec2ebf63ad 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -217,10 +217,41 @@ export const NEVER_TRANSLATE_VALUES = new Set([ ]) export const NATIVE_PICKER_LABELS = { - zh: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' }, - ko: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' }, - ja: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' }, - es: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' } + zh: { + chinese: '中文(简体)', + korean: '한국어', + japanese: '日本語', + spanish: 'Español', + french: 'Français' + }, + ko: { + chinese: '中文(简体)', + korean: '한국어', + japanese: '日本語', + spanish: 'Español', + french: 'Français' + }, + ja: { + chinese: '中文(简体)', + korean: '한국어', + japanese: '日本語', + spanish: 'Español', + french: 'Français' + }, + es: { + chinese: '中文(简体)', + korean: '한국어', + japanese: '日本語', + spanish: 'Español', + french: 'Français' + }, + fr: { + chinese: '中文(简体)', + korean: '한국어', + japanese: '日本語', + spanish: 'Español', + french: 'Français' + } } const CJK_LATIN_SPACED_TERM_PATTERN = CJK_LATIN_SPACED_TERMS.join('|') diff --git a/config/scripts/locale-zh-value-overrides.mjs b/config/scripts/locale-zh-value-overrides.mjs index b53fb1d2248..5055ba00341 100644 --- a/config/scripts/locale-zh-value-overrides.mjs +++ b/config/scripts/locale-zh-value-overrides.mjs @@ -44,6 +44,8 @@ export const ZH_VALUE_OVERRIDES = { 'Loading labels': '加载标签', // Why: MT rendered the "Pin Tab" action as "引脚标签" (noun reading of "pin"); pair it with 取消固定标签. 'Pin Tab': '固定标签', + // Why: MT read "Duplicate" as the adjective (重复); it is the action, and the menu is already on 选项卡. + 'Duplicate Tab': '复制选项卡', Approved: '已批准', Strike: '删除线', Bold: '粗体', diff --git a/config/scripts/node-pty-master-cloexec-patch.test.mjs b/config/scripts/node-pty-master-cloexec-patch.test.mjs index 16013cbf0a3..e323c5d61c8 100644 --- a/config/scripts/node-pty-master-cloexec-patch.test.mjs +++ b/config/scripts/node-pty-master-cloexec-patch.test.mjs @@ -27,7 +27,7 @@ afterEach(() => { } }) -describe('SSH relay node-pty pty-master close-on-exec patch', () => { +describe('SSH relay node-pty pty fd-leak patch', () => { it('adds the forkpty close-on-exec call and reverts to the published bytes', () => { const fixture = writeRelayFixture() @@ -44,6 +44,27 @@ describe('SSH relay node-pty pty-master close-on-exec patch', () => { expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) }) + it('rewrites the Apple branch, which is the only one macOS executes', () => { + const fixture = writeRelayFixture() + patchNodePtyMasterCloexecSource(fixture.root) + const patched = readFileSync(fixture.sourcePath, 'utf8') + + // Stock's cleanup never runs: the first posix_openpt() already returns >= 2, so the loop + // breaks with count == 0 -- and where it does run it closes low_fds[count], never low_fds[0]. + expect(STOCK_SOURCE).toContain('for (; count > 0; count--) {') + expect(patched).not.toContain('for (; count > 0; count--) {') + expect(patched).toContain('int low_fds[3] = {-1, -1, -1};') + expect(patched).toContain('for (size_t i = 0; i <= count && i < 3; i++) {') + + // `default:` sits in the `#else` arm of PtyFork's `#if defined(__APPLE__)`, so marking only + // the forkpty call site left the master macOS actually opens unmarked. + expect(patched).toContain( + ' if (pty_cloexec(master) == -1) {\n' + + ' throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");\n' + + ' }\n#else\n' + ) + }) + it('refuses a different node-pty version or an unrecognized source', () => { const wrongVersion = writeRelayFixture({ version: '1.2.0-beta.4' }) expect(() => patchNodePtyMasterCloexecSource(wrongVersion.root)).toThrow('expected 1.1.0') @@ -139,19 +160,81 @@ describe('SSH relay node-pty pty-master close-on-exec patch', () => { expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) }) - it('never compiles on a platform that does not leak', () => { - for (const platform of ['darwin', 'win32']) { - const fixture = writeRelayFixture() - const calls = [] - const status = applyNodePtyMasterCloexecPatch(fixture.root, { - platform, - rebuild: () => calls.push('rebuild'), - verify: () => 'isolated' - }) - expect(status).toBe('skipped:not-linux') - expect(calls).toEqual([]) - expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) - } + it('never compiles on a platform with no pty fds to leak', () => { + const fixture = writeRelayFixture() + const calls = [] + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'win32', + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + expect(status).toBe('skipped:unsupported-platform') + expect(calls).toEqual([]) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + }) + + it('compiles a macOS install out from under its shipped prebuild', () => { + // macOS has no build/ at all: node-pty runs `prebuilds/darwin-`, built from the leaky + // source. Moving `prebuilds` aside is what both arms the rollback and makes node-pty's own + // install script fall through from "prebuild found" to node-gyp. + const fixture = writeRelayFixture({ platform: 'darwin' }) + const prebuildsPresentDuringRebuild = [] + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'darwin', + arch: fixture.arch, + rebuild: () => { + prebuildsPresentDuringRebuild.push(existsSync(fixture.prebuildsDir)) + writeCompiledBuild(fixture, 'patched-build') + }, + verify: () => 'isolated' + }) + + expect(status).toBe('patched') + expect(prebuildsPresentDuringRebuild).toEqual([false]) + expect(readFileSync(fixture.compiledPath, 'utf8')).toBe('patched-build') + // The published tree must hold no unpatched binary: node-pty's loader checks build/Release + // first, but falls back to a prebuild if that ever fails to load. + expect(existsSync(fixture.prebuildsDir)).toBe(false) + expect(existsSync(fixture.backupDir)).toBe(false) + }) + + it('restores the macOS prebuild when the first compile fails', () => { + // A macOS host has no toolchain guarantee at all, so this is the common failure, not the rare + // one -- and the relay has to come back on the prebuild exactly as it was installed. + const fixture = writeRelayFixture({ platform: 'darwin' }) + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'darwin', + arch: fixture.arch, + rebuild: () => { + writeCompiledBuild(fixture, 'half-built') + throw new Error('npm rebuild node-pty exited 1: no C++ toolchain') + }, + verify: () => 'isolated' + }) + + expect(status).toContain('failed:') + expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build') + expect(existsSync(fixture.compiledPath)).toBe(false) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) + expect(existsSync(fixture.skipMarkerPath)).toBe(true) + }) + + it('will not rebuild a macOS install that has no prebuild to fall back on', () => { + const fixture = writeRelayFixture({ platform: 'darwin', build: false }) + const calls = [] + + const status = applyNodePtyMasterCloexecPatch(fixture.root, { + platform: 'darwin', + arch: fixture.arch, + rebuild: () => calls.push('rebuild'), + verify: () => 'isolated' + }) + + expect(status).toBe('skipped:no-prebuild') + expect(calls).toEqual([]) + expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE) }) it('leaves an already patched install alone', () => { @@ -201,19 +284,35 @@ describe('SSH relay node-pty pty-master close-on-exec patch', () => { }) }) -function writeRelayFixture({ version = '1.1.0', source = STOCK_SOURCE, build = true } = {}) { +/** + * `buildPath` is the working build the patch has to be able to fall back on, which differs by + * platform: Linux compiles into build/Release at install time, macOS runs a shipped prebuild and + * has no build/ at all. `compiledPath` is where the rebuild writes on either. + */ +function writeRelayFixture({ + version = '1.1.0', + source = STOCK_SOURCE, + build = true, + platform = 'linux', + arch = 'arm64' +} = {}) { const root = mkdtempSync(join(projectDir, '.node-pty-cloexec-patch-test-')) cleanupDirs.push(root) const nodePtyDir = join(root, 'node_modules', 'node-pty') const sourcePath = join(nodePtyDir, 'src', 'unix', 'pty.cc') - const buildPath = join(nodePtyDir, 'build', 'Release', 'pty.node') + const compiledPath = join(nodePtyDir, 'build', 'Release', 'pty.node') + const prebuildsDir = join(nodePtyDir, 'prebuilds') mkdirSync(join(nodePtyDir, 'src', 'unix'), { recursive: true }) writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version })) writeFileSync(sourcePath, source) const fixture = { root, + arch, sourcePath, - buildPath, + compiledPath, + prebuildsDir, + buildPath: + platform === 'darwin' ? join(prebuildsDir, `darwin-${arch}`, 'pty.node') : compiledPath, backupDir: join(nodePtyDir, '.orca-cloexec-prepatch-release'), skipMarkerPath: join(root, SKIP_MARKER_FILENAME) } @@ -227,3 +326,8 @@ function writeBuild(fixture, contents) { mkdirSync(resolve(fixture.buildPath, '..'), { recursive: true }) writeFileSync(fixture.buildPath, contents) } + +function writeCompiledBuild(fixture, contents) { + mkdirSync(resolve(fixture.compiledPath, '..'), { recursive: true }) + writeFileSync(fixture.compiledPath, contents) +} diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 2cd26aa80a5..15ded915c67 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -229,6 +229,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/cli/wsl-cli-powershell-boundary.test.ts', 'src/main/cursor/hook-service.test.ts', 'src/main/orca-profiles/profile-index-store.test.ts', + 'src/main/startup/windows-install-dir-acl-repair.win32.test.ts', 'src/main/runtime/repo-worktree-admin-fingerprint.test.ts', 'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts', 'src/shared/secure-file-fsync-flags.test.ts', @@ -264,6 +265,14 @@ export function shouldRunPrChecks(changedFiles) { return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file)) } +export function needsMobileDependencies(changedFiles) { + // Why: static analysis lints CHANGED files, mobile ones included, and its + // type-aware pass resolves types from mobile/node_modules. Mobile is a + // separate pnpm project, so without this the root-only install leaves every + // mobile type an `error` type and the gate reports phantom findings. + return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/')) +} + export function classifyPrJobs(changedFiles) { const emptyDiff = changedFiles.length === 0 const shouldRun = shouldRunPrChecks(changedFiles) @@ -277,6 +286,7 @@ export function classifyPrJobs(changedFiles) { return { should_run: shouldRun, native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)), + mobile_dependencies: shouldRun && needsMobileDependencies(changedFiles), ...jobs } } diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 1fe296af265..4642372135c 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -316,6 +316,24 @@ describe('per-job path classification', () => { } }) + // Why: static analysis lints changed mobile files with a type-aware pass, and + // mobile is a separate pnpm project. Without its node_modules every mobile type + // resolves to an `error` type and the changed-code gate fails on phantom + // findings, which is exactly how a react-test-renderer union broke a PR. + it('installs mobile dependencies exactly when mobile files change', () => { + expect(classifyPrJobs([]).mobile_dependencies).toBe(true) + expect(classifyPrJobs(['README.md']).mobile_dependencies).toBe(false) + expect(classifyPrJobs(['src/main/index.ts']).mobile_dependencies).toBe(false) + expect( + classifyPrJobs(['src/main/index.ts', 'mobile/src/session/a.test.ts']).mobile_dependencies + ).toBe(true) + // Why false: a mobile-only diff skips every desktop job, so the install step's own + // job never runs and claiming the install is needed contradicts should_run. + expect(classifyPrJobs(['mobile/package.json']).mobile_dependencies).toBe(false) + expect(classifyPrJobs(['mobile/package.json']).should_run).toBe(false) + expect(classifyPrJobs(['README.md', 'mobile/src/a.ts']).mobile_dependencies).toBe(false) + }) + it('keeps unit-test-only diffs out of packaging', () => { expectClassification(['src/main/git/git-status.test.ts'], { git_compatibility: true @@ -354,6 +372,20 @@ describe('PR Checks skip wiring', () => { } }) + it('gives static analysis the mobile types its type-aware pass resolves', () => { + expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe( + '${{ steps.filter.outputs.mobile_dependencies }}' + ) + const steps = prWorkflow.jobs.static_analysis.steps + const install = steps.findIndex((step) => step.name === 'Install mobile dependencies') + const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality') + expect(install).toBeGreaterThan(-1) + expect(install).toBeLessThan(gate) + expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'") + expect(steps[install]['working-directory']).toBe('mobile') + expect(steps[install].run).toContain('--frozen-lockfile') + }) + it('keeps the cheap root-directory guard on docs-only PRs', () => { expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined() expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined() diff --git a/config/scripts/release-blocker-fixes.test.mjs b/config/scripts/release-blocker-fixes.test.mjs new file mode 100644 index 00000000000..bccd631a266 --- /dev/null +++ b/config/scripts/release-blocker-fixes.test.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') + +describe('release blocker safeguards', () => { + it('keeps the root package version on the current stable release line', () => { + const packageJson = JSON.parse(readFileSync(resolve(projectDir, 'package.json'), 'utf8')) + const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(packageJson.version) + expect(match).not.toBeNull() + const version = match.slice(1, 4).map(Number) + const isAtLeastStable = + version[0] > 1 || + (version[0] === 1 && (version[1] > 4 || (version[1] === 4 && version[2] >= 196))) + expect(isAtLeastStable).toBe(true) + }) + + it('passes the staging confirmation through the step environment', () => { + const workflow = parse( + readFileSync( + resolve(projectDir, '.github/workflows/cloud-prove-relay-asia-staging.yml'), + 'utf8' + ) + ) + const step = workflow.jobs.prove.steps.find( + ({ name }) => name === 'Validate the exact staging proof request' + ) + + expect(step.env.CONFIRMATION).toBe('${{ inputs.confirmation }}') + expect(step.run).toContain('test "${CONFIRMATION}" = PROVE_ASIA_STAGING') + expect(step.run).not.toContain('${{ inputs.confirmation }}') + }) +}) diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index cb58372fab1..a73e9d5e3cc 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -11,7 +11,12 @@ import { repairTranslatedValue } from './locale-translation-policy.mjs' const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) -const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain']) +const LOCALIZATION_FUNCTION_NAMES = new Set([ + 't', + 'translate', + 'translateMain', + 'translateSearchKeyword' +]) const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales') export const LOCALIZATION_SOURCE_ROOTS = [ diff --git a/config/scripts/verify-localization-catalog.test.mjs b/config/scripts/verify-localization-catalog.test.mjs index 4bf3d7d7eba..4623f345b79 100644 --- a/config/scripts/verify-localization-catalog.test.mjs +++ b/config/scripts/verify-localization-catalog.test.mjs @@ -51,6 +51,20 @@ describe('verify-localization-catalog', () => { expect(readJson(path.join(localesDir, 'es.json'))).toEqual({}) }) + it('bootstraps keys referenced only through translateSearchKeyword', async () => { + const { root, localesDir } = makeProject({ + sourceText: + "import { translateSearchKeyword } from '@/components/settings/settings-search-keywords'\nexport const keywords = translateSearchKeyword('auto.components.settings.example.search.scroll', 'scroll')\n" + }) + + await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1) + await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0) + + expect(readJson(path.join(localesDir, 'en.json'))).toEqual({ + auto: { components: { settings: { example: { search: { scroll: 'scroll' } } } } } + }) + }) + it('never overwrites mismatched translations or removes target-only entries', async () => { const { root, localesDir } = makeProject({ sourceText: diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 56253527c69..2caf2149f73 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -19,6 +19,7 @@ "../src/preload/usage-provider-api.ts", "../src/shared/**/*", "../src/main/gitlab/mappers.ts", + "../src/main/ipc/deferred-emoji-shortcode-dataset.ts", "../src/main/ipc/worktree-branch-name.ts", "../src/main/ipc/worktree-logic.ts", "../src/main/ipc/worktree-display-name.ts", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 39fbcf45af1..ef8ebb61bb4 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 37m + + downloads: 38m @@ -15,7 +15,7 @@ downloads downloads - 37m - 37m + 38m + 38m diff --git a/docs/reference/ssh-execution-boundary.md b/docs/reference/ssh-execution-boundary.md index cc88cf39a17..88a4a3c0a0e 100644 --- a/docs/reference/ssh-execution-boundary.md +++ b/docs/reference/ssh-execution-boundary.md @@ -66,10 +66,27 @@ A verdict needs evidence from the host that owns the process. Apply these tests **Does the termination event match the current identity?** A host-delivered exit for the live PTY incarnation and provider generation, while its siblings still report, establishes `exited`. A stale event, an event for a superseded incarnation, or one quiet terminal with no host evidence does not. +**Did the answer carry its evidence, or only the same wording?** `pty.attach` refuses with `PTY "" not found` both for a pid the relay probed and found gone and for an id its session map never had — which is every id minted before a relay restart, since ids carry a per-start mint epoch. Only the probed refusal carries `PTY_ATTACH_PROVEN_EXITED_MARKER` (`src/shared/pty-attach-absence-evidence.ts`) and reaches the client as `SshPtyProvenExitedOnRelayError`; the unmarked union arrives as `SshPtyAbsentFromRelayError`, which licenses retiring the client's own route to the PTY and nothing more. A missing marker is never evidence — an older relay omits it too. + **Is a returned status actually a claim of success?** An operation that reports failure may have succeeded, and one that reports success may not have run — check the durable state it should have changed rather than trusting the return. Anything short of positive host evidence is `unverifiable`. Reporting it as `exited` is the error this document exists to prevent: it orphans live work and can cold-start a duplicate over the same worktree. +## Deciding a remote pane is idle + +The orphan-PTY sweep is the one flow that turns an observation into a SIGKILL, so its idleness evidence has to be measured against the same thing the signal reaches. It is not the terminal. + +`forceKillPosixPtyProcessGroups` (`src/main/pty/posix-pty-process-groups.ts`) collects every process group on the pane's tty and `killpg`s each one. The blast radius is therefore _(process groups on the tty) × (members of those groups, wherever they are)_, and the second factor is not bounded by the terminal at all. Two facts make that gap reachable: + +- **Job control can be off.** With `set +m` a background job does not get its own process group — it keeps the shell's. `ps` then shows one process group on the tty, running a build. Nothing in a tty-shaped predicate can see it. +- **A group member can leave the terminal.** `ioctl(TIOCNOTTY)` without `setsid` drops the controlling terminal but keeps the pgid, so the process reports `tpgid == -1`, never appears in `ps -t `, and is still killed by `killpg(shellPgid)`. A double-forked grandchild similarly keeps the pgid while reparenting to pid 1, so no walk by `ppid` from the PTY root can name it either. + +So `shellOwnsEveryTtyProcessGroup` (`src/main/providers/agent-foreground-process-batch.ts`) requires both measurements: every process group on the tty is the shell's own with none stopped, **and** the shell's own process group has no other member anywhere in the host's process table. The name is tty-shaped for wire-compatibility reasons only. + +Two residuals remain, and neither is removable here. The capture is a snapshot, so work started between the `ps` and the signal is invisible — bounded by `RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS` on the reading side, not eliminated. And a process the host's own `ps` cannot enumerate (another PID namespace, `hidepid=2`, a table truncated by a permission boundary) is unobservable while `killpg` still reaches it. + +The general rule this instantiates: **evidence must be measured in the unit the destructive action operates on.** Evidence in a different unit is `unverifiable` no matter how precise it looks. + ## Reading artifacts instead of process state Artifacts are stronger evidence than liveness signals, but they answer a narrower question than they appear to. diff --git a/docs/reference/xterm-patch-regeneration.md b/docs/reference/xterm-patch-regeneration.md index bd49dd8ca3d..ecb30913d28 100644 --- a/docs/reference/xterm-patch-regeneration.md +++ b/docs/reference/xterm-patch-regeneration.md @@ -24,11 +24,11 @@ truth. Everything else is derived from it by `config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact upstream commit the published tarball was built from. -`@xterm/addon-webgl` and `@xterm/addon-serialize` are generated the same way, -from their own source patches under `config/patches/xterm-src/`. Their entries -differ only in `packageDir` and build steps; everything below applies to all -three. `@xterm/addon-ligatures` is the one patch still written by hand — see -[Known Gaps](#known-gaps). +`@xterm/addon-webgl`, `@xterm/addon-search` and `@xterm/addon-serialize` are +generated the same way, from their own source patches under +`config/patches/xterm-src/`. Their entries differ only in `packageDir` and build +steps; everything below applies to all four. `@xterm/addon-ligatures` is the one +patch still written by hand — see [Known Gaps](#known-gaps). ## Rules diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index f470214dbed..f4a34494328 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -2,7 +2,11 @@ import { useMemo, useRef, useState } from 'react' import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question' +import { + formatQuestionAnswer, + formatQuestionFreeTextAnswer, + type MobileChatQuestion +} from './mobile-native-chat-question' type Props = { question: MobileChatQuestion @@ -18,6 +22,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J const [freeText, setFreeText] = useState('') const [sending, setSending] = useState(false) const sendingRef = useRef(false) + const allowOther = question.allowOther !== false const hasOptions = question.options.length > 0 const trimmedFreeText = freeText.trim() @@ -42,8 +47,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J } } - const answerSingle = async (option: string): Promise => { - await sendAnswer(formatQuestionAnswer(question, [option])) + const answerSingle = async (option: string, optionIndex: number): Promise => { + const token = question.optionTokens[optionIndex] + await sendAnswer(token && token.length > 0 ? token : formatQuestionAnswer(question, [option])) } const submitMulti = async (): Promise => { @@ -57,14 +63,13 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J if (trimmedFreeText.length === 0) { return } - // Free text is an unknown entry; formatQuestionAnswer passes it through. - if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) { + if (await sendAnswer(formatQuestionFreeTextAnswer(question, trimmedFreeText))) { setFreeText('') } } const canSubmitMulti = selected.length > 0 && !sending - const canSendFreeText = trimmedFreeText.length > 0 && !sending + const canSendFreeText = allowOther && trimmedFreeText.length > 0 && !sending // Stable keys for option rows even if an agent repeats a label. const optionRows = useMemo( @@ -81,7 +86,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J {hasOptions ? ( - {optionRows.map(({ label, key }) => { + {optionRows.map(({ label, key }, optIndex) => { const isSelected = selected.includes(label) return ( (question.multiSelect ? toggle(label) : answerSingle(label))} + onPress={() => + question.multiSelect ? toggle(label) : answerSingle(label, optIndex) + } > {question.multiSelect ? ( @@ -124,35 +131,37 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J ) : null} - - - [ - styles.freeSend, - !canSendFreeText && styles.freeSendDisabled, - pressed && canSendFreeText && styles.pressed - ]} - onPress={submitFreeText} - disabled={!canSendFreeText} - > - + - - + [ + styles.freeSend, + !canSendFreeText && styles.freeSendDisabled, + pressed && canSendFreeText && styles.pressed + ]} + onPress={submitFreeText} + disabled={!canSendFreeText} + > + + + + ) : null} ) } diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index 7dd09977c45..019e83c6a99 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -38,7 +38,7 @@ export function MobileSessionActiveContent({ browserScreencastSupported, showToast, nativeChatSendError, - nativeChatInputLockReason, + nativeChatOverlayInputLockReason, nativeChatController, dictation, handleDictationToggle, @@ -240,7 +240,7 @@ export function MobileSessionActiveContent({ dictationMode={dictationMode} onMicPressIn={handleDictationPressIn} onMicPressOut={handleDictationPressOut} - inputLockReason={nativeChatInputLockReason} + inputLockReason={nativeChatOverlayInputLockReason} sendErrorMessage={nativeChatSendError.message} onClearSendError={nativeChatSendError.clear} sendSurfaceId={controller.nativeChatScopeKey ?? ''} diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx index 1ddc7cb1d83..552f507a787 100644 --- a/mobile/src/session/MobileSessionHeader.tsx +++ b/mobile/src/session/MobileSessionHeader.tsx @@ -168,6 +168,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC {t.type === 'file' && ( )} + {t.type === 'agent-session' && } {t.type === 'terminal' && (() => { const agentId = resolveMobileTerminalTabAgentId(t) diff --git a/mobile/src/session/MobileSessionSheets.tsx b/mobile/src/session/MobileSessionSheets.tsx index 48dcabed23c..0aac2bb9d42 100644 --- a/mobile/src/session/MobileSessionSheets.tsx +++ b/mobile/src/session/MobileSessionSheets.tsx @@ -43,6 +43,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC setFileActionTarget, browserActionTarget, setBrowserActionTarget, + agentSessionActionTarget, + setAgentSessionActionTarget, discardMarkdownTarget, setDiscardMarkdownTarget, leaveDrafts, @@ -261,6 +263,14 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC onCloseTab={handleCloseSessionTab} bulkCloseActions={bulkCloseActions} /> + + setAgentSessionActionTarget(null) + )} + onClose={() => setAgentSessionActionTarget(null)} + /> = { activated: boolean activationSeq: number latestActivationSeq: number - sourceTerminalHandle: string + sourceTerminalHandle: string | null activeTerminalHandle: string | null + sourceSessionTabId?: string | null + activeSessionTabId?: string | null activeTabType: string | null } switchSessionTab: (tab: T) => void diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 2283256da05..890a3a1562e 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -58,7 +58,12 @@ export type MobileNativeChatController = { handleNativeChatSendWithOutcome: ( text: string, images?: string[], - deadline?: number + deadline?: number, + attachments?: readonly { + id?: string + path: string + previewUri: string + }[] ) => Promise /** Launch-context text still parked on the agent's TUI input line, or null. * Image sends read it to size their leading clear (one Ctrl+U per line). */ diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index 7daa4babea6..e1bd97cad8f 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -123,6 +123,30 @@ describe('resolveMobileNativeChat', () => { expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull() }) + it('resolves Codex structured agent-session tabs directly', () => { + expect( + resolveMobileNativeChat({ + type: 'agent-session', + sessionId: 'structured-1', + agent: 'codex' + }) + ).toEqual({ + agent: 'codex', + sessionId: 'structured-1', + transcriptPath: null + }) + }) + + it('rejects non-Codex structured agent-session tabs', () => { + expect( + resolveMobileNativeChat({ + type: 'agent-session', + sessionId: 'structured-1', + agent: 'claude' + } as never) + ).toBeNull() + }) + it('canShowMobileNativeChat mirrors resolution', () => { expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true) expect(canShowMobileNativeChat(null)).toBe(false) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index abda64b04ab..a3f66eb14aa 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -32,6 +32,8 @@ export type MobileNativeChatTab = { /** Host-provided launch context still parked as an unsent TUI-input draft. */ launchDraft?: string launchDraftCreatedAt?: number + sessionId?: string | null + agent?: string | null } /** Resolve a session tab to the transcript identity native chat needs, or @@ -42,7 +44,15 @@ export function resolveMobileNativeChat( tab: MobileNativeChatTab | null, nativeChatTranscriptIsLocalReadable = false ): MobileNativeChatResolution | null { - if (!tab || tab.type !== 'terminal') { + if (!tab) { + return null + } + if (tab.type === 'agent-session') { + return tab.sessionId && tab.agent === 'codex' + ? { agent: tab.agent, sessionId: tab.sessionId, transcriptPath: null } + : null + } + if (tab.type !== 'terminal') { return null } const liveAgent = tab.agentStatus?.agentType ?? null @@ -71,3 +81,15 @@ export function canShowMobileNativeChat( ): boolean { return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null } + +export function resolveMobileNativeChatFileSessionId( + tab: MobileNativeChatTab | null +): string | null { + if (tab?.type === 'agent-session') { + return tab.sessionId ?? null + } + if (tab?.type === 'terminal') { + return tab.agentStatus?.providerSession?.id ?? null + } + return null +} diff --git a/mobile/src/session/mobile-native-chat-image-scope-state.ts b/mobile/src/session/mobile-native-chat-image-scope-state.ts new file mode 100644 index 00000000000..8d7de510e3a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-image-scope-state.ts @@ -0,0 +1,18 @@ +import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' + +export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = [] + +export type MobileNativeChatImagesByScope = Record + +export function withScopeAttachments( + byScope: MobileNativeChatImagesByScope, + scope: string, + next: PendingNativeChatImage[] +): MobileNativeChatImagesByScope { + if (next.length > 0) { + return { ...byScope, [scope]: next } + } + const remaining = { ...byScope } + delete remaining[scope] + return remaining +} diff --git a/mobile/src/session/mobile-native-chat-question.test.ts b/mobile/src/session/mobile-native-chat-question.test.ts index 94fbcf055a9..079e661e545 100644 --- a/mobile/src/session/mobile-native-chat-question.test.ts +++ b/mobile/src/session/mobile-native-chat-question.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { formatQuestionAnswer, + formatQuestionFreeTextAnswer, mobileChatQuestionKey, parseAgentQuestion, type MobileChatQuestion @@ -141,6 +142,12 @@ describe('formatQuestionAnswer', () => { expect(formatQuestionAnswer(numbered, [])).toBe('') expect(formatQuestionAnswer(numbered, [' '])).toBe('') }) + + it('prefixes free-text answers with an opaque prompt token when provided', () => { + expect( + formatQuestionFreeTextAnswer({ ...numbered, freeTextToken: 'target' }, ' hi there ') + ).toBe(`target:${encodeURIComponent('hi there')}`) + }) }) describe('mobileChatQuestionKey', () => { @@ -154,5 +161,8 @@ describe('mobileChatQuestionKey', () => { expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe( mobileChatQuestionKey(first) ) + expect(mobileChatQuestionKey({ ...first, freeTextToken: 'target-2' })).not.toBe( + mobileChatQuestionKey(first) + ) }) }) diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts index 8a1f06dfbf7..5d4e65a46ff 100644 --- a/mobile/src/session/mobile-native-chat-question.ts +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -7,10 +7,14 @@ export type MobileChatQuestion = { question: string options: string[] multiSelect: boolean + /** Structured questions hide the free-text row when the provider does not accept it. */ + allowOther?: boolean /** Per-option leading marker ("1", "b", …) when the source line carried one, * parallel to `options`. Null where the option was a plain bullet. Used to * echo the exact choice the agent listed back to the terminal. */ optionTokens: (string | null)[] + /** Opaque prefix used when free-text answers must target a specific prompt. */ + freeTextToken?: string } export function mobileChatQuestionKey(question: MobileChatQuestion): string { @@ -152,3 +156,13 @@ export function formatQuestionAnswer(question: MobileChatQuestion, selected: str return parts.join(question.multiSelect ? ', ' : ' ') } + +export function formatQuestionFreeTextAnswer(question: MobileChatQuestion, text: string): string { + const trimmed = text.trim() + if (trimmed.length === 0) { + return '' + } + return question.freeTextToken + ? `${question.freeTextToken}:${encodeURIComponent(trimmed)}` + : formatQuestionAnswer(question, [trimmed]) +} diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 5134cd373d4..c6e5f434326 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -62,15 +62,15 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = '5c475b904928f418c76a7885afdbed7adbfea3fe3ea05e85d956dc22f958a302' -const HEAD_HOOK_BINDING_SHA256 = '028f99dd14fea2110cff446418ee71513aeed38484c2dcea68bf0da8eff377c0' +const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6' +const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1' const HEAD_CALLBACK_IDENTITY_SHA256 = - 'd60ffe53f8d77f2dd3ebd14a5de162bb399113c170b59bdc917de6318ec433ec' -const HEAD_CALLBACK_BODY_SHA256 = '69dfda53fd700f4395a18a37ffdaa530e187bc24b4986d8fdc0184127c00b52d' -const HEAD_EFFECT_SHA256 = '346d384ea0bf2f8f926c5092c5bf57bc2a03494f49f9639e9d6b8a2c51c9f882' + '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' +const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9' +const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = - 'b562c117eb1e4532dd656d8bdd3ca3bc58ce65d78a7ed740dbd866a48d4d8dbe' + '6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73' const HEAD_NATIVE_REGISTRATION_SHA256 = 'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e' const HEAD_NATIVE_REMOVAL_SHA256 = @@ -79,13 +79,13 @@ const HEAD_TIMER_CREATION_SHA256 = '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' const HEAD_RUNTIME_STRING_SHA256 = - 'ad0def23206f08d0523c155fe730e86824876e67cf1db6b597541b9c35b54447' + 'ba52a3ede721bd29acbe8593161e90b216b7f361ff896e085927d4d73fa83b2f' const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5' -const HEAD_LEAF_JSX_SHA256 = 'b070e25c47b3e298be02a4ffe1572b36e204446fc161bad894690e9939403f54' +const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016' const HEAD_STYLE_REFERENCE_SHA256 = '295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a' const HEAD_IDENTITY_FIELD_SHA256 = - '6b37a0351795a387a358df76a5ab919a7098ddb76bf25a936c8902c062c8951c' + '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d' @@ -472,10 +472,10 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(269) + expect(main.hooks).toHaveLength(266) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) - expect(main.callbacks).toHaveLength(78) + expect(main.callbacks).toHaveLength(77) expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256) expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256) expect(main.effects).toHaveLength(24) @@ -517,12 +517,12 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(537) + expect(strings).toHaveLength(546) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) expect(jsx.host).toHaveLength(124) expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256) - expect(jsx.leaf).toHaveLength(59) + expect(jsx.leaf).toHaveLength(61) expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256) expect(jsx.styleReferences).toHaveLength(172) expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256) diff --git a/mobile/src/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts index a61b653a0e3..36c90b0a29d 100644 --- a/mobile/src/session/mobile-session-route-types.ts +++ b/mobile/src/session/mobile-session-route-types.ts @@ -9,7 +9,7 @@ import type { TerminalRecord } from './mobile-terminal-records' export type Terminal = TerminalRecord -export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser' +export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' export type MobileSessionTab = | { @@ -30,6 +30,14 @@ export type MobileSessionTab = terminalTheme?: MobileTerminalTheme isActive: boolean } + | { + type: 'agent-session' + id: string + title: string + sessionId: string + agent: 'codex' + isActive: boolean + } | { type: 'markdown' id: string diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index acab1d5e7c6..83b2021b95e 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -29,6 +29,14 @@ const tabReconciliationOwnerSource = readMobileSessionRouteSource( const autoCreateHookSource = readMobileSessionRouteSource( './use-initial-session-terminal-autocreate.ts' ) +const foundationSource = readMobileSessionRouteSource('./use-mobile-session-foundation.ts') +const terminalRuntimeSource = readMobileSessionRouteSource( + './use-mobile-session-terminal-runtime.ts' +) +const terminalSubscriptionSourceForIdentity = readMobileSessionRouteSource( + './use-mobile-session-terminal-subscription.ts' +) +const lifecycleSource = readMobileSessionRouteSource('./use-mobile-session-lifecycle.ts') function sliceBetween(startPattern: string, endPattern: string, targetSource = source): string { const start = targetSource.indexOf(startPattern) @@ -106,6 +114,19 @@ describe('mobile session startup', () => { expect(reconciliationHookSource).toContain('appStateSubscription.remove()') }) + it('binds terminal identity to the shared client before subscription effects run', () => { + expect(foundationSource).toContain('const { client, clientId, state: connState }') + expect(foundationSource).toContain(' clientId,') + expect(terminalRuntimeSource).toContain('useRef(clientId)') + expect(terminalRuntimeSource).toContain('deviceTokenRef.current = clientId') + expect(terminalRuntimeSource).toContain('inputGate.canSend && clientId !== null') + expect(terminalSubscriptionSourceForIdentity).toContain('if (clientId === null)') + expect(terminalSubscriptionSourceForIdentity).toContain( + "client: { id: clientId, type: 'mobile' as const }" + ) + expect(lifecycleSource).not.toContain('deviceTokenRef.current = host.deviceToken') + }) + it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => { expect(terminalSubscriptionSource).toContain( "if (data.type === 'end' || data.type === 'error')" diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts new file mode 100644 index 00000000000..84cb7033d30 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -0,0 +1,251 @@ +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import type { MobileChatQuestion } from './mobile-native-chat-question' + +export type StructuredApprovalItem = AgentJournalRenderItem & { + body: Extract +} + +export type StructuredQuestionItem = AgentJournalRenderItem & { + body: Extract +} + +export type StructuredPromptResponseTarget = { + itemId: string + expectedRevision: number + optionId: string +} + +type PromptTokenPayload = + | { + kind: 'approval' + itemId: string + revision: number + optionId: string + } + | { + kind: 'question-option' + itemId: string + revision: number + optionId: string + } + | { + kind: 'question-free-text' + itemId: string + revision: number + questionId: string + } + +const STRUCTURED_PROMPT_TOKEN_PREFIX = 'structured-agent-prompt:' + +export function pendingStructuredApproval( + item: AgentJournalRenderItem +): item is StructuredApprovalItem { + return item.body.kind === 'approval' && item.body.resolution.state === 'pending' +} + +export function pendingStructuredQuestion( + item: AgentJournalRenderItem +): item is StructuredQuestionItem { + return item.body.kind === 'question' && item.body.resolution.state === 'pending' +} + +function encodeQuestionAnswer(questionId: string, answer: string): string { + return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` +} + +function encodePromptToken(payload: PromptTokenPayload): string { + return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}` +} + +function decodePromptToken(value: string): PromptTokenPayload | null { + if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) { + return null + } + try { + const decoded = JSON.parse( + decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length)) + ) as Record + if ( + typeof decoded.itemId !== 'string' || + typeof decoded.revision !== 'number' || + !Number.isFinite(decoded.revision) + ) { + return null + } + if (decoded.kind === 'approval' && typeof decoded.optionId === 'string') { + return { + kind: decoded.kind, + itemId: decoded.itemId, + revision: decoded.revision, + optionId: decoded.optionId + } + } + if (decoded.kind === 'question-option' && typeof decoded.optionId === 'string') { + return { + kind: decoded.kind, + itemId: decoded.itemId, + revision: decoded.revision, + optionId: decoded.optionId + } + } + if (decoded.kind === 'question-free-text' && typeof decoded.questionId === 'string') { + return { + kind: decoded.kind, + itemId: decoded.itemId, + revision: decoded.revision, + questionId: decoded.questionId + } + } + } catch { + return null + } + return null +} + +function decodeQuestionFreeTextAnswer(value: string): { + payload: Extract + answer: string +} | null { + if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) { + return null + } + const separator = value.indexOf(':', STRUCTURED_PROMPT_TOKEN_PREFIX.length) + if (separator === -1) { + return null + } + const payload = decodePromptToken(value.slice(0, separator)) + if (payload?.kind !== 'question-free-text') { + return null + } + return { payload, answer: decodeURIComponent(value.slice(separator + 1)) } +} + +export function projectStructuredPermission( + prompt: StructuredApprovalItem | null +): MobileChatPermission | null { + if (prompt?.body.kind !== 'approval') { + return null + } + return { + title: prompt.body.title, + ...(prompt.body.detail ? { detail: prompt.body.detail } : {}), + options: prompt.body.options.map((option) => ({ + label: option.label, + send: encodePromptToken({ + kind: 'approval', + itemId: prompt.itemId, + revision: prompt.revision, + optionId: option.id + }) + })) + } +} + +export function projectStructuredQuestion( + prompt: StructuredQuestionItem | null +): MobileChatQuestion | null { + if (prompt?.body.kind !== 'question') { + return null + } + return { + question: prompt.body.question, + options: prompt.body.options.map((option) => option.label), + multiSelect: false, + allowOther: Boolean(prompt.body.freeTextQuestionId), + optionTokens: prompt.body.options.map((option) => + encodePromptToken({ + kind: 'question-option', + itemId: prompt.itemId, + revision: prompt.revision, + optionId: option.id + }) + ), + ...(prompt.body.freeTextQuestionId + ? { + freeTextToken: encodePromptToken({ + kind: 'question-free-text', + itemId: prompt.itemId, + revision: prompt.revision, + questionId: prompt.body.freeTextQuestionId + }) + } + : {}) + } +} + +export function structuredApprovalResponseTarget( + response: string, + currentPrompt: StructuredApprovalItem | null +): StructuredPromptResponseTarget | null { + const token = decodePromptToken(response) + if (token?.kind === 'approval') { + return { + itemId: token.itemId, + expectedRevision: token.revision, + optionId: token.optionId + } + } + if (token) { + return null + } + const option = currentPrompt?.body.options.find( + (candidate) => candidate.id === response || candidate.label === response + ) + return currentPrompt && option + ? { + itemId: currentPrompt.itemId, + expectedRevision: currentPrompt.revision, + optionId: option.id + } + : null +} + +export function structuredQuestionResponseTarget( + response: string, + currentPrompt: StructuredQuestionItem | null +): StructuredPromptResponseTarget | null { + const token = decodePromptToken(response) + if (token?.kind === 'question-option') { + return { + itemId: token.itemId, + expectedRevision: token.revision, + optionId: token.optionId + } + } + if (token) { + return null + } + const freeText = decodeQuestionFreeTextAnswer(response) + if (freeText) { + const answer = freeText.answer.trim() + return answer.length > 0 + ? { + itemId: freeText.payload.itemId, + expectedRevision: freeText.payload.revision, + optionId: encodeQuestionAnswer(freeText.payload.questionId, answer) + } + : null + } + if (!currentPrompt) { + return null + } + const trimmed = response.trim() + const option = currentPrompt.body.options.find( + (candidate) => candidate.id === response || candidate.label === trimmed + ) + if (option) { + return { + itemId: currentPrompt.itemId, + expectedRevision: currentPrompt.revision, + optionId: option.id + } + } + return currentPrompt.body.freeTextQuestionId && trimmed + ? { + itemId: currentPrompt.itemId, + expectedRevision: currentPrompt.revision, + optionId: encodeQuestionAnswer(currentPrompt.body.freeTextQuestionId, trimmed) + } + : null +} diff --git a/mobile/src/session/mobile-structured-agent-session-launch.test.ts b/mobile/src/session/mobile-structured-agent-session-launch.test.ts new file mode 100644 index 00000000000..54f9b5cbe88 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-launch.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch' + +function clientReturning( + ...responses: unknown[] +): RpcClient & { sendRequest: ReturnType } { + let responseIndex = 0 + const sendRequest = vi.fn(async () => responses[responseIndex++]) + return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType } +} + +const acceptedCreateResult = { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 0 }, + value: { + sessionId: 'codex_session_1', + fence: 1, + page: { + sessionId: 'codex_session_1', + epoch: 'epoch-1', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + unconfirmedClientMessageIds: [] + } +} +const acceptedCreate = { ok: true, result: acceptedCreateResult } + +describe('mobile structured Codex launch', () => { + it('creates through the structured agent-session intent after support is confirmed', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }, acceptedCreate) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({ + kind: 'created', + sessionId: expect.stringMatching(/^codex_[A-Za-z0-9_]{8,128}$/) + }) + expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', { + worktree: 'id:workspace-1', + agent: 'codex' + }) + expect(client.sendRequest).toHaveBeenNthCalledWith( + 2, + 'agentSession.create', + expect.objectContaining({ + worktree: 'id:workspace-1', + agent: 'codex', + envelope: expect.objectContaining({ expectedRuntimeFence: null }) + }), + expect.objectContaining({ budgetSpansConnect: true }) + ) + const params = client.sendRequest.mock.calls[1]?.[1] as { + envelope: { sessionId: string; payloadFingerprint: string } + worktree: string + agent: 'codex' + } + expect(params.envelope.payloadFingerprint).toMatch(/^[0-9a-f]{64}$/) + expect(params.envelope.sessionId).toMatch(/^codex_[A-Za-z0-9_]{8,128}$/) + }) + + it('reports unsupported without creating a terminal when the structured path is unavailable', async () => { + const client = clientReturning({ ok: true, result: { supported: false, reason: 'remote' } }) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({ + kind: 'unsupported', + reason: 'remote' + }) + expect(client.sendRequest).toHaveBeenCalledTimes(1) + }) + + it('keeps an unknown create outcome distinct so callers do not create a duplicate terminal', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }) + client.sendRequest.mockImplementationOnce(async () => ({ + ok: true, + result: { supported: true } + })) + client.sendRequest.mockRejectedValue(markRpcDeliveryUnknown(new Error('response lost'))) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({ + kind: 'unknown' + }) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.create' + ]) + expect(client.sendRequest.mock.calls[1]?.[1]).toBe(client.sendRequest.mock.calls[2]?.[1]) + }) + + it('keeps the outcome unknown when the idempotent retry cannot be sent', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }) + client.sendRequest.mockImplementationOnce(async () => ({ + ok: true, + result: { supported: true } + })) + client.sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost'))) + client.sendRequest.mockRejectedValueOnce(new Error('connection interrupted')) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({ + kind: 'unknown' + }) + }) + + it('never creates a legacy sibling after an unclassified create exception', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }) + client.sendRequest.mockImplementationOnce(async () => ({ + ok: true, + result: { supported: true } + })) + client.sendRequest.mockRejectedValue(new Error('internal error after commit')) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({ + kind: 'unknown' + }) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.create' + ]) + expect(client.sendRequest.mock.calls[1]?.[1]).toBe(client.sendRequest.mock.calls[2]?.[1]) + }) + + it('treats malformed structured responses as unknown', async () => { + const client = clientReturning( + { ok: true, result: { supported: true } }, + { ok: true, result: { ok: true, value: { sessionId: '' } } } + ) + + await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({ + kind: 'unknown' + }) + }) +}) diff --git a/mobile/src/session/mobile-structured-agent-session-launch.ts b/mobile/src/session/mobile-structured-agent-session-launch.ts new file mode 100644 index 00000000000..ecad0410dfd --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-launch.ts @@ -0,0 +1,158 @@ +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../src/shared/agent-session-wire' +import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation' +import type { RpcClient } from '../transport/rpc-client' +import { structuredSessionOperationId } from './mobile-structured-agent-session-rpc' + +type StructuredCreateSupport = { + supported?: boolean + reason?: 'agent' | 'remote' | 'wsl' +} + +export type MobileStructuredCodexLaunchResult = + | { kind: 'created'; sessionId: string } + | { kind: 'unsupported'; reason?: StructuredCreateSupport['reason'] } + | { kind: 'failed'; message: string } + | { kind: 'unknown'; message: string } + +type StructuredCreateParams = { + envelope: { + sessionId: string + clientOperationId: string + expectedRuntimeFence: null + payloadFingerprint: string + } + worktree: string + agent: 'codex' +} + +function createStructuredCodexSessionId(): string { + return `codex_${createRandomUuid().replaceAll('-', '_')}` +} + +function createRandomUuid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID() + } + return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('') +} + +function createStructuredCodexSessionParams(worktreeId: string): StructuredCreateParams { + const sessionId = createStructuredCodexSessionId() + const worktree = `id:${worktreeId}` + const fields = { worktree, agent: 'codex' as const } + return { + envelope: { + sessionId, + clientOperationId: structuredSessionOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId, + fields + }) + }, + ...fields + } +} + +function unknownCreateResult(error: unknown): MobileStructuredCodexLaunchResult { + const message = error instanceof Error ? error.message.trim() : '' + return { + kind: 'unknown', + message: message || 'The Codex chat result could not be confirmed.' + } +} + +export async function createMobileStructuredCodexSession( + client: RpcClient, + worktreeId: string +): Promise { + const worktree = `id:${worktreeId}` + let supportResponse + try { + supportResponse = await client.sendRequest('agentSession.createSupport', { + worktree, + agent: 'codex' + }) + } catch { + // A support probe has no side effect; an unavailable probe safely degrades to terminal chat. + return { kind: 'unsupported' } + } + if ( + !supportResponse || + typeof supportResponse !== 'object' || + typeof supportResponse.ok !== 'boolean' || + !supportResponse.ok + ) { + return { kind: 'unsupported' } + } + const support = supportResponse.result as StructuredCreateSupport | null + if (!support || typeof support !== 'object' || support.supported !== true) { + return { kind: 'unsupported', reason: support?.reason } + } + + const params = createStructuredCodexSessionParams(worktreeId) + let response + try { + response = await client.sendRequest('agentSession.create', params, { + timeoutMs: 15_000, + budgetSpansConnect: true + }) + } catch { + // Replay the durable envelope once so a lost acknowledgement cannot create a sibling. + try { + response = await client.sendRequest('agentSession.create', params, { + timeoutMs: 15_000, + budgetSpansConnect: true + }) + } catch (retryError) { + // A second transport error cannot disprove the first attempt committed. + return unknownCreateResult(retryError) + } + } + + if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') { + return unknownCreateResult(new Error('The Codex chat result could not be confirmed.')) + } + if (!response.ok) { + if ( + !response.error || + typeof response.error !== 'object' || + typeof response.error.code !== 'string' + ) { + return unknownCreateResult(new Error('The Codex chat result could not be confirmed.')) + } + if (response.error.code === 'agent_session_operation_unknown') { + return unknownCreateResult(new Error(response.error.message)) + } + return { kind: 'failed', message: response.error.message || 'Could not open Codex chat.' } + } + const result = response.result as AgentSessionMutationResult + if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') { + return unknownCreateResult(new Error('The Codex chat result could not be confirmed.')) + } + if (!result.ok) { + if ( + !result.refusal || + typeof result.refusal !== 'object' || + typeof result.refusal.code !== 'string' + ) { + return unknownCreateResult(new Error('The Codex chat result could not be confirmed.')) + } + if (result.refusal.code === 'agent_session_operation_unknown') { + return unknownCreateResult(new Error(result.refusal.message)) + } + return { kind: 'failed', message: result.refusal.message || 'Could not open Codex chat.' } + } + if ( + !result.value || + typeof result.value.sessionId !== 'string' || + !result.value.sessionId.trim() + ) { + return unknownCreateResult(new Error('The Codex chat result could not be confirmed.')) + } + return { kind: 'created', sessionId: result.value.sessionId } +} diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts new file mode 100644 index 00000000000..a602122978e --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -0,0 +1,152 @@ +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + parseAgentSessionOperationTimestamp +} from '../../../src/shared/agent-session-host-authority' +import type { AgentSessionMutationResult } from '../../../src/shared/agent-session-wire' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../src/shared/structured-agent-session-mutation' +import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import type { RpcClient } from '../transport/rpc-client' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS } from './mobile-native-chat-send' + +export const STRUCTURED_SEND_TIMEOUT_MS = 15_000 + +export type StructuredAgentSessionMutationCallResult = + | { status: 'accepted'; value: TValue } + | { status: 'refused'; message: string } + | { status: 'failed'; message: string } + | { status: 'unknown' } + +export type StructuredAgentSessionMutationResult = + | { status: 'accepted'; value: TValue; sameFence: boolean } + | { status: 'rejected' } + | { status: 'unknown' } + +export type StructuredAgentSessionMutate = ( + method: string, + fingerprintMethod: string, + fields: Record +) => Promise> + +export async function callAgentSession( + client: RpcClient, + method: string, + params: unknown, + timeoutMs = STRUCTURED_SEND_TIMEOUT_MS, + options?: { failWhenDisconnected?: boolean } +): Promise { + const response = await client.sendRequest(method, params, { + timeoutMs, + budgetSpansConnect: true, + ...(options?.failWhenDisconnected ? { failWhenDisconnected: true } : {}) + }) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult +} + +export function structuredSessionOperationId(): string { + const randomUuid = + typeof globalThis.crypto?.randomUUID === 'function' + ? () => globalThis.crypto.randomUUID() + : () => { + return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join( + '' + ) + } + return createStructuredAgentSessionOperationId(randomUuid) +} + +/** + * Bounded by expiry, never by count: every retained id belongs to a send whose outcome is still + * unknown, so dropping one turns the user's retry into a second message on the host. Only an id + * the host would already refuse — unparseable, or past the window in which it can be admitted — + * is safe to release, which matches the host's own tombstone retention. + */ +export function retainStructuredSessionOperationId( + operationIds: Map, + key: string, + operationId = structuredSessionOperationId(), + now: number = Date.now() +): string { + operationIds.delete(key) + operationIds.set(key, operationId) + for (const [retainedKey, retainedId] of operationIds) { + if (retainedKey === key) { + continue + } + const timestamp = parseAgentSessionOperationTimestamp(retainedId) + if (timestamp === null || now - timestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { + operationIds.delete(retainedKey) + } + } + return operationId +} + +export function timeoutForDeadline(deadline: number | undefined): number | null { + if (deadline === undefined) { + return STRUCTURED_SEND_TIMEOUT_MS + } + const timeoutMs = deadline - Date.now() + return timeoutMs >= MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS ? timeoutMs : null +} + +export async function requestStructuredAgentSessionMutation(args: { + client: RpcClient + method: string + fingerprintMethod: string + sessionId: string + expectedRuntimeFence: number + fields: Record + clientOperationId?: string + retryUnknown?: boolean + timeoutMs?: number +}): Promise> { + const { + client, + method, + fingerprintMethod, + sessionId, + expectedRuntimeFence, + fields, + clientOperationId, + retryUnknown, + timeoutMs + } = args + try { + const result = await callAgentSession>( + client, + method, + { + envelope: { + sessionId, + clientOperationId: clientOperationId ?? structuredSessionOperationId(), + expectedRuntimeFence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: fingerprintMethod, + sessionId, + fields + }) + }, + ...(retryUnknown ? { retryUnknown: true } : {}), + ...fields + }, + timeoutMs + ) + return result.ok + ? { status: 'accepted', value: result.value } + : { status: 'refused', message: result.refusal.message } + } catch (error) { + if (isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)) { + return { status: 'unknown' } + } + return { + status: 'failed', + message: error instanceof Error ? error.message : 'Request not sent' + } + } +} diff --git a/mobile/src/session/mobile-structured-session-operation-retention.test.ts b/mobile/src/session/mobile-structured-session-operation-retention.test.ts new file mode 100644 index 00000000000..209f28f65c9 --- /dev/null +++ b/mobile/src/session/mobile-structured-session-operation-retention.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../src/shared/agent-session-host-authority' +import { retainStructuredSessionOperationId } from './mobile-structured-agent-session-rpc' + +const NOW = 1_900_000_000_000 + +function operationIdAt(timestamp: number, entropy: string): string { + return `${timestamp}-${entropy.repeat(32).slice(0, 32)}` +} + +describe('structured session operation retention', () => { + it('keeps every unconfirmed operation id past the old 128-entry cap', () => { + const operationIds = new Map() + for (let index = 0; index < 400; index += 1) { + retainStructuredSessionOperationId( + operationIds, + `request-${index}`, + operationIdAt(NOW, 'a'), + NOW + ) + } + + expect(operationIds.size).toBe(400) + // Why: the first send is exactly the one a retry would duplicate if it were evicted. + expect(operationIds.get('request-0')).toBe(operationIdAt(NOW, 'a')) + }) + + it('releases only ids the host would already refuse as expired', () => { + const operationIds = new Map() + const expired = operationIdAt(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1, 'b') + const admissible = operationIdAt(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, 'c') + retainStructuredSessionOperationId(operationIds, 'stale', expired, NOW) + retainStructuredSessionOperationId(operationIds, 'live', admissible, NOW) + + retainStructuredSessionOperationId(operationIds, 'fresh', operationIdAt(NOW, 'd'), NOW) + + expect(operationIds.has('stale')).toBe(false) + expect(operationIds.get('live')).toBe(admissible) + expect(operationIds.get('fresh')).toBe(operationIdAt(NOW, 'd')) + }) + + it('drops ids the host could never admit and re-keys a repeated send', () => { + const operationIds = new Map() + retainStructuredSessionOperationId(operationIds, 'unparseable', 'not-an-operation-id', NOW) + const reused = retainStructuredSessionOperationId( + operationIds, + 'send', + operationIdAt(NOW, 'e'), + NOW + ) + + // A retry of the same send reuses the retained id rather than minting a duplicate. + expect( + retainStructuredSessionOperationId(operationIds, 'send', operationIds.get('send'), NOW) + ).toBe(reused) + expect(operationIds.has('unparseable')).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index e4ce55a84aa..bcc9510d0e8 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -182,6 +182,20 @@ describe('mobile terminal records', () => { ).toBe(false) }) + it('treats structured agent-session identity changes as session-tab changes', () => { + const base = { + type: 'agent-session' as const, + id: 'agent-tab-1', + title: 'Codex', + sessionId: 'session-1', + agent: 'codex', + isActive: true + } + + expect(mobileSessionTabsEqual([base], [{ ...base }])).toBe(true) + expect(mobileSessionTabsEqual([base], [{ ...base, sessionId: 'session-2' }])).toBe(false) + }) + const record = (over: Partial & { handle: string }): TerminalRecord => ({ title: 'Terminal', terminalTheme: undefined, diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index 09426863b99..f03a31acf41 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -62,6 +62,14 @@ type MobileSessionTabLike = canGoForward?: boolean isActive?: boolean } + | { + type: 'agent-session' + id: string + title?: string + sessionId?: string + agent?: string + isActive?: boolean + } export function mobileTerminalThemesEqual( left: MobileTerminalTheme | null | undefined, @@ -152,6 +160,8 @@ function mobileSessionTabEqual( a.canGoBack === b.canGoBack && a.canGoForward === b.canGoForward ) + case 'agent-session': + return b.type === 'agent-session' && a.sessionId === b.sessionId && a.agent === b.agent } } diff --git a/mobile/src/session/mobile-terminal-tab-agent.test.ts b/mobile/src/session/mobile-terminal-tab-agent.test.ts index 5177981f0e6..6ad034335ad 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.test.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.test.ts @@ -120,4 +120,17 @@ describe('getMobileSessionTabTitle', () => { expect(getMobileSessionTabTitle(blankBrowserTab)).toBe('New Browser') }) + + it('labels structured agent-session tabs without terminal decoration rules', () => { + expect( + getMobileSessionTabTitle({ + type: 'agent-session', + id: 'agent-tab-1', + title: 'Codex Chat', + sessionId: 'session-1', + agent: 'codex', + isActive: true + }) + ).toBe('Codex Chat') + }) }) diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts index 20326d41c98..dfc7be812e8 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -62,6 +62,9 @@ export function getMobileSessionTabTitle(tab: MobileSessionTab): string { if (tab.type === 'file') { return tab.title || 'File' } + if (tab.type === 'agent-session') { + return tab.title || 'Chat' + } // Why: strip the leading agent status glyph (✳ etc.) once the tab shows the // provider icon. Mobile falls back for glyph-only titles because iOS can // render the bare status glyph as a stray colored box beside the icon. diff --git a/mobile/src/session/opened-mobile-session-tab.test.ts b/mobile/src/session/opened-mobile-session-tab.test.ts index 988a3ea313f..dbeed5ed830 100644 --- a/mobile/src/session/opened-mobile-session-tab.test.ts +++ b/mobile/src/session/opened-mobile-session-tab.test.ts @@ -378,4 +378,19 @@ describe('shouldActivateOpenedMobileSessionTab', () => { }) ).toBe(false) }) + + it('allows a structured agent-session tab to anchor chat file activation', () => { + expect( + shouldActivateOpenedMobileSessionTab({ + activated: false, + activationSeq: 2, + latestActivationSeq: 2, + sourceTerminalHandle: null, + activeTerminalHandle: null, + sourceSessionTabId: 'agent-tab-1', + activeSessionTabId: 'agent-tab-1', + activeTabType: 'agent-session' + }) + ).toBe(true) + }) }) diff --git a/mobile/src/session/opened-mobile-session-tab.ts b/mobile/src/session/opened-mobile-session-tab.ts index 17c8cff9848..f76571eceef 100644 --- a/mobile/src/session/opened-mobile-session-tab.ts +++ b/mobile/src/session/opened-mobile-session-tab.ts @@ -9,8 +9,10 @@ export type OpenedMobileSessionTabActivationState = { activated: boolean activationSeq: number latestActivationSeq: number - sourceTerminalHandle: string + sourceTerminalHandle: string | null activeTerminalHandle: string | null + sourceSessionTabId?: string | null + activeSessionTabId?: string | null activeTabType: string | null } @@ -114,12 +116,15 @@ export async function activateOpenedSourceControlDiffTab( diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts index 21f07562459..6d0adbbf8df 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.test.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts @@ -142,4 +142,31 @@ describe('useMobileFileTapHandlers', () => { ) expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12") }) + + it('lets structured chat file taps resolve without a backing terminal handle', async () => { + const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false })) + const options = { + ...createOptions(sendRequest), + activeHandleRef: { current: null as string | null }, + getActiveSessionTabId: () => 'agent-tab-1', + getActiveSessionTabType: () => 'agent-session' + } + act(() => { + renderer = create(createElement(Harness, { options })) + }) + + handlers!.handleNativeChatFileTap('src/app.ts') + await act(async () => {}) + + expect(sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { + worktree: 'id:wt-1', + pathText: 'src/app.ts', + crossWorkspace: true, + nativeChatContext: { tabId: 'agent-tab-1', sessionId: 'session-1' } + }, + { timeoutMs: 10_000 } + ) + }) }) diff --git a/mobile/src/session/use-mobile-file-tap-handlers.ts b/mobile/src/session/use-mobile-file-tap-handlers.ts index 67995115f45..5bfe71f839b 100644 --- a/mobile/src/session/use-mobile-file-tap-handlers.ts +++ b/mobile/src/session/use-mobile-file-tap-handlers.ts @@ -141,15 +141,13 @@ export function useMobileFileTapHandlers( const handleNativeChatFileTap = useCallback((pathText: string) => { const current = optionsRef.current - // The chat overlay rides on its backing terminal tab; that handle anchors - // the activation gate even though resolution ignores the terminal's cwd. const sourceTerminalHandle = current.activeHandleRef.current - if (!current.client || !sourceTerminalHandle) { + const nativeChatSessionId = current.nativeChatSessionId + const nativeChatTabId = current.getActiveSessionTabId() + if (!current.client || (!sourceTerminalHandle && !(nativeChatSessionId && nativeChatTabId))) { return } const activationSeq = ++activationSeqRef.current - const nativeChatSessionId = current.nativeChatSessionId - const nativeChatTabId = current.getActiveSessionTabId() openMobileNativeChatFileTap({ client: current.client, hostId: current.hostId, @@ -172,6 +170,8 @@ export function useMobileFileTapHandlers( latestActivationSeq: activationSeqRef.current, sourceTerminalHandle, activeTerminalHandle: current.activeHandleRef.current, + sourceSessionTabId: nativeChatTabId, + activeSessionTabId: current.getActiveSessionTabId(), activeTabType: current.getActiveSessionTabType() }), switchSessionTab: current.switchSessionTab, diff --git a/mobile/src/session/use-mobile-native-chat-active-resolution.ts b/mobile/src/session/use-mobile-native-chat-active-resolution.ts new file mode 100644 index 00000000000..ea7f923de47 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-active-resolution.ts @@ -0,0 +1,83 @@ +import { useLayoutEffect, useRef, type MutableRefObject } from 'react' +import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' +import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility' +import { useMobileSessionViewMode } from './use-mobile-session-view-mode' + +export function useMobileNativeChatActiveResolution(args: { + hostId: string + worktreeId: string + activeSessionTab: MobileNativeChatTab | null + activeSessionTabId: string | null + activeHandleRef: MutableRefObject + nativeChatTranscriptIsLocalReadable: boolean +}): { + isTabChatView: (tabId: string) => boolean + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + activeChatAgent: string | null + activeChatAgentRef: MutableRefObject + activeChatSessionId: string | null + activeChatStructured: boolean + activeChatResolution: ReturnType + activeTabAgentWorking: boolean + nativeChatStatus: MobileNativeChatTab['agentStatus'] | null + sourceIdentity: string + streamIdentity: string + streamScopeKey: string +} { + const { + activeHandleRef, + activeSessionTab, + activeSessionTabId, + hostId, + nativeChatTranscriptIsLocalReadable, + worktreeId + } = args + const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId }) + const tabWantsChat = + activeSessionTab?.type === 'agent-session' || + (activeSessionTabId ? isTabChatView(activeSessionTabId) : false) + const activeChatResolution = + activeSessionTab && activeSessionTabId && tabWantsChat + ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) + : null + const showNativeChat = activeChatResolution != null + const showNativeChatRef = useRef(showNativeChat) + const activeChatAgent = activeChatResolution?.agent ?? null + const activeChatAgentRef = useRef(activeChatAgent) + + useLayoutEffect(() => { + showNativeChatRef.current = showNativeChat + activeChatAgentRef.current = activeChatAgent + }, [activeChatAgent, showNativeChat]) + + const activeChatSessionId = activeChatResolution?.sessionId ?? null + const activeChatStructured = + activeChatResolution != null && activeSessionTab?.type === 'agent-session' + const activeTabStatus = activeSessionTab?.agentStatus + const activeTabAgentWorking = + activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring' + const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null + const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` + const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + const providerSessionId = activeSessionTab?.agentStatus?.providerSession?.id ?? '' + const streamScopeKey = `${routeKey}\0${activeChatSessionId ?? providerSessionId}\0${activeHandleRef.current ?? ''}` + + return { + isTabChatView, + toggleTabChatView, + showNativeChat, + showNativeChatRef, + activeChatAgent, + activeChatAgentRef, + activeChatSessionId, + activeChatStructured, + activeChatResolution, + activeTabAgentWorking, + nativeChatStatus, + sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), + streamIdentity, + streamScopeKey + } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 40937adc0e0..83f8075d914 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -1,6 +1,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -14,6 +15,55 @@ const holdUnconfirmedSend = vi.fn() // and transcript state; defaults keep the send-seam tests unchanged. const viewMode = { isTabChatView: (_tabId: string) => true } const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false } +const structuredSendWithOutcome = vi.fn() +const structuredCancel = vi.fn() +const structuredRespondPermission = vi.fn(async () => true) +const structuredRespondQuestion = vi.fn(async () => true) +const structuredSetOption = vi.fn(async () => true) +const structuredInvokeOption = vi.fn(async () => true) +const structuredOptionSnapshot: SessionOptionDescriptor[] = [ + { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'gpt-fast', + choices: [{ value: 'gpt-fast', label: 'GPT Fast' }] + }, + valueSource: 'reported', + settable: true + } +] +const structuredOptionSurface = { + getSnapshot: () => structuredOptionSnapshot, + setOption: async () => ({ snapshot: structuredOptionSnapshot }), + invokeAction: async () => ({ snapshot: structuredOptionSnapshot }), + subscribe: () => () => {} +} +const structuredPermission = { + title: 'Allow Bash?', + detail: 'rm -rf build', + options: [ + { label: 'Allow once', send: 'allow-once' }, + { label: 'Deny', send: 'deny' } + ] +} +const structuredQuestion = { + question: 'Pick destination', + options: ['Choice A', 'Choice B'], + allowOther: true, + optionTokens: ['choice-a', 'choice-b'] +} +const structuredSessionState = { + messages: [] as unknown[], + status: 'ready', + transcriptLoading: false, + error: undefined, + hasMore: false, + loadingEarlier: false, + loadEarlier: vi.fn() +} const draftsArgs: Record[] = [] const promptsState = { permission: null as unknown, @@ -33,6 +83,24 @@ vi.mock('./use-mobile-session-view-mode', () => ({ vi.mock('./use-mobile-native-chat-session', () => ({ useMobileNativeChatSession: () => sessionState })) +vi.mock('./use-mobile-structured-agent-session', () => ({ + useMobileStructuredAgentSession: () => ({ + session: structuredSessionState, + isWorking: false, + turnId: null, + sendWithOutcome: structuredSendWithOutcome, + cancel: structuredCancel, + permission: structuredPermission, + question: structuredQuestion, + optionSnapshot: structuredOptionSnapshot, + optionSurface: structuredOptionSurface, + pendingOptionId: 'model', + respondPermission: structuredRespondPermission, + respondQuestion: structuredRespondQuestion, + setStructuredOption: structuredSetOption, + invokeStructuredOption: structuredInvokeOption + }) +})) vi.mock('./use-mobile-native-chat-drafts', () => ({ useMobileNativeChatDrafts: (args: Record) => { draftsArgs.push(args) @@ -110,18 +178,28 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { // itself is mocked above). const clientStub = { sendRequest: vi.fn() } - function Harness({ connState = 'connected' }: { connState?: ConnectionState }): null { + function Harness({ + connState = 'connected', + tab = null, + activeHandle = 'term-1', + inputLeaseReady = true + }: { + connState?: ConnectionState + tab?: unknown + activeHandle?: string | null + inputLeaseReady?: boolean + }): null { controller = useMobileNativeChatController({ client: clientStub as unknown as RpcClient, connState, hostId: 'h', worktreeId: 'w', - activeSessionTab: null, - activeSessionTabId: 'tab-1', - activeHandleRef: { current: 'term-1' }, + activeSessionTab: tab as never, + activeSessionTabId: (tab as { id?: string } | null)?.id ?? 'tab-1', + activeHandleRef: { current: activeHandle }, deviceTokenRef: { current: null }, nativeChatTranscriptIsLocalReadable: true, - nativeChatInputLeaseReady: true, + nativeChatInputLeaseReady: inputLeaseReady, onSendError, onSendResolved }) @@ -138,6 +216,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { }) resetMobileNativeChatStaleInputForTests() captureSendOrigin.mockReturnValue(ORIGIN) + structuredSendWithOutcome.mockResolvedValue('accepted') act(() => { renderer = create(createElement(Harness)) }) @@ -233,6 +312,80 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(restoreRejectedDraft).not.toHaveBeenCalled() }) + it('routes structured agent-session sends away from terminal/nativeChat transports', async () => { + await act(async () => { + renderer?.update( + createElement(Harness, { + tab: { + type: 'agent-session', + id: 'agent-tab-1', + title: 'Codex Chat', + sessionId: 'session-structured', + agent: 'codex', + isActive: true + }, + activeHandle: null, + inputLeaseReady: false + }) + ) + }) + + let accepted = false + await act(async () => { + accepted = await controller!.handleNativeChatSend('look') + }) + + expect(accepted).toBe(true) + expect(structuredSendWithOutcome).toHaveBeenCalledWith('look') + expect(sendWithOutcome).not.toHaveBeenCalled() + expect(clientStub.sendRequest).not.toHaveBeenCalled() + }) + + it('exposes structured prompt cards and session options on structured tabs', async () => { + await act(async () => { + renderer?.update( + createElement(Harness, { + tab: { + type: 'agent-session', + id: 'agent-tab-1', + title: 'Codex Chat', + sessionId: 'session-structured', + agent: 'codex', + isActive: true + }, + activeHandle: null, + inputLeaseReady: false + }) + ) + }) + + expect(controller!.nativeChatPermission).toEqual(structuredPermission) + expect(controller!.nativeChatQuestion).toEqual(structuredQuestion) + expect(controller!.nativeChatSessionOptions).not.toBeNull() + expect(controller!.nativeChatSessionOptions?.controller.snapshot).toEqual( + structuredOptionSnapshot + ) + + await act(async () => { + expect(await controller!.handleNativeChatRespondPermission('allow-once')).toBe(true) + }) + expect(structuredRespondPermission).toHaveBeenCalledWith('allow-once') + expect(sendWithOutcome).not.toHaveBeenCalled() + + await act(async () => { + expect(await controller!.handleNativeChatQuestionAnswer('choice-a')).toBe(true) + }) + expect(structuredRespondQuestion).toHaveBeenCalledWith('choice-a') + expect(clientStub.sendRequest).not.toHaveBeenCalled() + + await act(async () => { + expect( + await controller!.nativeChatSessionOptions!.controller.setOption('model', 'gpt-fast') + ).toBe(true) + }) + expect(structuredSetOption).toHaveBeenCalledWith('model', 'gpt-fast') + }) + it('pre-clears separately for a text-only send but never for an image send', async () => { // The image path pastes the image behind its OWN leading Ctrl+U and then calls // this send; a second clear here wipes the image off the input line and the diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 109dea93ec3..bf944398e87 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,9 +1,7 @@ -import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react' -import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' -import { useMobileSessionViewMode } from './use-mobile-session-view-mode' +import { useLayoutEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' -import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' +import type { MobileNativeChatTab } from './mobile-native-chat-eligibility' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' @@ -11,15 +9,17 @@ import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-as import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' -import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate' import { useMobileNativeChatSession } from './use-mobile-native-chat-session' -import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options' +import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' import { useThrottledLatestValue } from './use-throttled-latest-value' import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' +import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution' export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' @@ -58,36 +58,51 @@ export function useMobileNativeChatController(args: { onSendError, onSendResolved } = args - const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId }) - - const activeChatResolution = - activeSessionTab && activeSessionTabId && isTabChatView(activeSessionTabId) - ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) - : null - const showNativeChat = activeChatResolution != null - const showNativeChatRef = useRef(showNativeChat) - const activeChatAgent = activeChatResolution?.agent ?? null - const activeChatAgentRef = useRef(activeChatAgent) - useLayoutEffect(() => { - showNativeChatRef.current = showNativeChat - activeChatAgentRef.current = activeChatAgent - }, [activeChatAgent, showNativeChat]) - - const activeChatSessionId = activeChatResolution?.sessionId ?? null - const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` - const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` - // Same chat, but keyed off the tab rather than the view-gated resolution: - // `streamIdentity` goes session-less the moment the user peeks at the terminal, - // and a scope that flips on a view toggle throws the gate's baseline away. - const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}` - - const nativeChatSession = useMobileNativeChatSession({ - client, - sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), - agent: activeChatResolution?.agent ?? null, - sessionId: activeChatSessionId, - transcriptPath: activeChatResolution?.transcriptPath ?? null + const { + activeChatAgent, + activeChatAgentRef, + activeChatResolution, + activeChatSessionId, + activeChatStructured, + activeTabAgentWorking, + isTabChatView, + nativeChatStatus, + showNativeChat, + showNativeChatRef, + sourceIdentity, + streamIdentity, + streamScopeKey, + toggleTabChatView + } = useMobileNativeChatActiveResolution({ + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + nativeChatTranscriptIsLocalReadable }) + + const legacyNativeChatSession = useMobileNativeChatSession({ + client, + sourceIdentity, + agent: activeChatStructured ? null : (activeChatResolution?.agent ?? null), + sessionId: activeChatStructured ? null : activeChatSessionId, + transcriptPath: activeChatStructured ? null : (activeChatResolution?.transcriptPath ?? null) + }) + const structuredNativeChat = useMobileStructuredAgentSession({ + client, + sessionId: activeChatStructured ? activeChatSessionId : null, + sourceIdentity, + enabled: showNativeChat, + // Holds are connection-scoped; dropping this on transport loss lets the hook + // reacquire the provider without clearing the cached transcript. + connected: connState === 'connected', + agent: activeChatStructured ? activeChatAgent : null, + onSendError + }) + const nativeChatSession = activeChatStructured + ? structuredNativeChat.session + : legacyNativeChatSession const { composerText: chatComposerText, setComposerText: setChatComposerText, @@ -117,27 +132,29 @@ export function useMobileNativeChatController(args: { transcriptSettled: nativeChatSession.status === 'ready' }) - const activeTabStatus = activeSessionTab?.agentStatus - const activeTabAgentWorking = - activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring' - const nativeChatStatus = activeChatResolution ? activeTabStatus : null - const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking + const nativeChatAgentWorking = activeChatStructured + ? structuredNativeChat.isWorking + : activeChatResolution != null && activeTabAgentWorking // Deliberately not gated on the chat view being visible: the streaming gate // has to tell "hidden mid-turn" from "the turn ended". - const nativeChatStreamLive = activeTabAgentWorking + const nativeChatStreamLive = activeChatStructured + ? structuredNativeChat.isWorking + : activeTabAgentWorking // Throttle the streaming bubble: OpenCode emits a status frame per streamed // part, and each one re-renders and re-parses the whole accumulated markdown. const nativeChatStreamingText = useThrottledLatestValue( - mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking), + activeChatStructured + ? undefined + : mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking), NATIVE_CHAT_STREAM_THROTTLE_MS ) const { - permission: nativeChatPermission, - question: nativeChatQuestion, + permission: legacyNativeChatPermission, + question: legacyNativeChatQuestion, detectedAsk: nativeChatDetectedAsk, ask: nativeChatAskPrompt } = useMobileNativeChatPrompts({ - enabled: activeChatResolution != null, + enabled: activeChatResolution != null && !activeChatStructured, status: nativeChatStatus, messages: nativeChatSession.messages, transcriptLoading: nativeChatSession.transcriptLoading @@ -146,8 +163,6 @@ export function useMobileNativeChatController(args: { const nativeChatTranscriptSettled = nativeChatSession.status === 'ready' || (nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0) - const nativeChatAskObservable = - showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled) const { askKey: nativeChatAskKey, showAsk: showNativeChatAsk, @@ -157,17 +172,19 @@ export function useMobileNativeChatController(args: { detectedAsk: nativeChatDetectedAsk, scopeKey: activeSessionTabId, sessionKey: activeChatSessionId, - observing: nativeChatAskObservable + observing: showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled) }) // Every chat write gates on both: the lease proves the input floor is ours, and // `connState` collapses a render before the lease does on disconnect. - const inputSendable = nativeChatInputLeaseReady && connState === 'connected' + const inputSendable = activeChatStructured + ? client != null && activeChatSessionId != null && connState === 'connected' + : nativeChatInputLeaseReady && connState === 'connected' const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = useMobileNativeChatAnswerSend({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, agentRef: activeChatAgentRef, @@ -178,16 +195,16 @@ export function useMobileNativeChatController(args: { const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, cancelPending: cancelNativeChatAnswer, onSendError }) - const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ + const legacyHandleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, onSendError @@ -195,7 +212,7 @@ export function useMobileNativeChatController(args: { const handleNativeChatStop = useMobileNativeChatStop({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, streamIdentity, @@ -216,11 +233,11 @@ export function useMobileNativeChatController(args: { const { send: handleNativeChatSend, sendWithOutcome: handleNativeChatSendWithOutcome, - answerQuestion: handleNativeChatQuestionAnswer, + answerQuestion: legacyHandleNativeChatQuestionAnswer, dispatchCommand: handleNativeChatDispatchCommand } = useMobileNativeChatMessageSend({ client, - enabled: inputSendable, + enabled: inputSendable && !activeChatStructured, handleRef: activeHandleRef, deviceTokenRef, agentRef: activeChatAgentRef, @@ -234,26 +251,44 @@ export function useMobileNativeChatController(args: { onSendError }) - // Bring the terminal view forward when an agent-owned picker command is used. - const handleAgentPicker = useCallback(() => { - if (activeSessionTabId && isTabChatView(activeSessionTabId)) { - toggleTabChatView(activeSessionTabId) - } - }, [activeSessionTabId, isTabChatView, toggleTabChatView]) - - const sessionOptions = useMobileNativeChatSessionOptions({ - agent: activeChatResolution?.agent ?? null, - scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), - reportedModel: activeSessionTab?.agentStatus?.model ?? null, - dispatchCommand: handleNativeChatDispatchCommand, - onAgentPicker: handleAgentPicker + const structuredNativeChatSend = useMobileStructuredNativeChatSendBridge({ + sendStructured: structuredNativeChat.sendWithOutcome, + captureSendOrigin, + clearDraftForSend, + acceptSend, + holdUnconfirmedSend, + restoreRejectedDraft, + onSendError }) + + const { nativeChatSessionOptions, recordCommand: recordNativeChatSessionOptionCommand } = + useMobileNativeChatSessionOptionController({ + activeChatStructured, + activeSessionTabId, + agent: activeChatResolution?.agent ?? null, + dispatchCommand: handleNativeChatDispatchCommand, + hostId, + isTabChatView, + isWorking: nativeChatAgentWorking, + reportedModel: activeSessionTab?.agentStatus?.model ?? null, + structured: { + snapshot: structuredNativeChat.optionSnapshot, + pendingId: structuredNativeChat.pendingOptionId, + setOption: structuredNativeChat.setStructuredOption, + invokeAction: structuredNativeChat.invokeStructuredOption + }, + toggleTabChatView, + worktreeId + }) useLayoutEffect(() => { - recordSessionOptionCommandRef.current = sessionOptions.recordCommand - }, [sessionOptions.recordCommand]) + recordSessionOptionCommandRef.current = recordNativeChatSessionOptionCommand + }, [recordNativeChatSessionOptionCommand]) // Card actions retire the route's held failure banner too, not just sends. const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved) const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved) + const handleNativeChatRespondPermission = activeChatStructured + ? structuredNativeChat.respondPermission + : legacyHandleNativeChatRespondPermission const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved) return { @@ -272,24 +307,31 @@ export function useMobileNativeChatController(args: { nativeChatStreamingText, nativeChatStreamLive, nativeChatStreamScopeKey: streamScopeKey, - nativeChatPermission, - nativeChatQuestion, - nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null, + nativeChatPermission: activeChatStructured + ? structuredNativeChat.permission + : legacyNativeChatPermission, + nativeChatQuestion: activeChatStructured + ? structuredNativeChat.question + : legacyNativeChatQuestion, + nativeChatAsk: !activeChatStructured && showNativeChatAsk ? nativeChatAskPrompt : null, nativeChatAskKey, dismissNativeChatAsk, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, handleNativeChatRespondPermission: respond, - handleNativeChatStop, + handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, nativeChatFilePaths, loadNativeChatFiles, - handleNativeChatQuestionAnswer, - handleNativeChatSend, - handleNativeChatSendWithOutcome, + handleNativeChatQuestionAnswer: activeChatStructured + ? structuredNativeChat.respondQuestion + : legacyHandleNativeChatQuestionAnswer, + handleNativeChatSend: activeChatStructured + ? structuredNativeChatSend.send + : handleNativeChatSend, + handleNativeChatSendWithOutcome: activeChatStructured + ? structuredNativeChatSend.sendWithOutcome + : handleNativeChatSendWithOutcome, readSeededLaunchDraft, - nativeChatSessionOptions: - sessionOptions.snapshot.length > 0 - ? { controller: sessionOptions, isWorking: nativeChatAgentWorking } - : null + nativeChatSessionOptions } } diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index dbcb97df527..77d839adf34 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -1,18 +1,17 @@ import { useCallback, useRef, useState } from 'react' -import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' -import { - ImageLibraryPermissionError, - pickMobileImages, - type MobileImageSource -} from './mobile-image-source-picker' +import type { MobileImageSource } from './mobile-image-source-picker' import { appendPendingNativeChatImages, - uploadMobileNativeChatImages, type PendingNativeChatImage } from './mobile-native-chat-image-attachment' +import { + NO_NATIVE_CHAT_IMAGE_ATTACHMENTS, + withScopeAttachments, + type MobileNativeChatImagesByScope +} from './mobile-native-chat-image-scope-state' import { MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS, pasteMobileNativeChatImagePaths @@ -31,6 +30,7 @@ import { acquireMobileNativeChatTerminalWrite, releaseMobileNativeChatTerminalWrite } from './mobile-native-chat-terminal-write-lock' +import { useMobileNativeChatImageUpload } from './use-mobile-native-chat-image-upload' type CurrentRef = { readonly current: T } type ShowToast = (message: string, durationMs?: number) => void @@ -60,8 +60,11 @@ type Args = { readonly baseSend: ( text: string, imagePreviewUris?: string[], - deadline?: number + deadline?: number, + attachments?: readonly PendingNativeChatImage[] ) => Promise + /** Structured sessions send attachments without the terminal paste path. */ + readonly structuredNativeChat: boolean /** Launch-context text parked on the agent's TUI input line, or null. The * paste's leading clear must cover every line of it, or the draft's earlier * lines survive and ride along with the image. */ @@ -83,21 +86,6 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } -const NO_ATTACHMENTS: PendingNativeChatImage[] = [] - -function withScopeAttachments( - byScope: Record, - scope: string, - next: PendingNativeChatImage[] -): Record { - if (next.length > 0) { - return { ...byScope, [scope]: next } - } - const remaining = { ...byScope } - delete remaining[scope] - return remaining -} - const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) @@ -112,98 +100,40 @@ export function useMobileNativeChatImageAttachments({ showToast, onSendError, baseSend, + structuredNativeChat, readSeededLaunchDraft, onAttachSuccess, onError, sleep = defaultSleep }: Args): MobileNativeChatImageAttachments { - const [attachmentsByScope, setAttachmentsByScope] = useState< - Record - >({}) - const [isAttaching, setIsAttaching] = useState(false) + const [attachmentsByScope, setAttachmentsByScope] = useState({}) const idCounter = useRef(0) - // Count in-flight uploads so an overlapping attach can't clear the flag early. - const attachingCount = useRef(0) - // Live connState for attachImage's catch: the closure's value was already - // checked 'connected' at entry, so only a ref can see a mid-upload disconnect. - const connStateRef = useRef(connState) - connStateRef.current = connState + const attachments = + (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS - const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS - - const attachImage = useCallback( - async (source: MobileImageSource): Promise => { - // The chip lands in the scope that initiated the pick, even if the user - // switches tabs while the upload is in flight. - const scope = scopeKey - if (!client || !scope || !activeHandleRef.current || connState !== 'connected') { - return - } - // Only this call's own increment may be undone in `finally`; a cancelled - // pick or pre-upload error never ran `onUploadStart`, so decrementing the - // shared counter would clear a concurrent upload's in-flight flag early. - let started = false - const uploadedImages: Omit[] = [] - let uploadError: unknown = null - try { - await uploadMobileNativeChatImages(source, { - client, - getConnectionId: getActiveWorktreeConnectionId, - pickImages: pickMobileImages, - onImageUploaded: (image) => uploadedImages.push(image), - onUploadStart: () => { - started = true - attachingCount.current += 1 - setIsAttaching(true) - } - }) - } catch (error) { - uploadError = error - } finally { - if (started) { - attachingCount.current -= 1 - if (attachingCount.current === 0) { - setIsAttaching(false) - } - } - } - if (uploadedImages.length > 0) { - setAttachmentsByScope((prev) => ({ - ...prev, - [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) - })) - onAttachSuccess?.() - } - if (uploadError !== null) { - const message = uploadError instanceof Error ? uploadError.message : String(uploadError) - onError?.() - if (connStateRef.current !== 'connected') { - showToast('Attach failed (disconnected)', 1500) - return - } - if (uploadError instanceof ImageLibraryPermissionError) { - showToast('Photo permission denied', 1500) - return - } - if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { - showToast('Image too large to attach', 1500) - return - } - showToast('Attach failed', 1500) - } + const addUploadedImages = useCallback( + (scope: string, uploadedImages: Omit[]) => { + setAttachmentsByScope((prev) => ({ + ...prev, + [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) + })) }, - [ - activeHandleRef, - client, - connState, - getActiveWorktreeConnectionId, - onAttachSuccess, - onError, - scopeKey, - showToast - ] + [] ) + const { attachImage, isAttaching } = useMobileNativeChatImageUpload({ + client, + activeHandleRef, + getActiveWorktreeConnectionId, + connState, + scopeKey, + structuredNativeChat, + showToast, + onImagesUploaded: addUploadedImages, + onAttachSuccess, + onError + }) + const removeAttachment = useCallback( (id: string): void => { const scope = scopeKey @@ -238,7 +168,32 @@ export function useMobileNativeChatImageAttachments({ const deadline = openMobileNativeChatSendBudget() try { const scope = scopeKey - const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS + const pendingImages = + (scope ? attachmentsByScope[scope] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS + if (structuredNativeChat && pendingImages.length > 0 && scope) { + if (!client || !enabled || connState !== 'connected') { + onError?.() + onSendError('Message not sent (disconnected)') + return false + } + const outcome = await baseSend( + text, + pendingImages.map((attachment) => attachment.previewUri), + deadline, + pendingImages + ) + if (outcome !== 'rejected') { + const sentIds = new Set(pendingImages.map((attachment) => attachment.id)) + setAttachmentsByScope((prev) => + withScopeAttachments( + prev, + scope, + (prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id)) + ) + ) + } + return outcome !== 'rejected' + } if (pendingImages.length === 0 || !scope) { // Heal a previously failed paste: a text-only send to that terminal would // otherwise glue the stale image paste onto this message. Best-effort — diff --git a/mobile/src/session/use-mobile-native-chat-image-upload.ts b/mobile/src/session/use-mobile-native-chat-image-upload.ts new file mode 100644 index 00000000000..01567b5c727 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-image-upload.ts @@ -0,0 +1,126 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { + ImageLibraryPermissionError, + pickMobileImages, + type MobileImageSource +} from './mobile-image-source-picker' +import { + uploadMobileNativeChatImages, + type PendingNativeChatImage +} from './mobile-native-chat-image-attachment' + +type CurrentRef = { readonly current: T } +type UploadedNativeChatImage = Omit +type ShowToast = (message: string, durationMs?: number) => void + +export function useMobileNativeChatImageUpload(args: { + client: RpcClient | null + activeHandleRef: CurrentRef + getActiveWorktreeConnectionId: () => Promise + connState: ConnectionState + scopeKey: string | null + structuredNativeChat: boolean + showToast: ShowToast + onImagesUploaded: (scope: string, images: UploadedNativeChatImage[]) => void + onAttachSuccess?: () => void + onError?: () => void +}): { + attachImage: (source: MobileImageSource) => Promise + isAttaching: boolean +} { + const { + activeHandleRef, + client, + connState, + getActiveWorktreeConnectionId, + onAttachSuccess, + onError, + onImagesUploaded, + scopeKey, + showToast, + structuredNativeChat + } = args + const [isAttaching, setIsAttaching] = useState(false) + const attachingCount = useRef(0) + const connStateRef = useRef(connState) + useLayoutEffect(() => { + connStateRef.current = connState + }, [connState]) + + const attachImage = useCallback( + async (source: MobileImageSource): Promise => { + const scope = scopeKey + if ( + !client || + !scope || + connState !== 'connected' || + (!activeHandleRef.current && !structuredNativeChat) + ) { + return + } + let started = false + const uploadedImages: UploadedNativeChatImage[] = [] + let uploadError: unknown = null + try { + await uploadMobileNativeChatImages(source, { + client, + getConnectionId: getActiveWorktreeConnectionId, + pickImages: pickMobileImages, + onImageUploaded: (image) => uploadedImages.push(image), + onUploadStart: () => { + started = true + attachingCount.current += 1 + setIsAttaching(true) + } + }) + } catch (error) { + uploadError = error + } finally { + if (started) { + attachingCount.current -= 1 + if (attachingCount.current === 0) { + setIsAttaching(false) + } + } + } + if (uploadedImages.length > 0) { + onImagesUploaded(scope, uploadedImages) + onAttachSuccess?.() + } + if (uploadError !== null) { + const message = uploadError instanceof Error ? uploadError.message : String(uploadError) + onError?.() + if (connStateRef.current !== 'connected') { + showToast('Attach failed (disconnected)', 1500) + return + } + if (uploadError instanceof ImageLibraryPermissionError) { + showToast('Photo permission denied', 1500) + return + } + if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { + showToast('Image too large to attach', 1500) + return + } + showToast('Attach failed', 1500) + } + }, + [ + activeHandleRef, + client, + connState, + getActiveWorktreeConnectionId, + onAttachSuccess, + onError, + onImagesUploaded, + scopeKey, + showToast, + structuredNativeChat + ] + ) + + return { attachImage, isAttaching } +} diff --git a/mobile/src/session/use-mobile-native-chat-session-option-controller.ts b/mobile/src/session/use-mobile-native-chat-session-option-controller.ts new file mode 100644 index 00000000000..aa61bdd85ff --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session-option-controller.ts @@ -0,0 +1,100 @@ +import { useCallback, useMemo } from 'react' +import type { + SessionOptionDescriptor, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' +import { + useMobileNativeChatSessionOptions, + type MobileNativeChatSessionOptionsController +} from './use-mobile-native-chat-session-options' + +export function useMobileNativeChatSessionOptionController(args: { + activeChatStructured: boolean + activeSessionTabId: string | null + agent: string | null + dispatchCommand: (text: string) => Promise + hostId: string + isTabChatView: (tabId: string) => boolean + isWorking: boolean + reportedModel: string | null + structured: { + snapshot: SessionOptionDescriptor[] + pendingId: string | null + setOption: (id: string, value: SessionOptionValue) => Promise + invokeAction: (id: string) => Promise + } + toggleTabChatView: (tabId: string) => void + worktreeId: string +}): { + nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null + recordCommand: (command: string) => void +} { + const { + activeChatStructured, + activeSessionTabId, + agent, + dispatchCommand, + hostId, + isTabChatView, + isWorking, + reportedModel, + structured, + toggleTabChatView, + worktreeId + } = args + const { + invokeAction: invokeStructuredAction, + pendingId: structuredPendingId, + setOption: setStructuredOption, + snapshot: structuredSnapshot + } = structured + + const handleAgentPicker = useCallback(() => { + if (activeSessionTabId && isTabChatView(activeSessionTabId)) { + toggleTabChatView(activeSessionTabId) + } + }, [activeSessionTabId, isTabChatView, toggleTabChatView]) + + const sessionOptions = useMobileNativeChatSessionOptions({ + agent: activeChatStructured ? null : agent, + scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), + reportedModel, + dispatchCommand, + onAgentPicker: handleAgentPicker + }) + const structuredController = useMemo( + () => + activeChatStructured && structuredSnapshot.length > 0 + ? { + snapshot: structuredSnapshot, + pendingId: structuredPendingId, + setOption: setStructuredOption, + invokeAction: invokeStructuredAction, + recordCommand: () => {} + } + : null, + [ + activeChatStructured, + invokeStructuredAction, + setStructuredOption, + structuredPendingId, + structuredSnapshot + ] + ) + const nativeChatSessionOptions = useMemo( + () => + activeChatStructured + ? structuredController + ? { controller: structuredController, isWorking } + : null + : sessionOptions.snapshot.length > 0 + ? { controller: sessionOptions, isWorking } + : null, + [activeChatStructured, isWorking, sessionOptions, structuredController] + ) + + return { nativeChatSessionOptions, recordCommand: sessionOptions.recordCommand } +} diff --git a/mobile/src/session/use-mobile-session-attachments.ts b/mobile/src/session/use-mobile-session-attachments.ts index 66691545118..841d3984b8f 100644 --- a/mobile/src/session/use-mobile-session-attachments.ts +++ b/mobile/src/session/use-mobile-session-attachments.ts @@ -36,7 +36,8 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti nativeChatInputLeaseReady, nativeChatController, getActiveWorktreeConnectionId, - refreshCanPaste + refreshCanPaste, + activeSessionTab } = scope const handlePaste = useMobileTerminalPaste({ client, @@ -80,6 +81,7 @@ export function useMobileSessionAttachments(scope: MobileSessionAccessorySelecti getActiveWorktreeConnectionId, beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome, + structuredNativeChat: activeSessionTab?.type === 'agent-session', readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft, showToast, onNativeChatSendError: nativeChatSendError.show, diff --git a/mobile/src/session/use-mobile-session-file-actions.ts b/mobile/src/session/use-mobile-session-file-actions.ts index 7ba21770a21..56aa2b049d8 100644 --- a/mobile/src/session/use-mobile-session-file-actions.ts +++ b/mobile/src/session/use-mobile-session-file-actions.ts @@ -1,6 +1,7 @@ import { useRef, useCallback } from 'react' import { Linking } from 'react-native' import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers' +import { resolveMobileNativeChatFileSessionId } from './mobile-native-chat-eligibility' import { activateOpenedSourceControlDiffTab } from './opened-mobile-session-tab' import type { MobileSessionTab } from './mobile-session-route-types' import type { MobileSessionTerminalSendActionsModel } from './use-mobile-session-terminal-send-actions' @@ -31,10 +32,7 @@ export function useMobileSessionFileActions(scope: MobileSessionTerminalSendActi hostId, worktreeId, worktreeName: routeWorktreeName, - nativeChatSessionId: - activeSessionTab?.type === 'terminal' - ? (activeSessionTab.agentStatus?.providerSession?.id ?? null) - : null, + nativeChatSessionId: resolveMobileNativeChatFileSessionId(activeSessionTab), activeHandleRef, terminalCwdRef, openBrowser: (url) => void handleCreateBrowserRef.current?.(url), diff --git a/mobile/src/session/use-mobile-session-foundation.ts b/mobile/src/session/use-mobile-session-foundation.ts index 9a7889e10cb..fa2f9607bbc 100644 --- a/mobile/src/session/use-mobile-session-foundation.ts +++ b/mobile/src/session/use-mobile-session-foundation.ts @@ -35,7 +35,7 @@ export function useMobileSessionFoundation() { const router = useRouter() const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md). - const { client, state: connState } = useHostClient(hostId) + const { client, clientId, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) const forceReconnectHost = useForceReconnect() @@ -96,6 +96,7 @@ export function useMobileSessionFoundation() { router, insets, client, + clientId, connState, reconnectAttempts, lastConnectedAt, diff --git a/mobile/src/session/use-mobile-session-image-attachments.test.tsx b/mobile/src/session/use-mobile-session-image-attachments.test.tsx new file mode 100644 index 00000000000..68aeafaea6f --- /dev/null +++ b/mobile/src/session/use-mobile-session-image-attachments.test.tsx @@ -0,0 +1,123 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileSessionImageAttachments } from './use-mobile-session-image-attachments' + +const mocks = vi.hoisted(() => ({ + useMobileImageAttachment: vi.fn(), + useMobileNativeChatImageAttachments: vi.fn() +})) + +vi.mock('./use-mobile-image-attachment', () => ({ + useMobileImageAttachment: mocks.useMobileImageAttachment +})) + +vi.mock('./use-mobile-native-chat-image-attachments', () => ({ + useMobileNativeChatImageAttachments: mocks.useMobileNativeChatImageAttachments +})) + +type HookArgs = Parameters[0] + +function baseArgs(overrides: Partial = {}): HookArgs { + return { + client: {} as RpcClient, + activeHandle: 'term-1', + activeHandleRef: { current: null }, + canSend: true, + connState: 'connected', + deviceTokenRef: { current: null }, + nativeChatScopeKey: 'scope-1', + nativeChatInputLeaseReady: false, + getActiveWorktreeConnectionId: async () => 'conn-1', + beforeTerminalSend: async () => true, + nativeChatBaseSend: vi.fn().mockResolvedValue('accepted'), + structuredNativeChat: true, + readSeededLaunchDraft: () => null, + showToast: vi.fn(), + onNativeChatSendError: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), + ...overrides + } +} + +describe('useMobileSessionImageAttachments', () => { + let renderer: ReactTestRenderer | null = null + + function Harness({ args }: { args: HookArgs }): null { + useMobileSessionImageAttachments(args) + return null + } + + beforeEach(() => { + mocks.useMobileImageAttachment.mockReturnValue({ + attachImage: vi.fn(), + isAttaching: false + }) + mocks.useMobileNativeChatImageAttachments.mockReturnValue({ + attachments: [], + isAttaching: false, + attachImage: vi.fn(), + removeAttachment: vi.fn(), + sendNativeChat: vi.fn() + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.clearAllMocks() + }) + + function render(args: HookArgs): void { + act(() => { + renderer = create(createElement(Harness, { args })) + }) + } + + it('enables native-chat image sends for connected structured sessions without a terminal lease', () => { + render(baseArgs()) + + expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + structuredNativeChat: true + }) + ) + }) + + it('keeps terminal-backed native-chat image sends gated on the input lease', () => { + render( + baseArgs({ + activeHandleRef: { current: 'term-1' }, + nativeChatInputLeaseReady: false, + structuredNativeChat: false + }) + ) + + expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + structuredNativeChat: false + }) + ) + }) + + it('disables structured native-chat image sends while disconnected', () => { + render( + baseArgs({ + connState: 'connecting', + nativeChatInputLeaseReady: true, + structuredNativeChat: true + }) + ) + + expect(mocks.useMobileNativeChatImageAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + structuredNativeChat: true + }) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index 9b5a010df9f..07edac51f39 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -29,8 +29,15 @@ type Args = { readonly nativeChatBaseSend: ( text: string, images?: string[], - deadline?: number + deadline?: number, + attachments?: readonly { + id: string + path: string + previewUri: string + }[] ) => Promise + /** Structured agent sessions do not have a terminal paste path. */ + readonly structuredNativeChat: boolean /** Launch-context text parked on the agent's TUI input line, or null — sizes * the image paste's leading clear so a multi-line draft cannot ride along. */ readonly readSeededLaunchDraft: () => string | null @@ -57,6 +64,7 @@ export function useMobileSessionImageAttachments({ getActiveWorktreeConnectionId, beforeTerminalSend, nativeChatBaseSend, + structuredNativeChat, readSeededLaunchDraft, showToast, onNativeChatSendError, @@ -86,7 +94,8 @@ export function useMobileSessionImageAttachments({ getActiveWorktreeConnectionId, connState, scopeKey: nativeChatScopeKey, - enabled: nativeChatInputLeaseReady, + enabled: structuredNativeChat ? connState === 'connected' : nativeChatInputLeaseReady, + structuredNativeChat, showToast, onSendError: onNativeChatSendError, baseSend: nativeChatBaseSend, diff --git a/mobile/src/session/use-mobile-session-lifecycle.ts b/mobile/src/session/use-mobile-session-lifecycle.ts index 912ebdb4921..7c58e84a3e5 100644 --- a/mobile/src/session/use-mobile-session-lifecycle.ts +++ b/mobile/src/session/use-mobile-session-lifecycle.ts @@ -16,7 +16,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM connState, setCustomKeys, setVisibleBuiltInIds, - deviceTokenRef, setHostEndpoint, connStateRef, terminalRefs, @@ -26,7 +25,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM unsubscribeTerminal, subscribeToTerminal } = scope - // Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity. + // Why: the shared client owns authenticated identity; this host read only supplies connection-hint metadata. useEffect(() => { if (!hostId) { return @@ -38,7 +37,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM } const host = hosts.find((h) => h.id === hostId) if (host) { - deviceTokenRef.current = host.deviceToken setHostEndpoint(host.endpoint) } }) diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts index 7942233dbfd..6046cba1059 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -77,6 +77,12 @@ export function useMobileSessionNativeChatDictation( }) const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController nativeChatSendError.bannerMountedRef.current = showNativeChat + const nativeChatOverlayInputLockReason = + activeSessionTab?.type === 'agent-session' + ? connState === 'connected' + ? null + : 'disconnected' + : nativeChatInputLockReason const routeKey = nativeChatScopeKey ?? `${hostId}\0${worktreeId}` const getSendCompletionGeneration = useMobileSendCompletionGeneration({ onBlur: resetLiveInputFocus, @@ -211,6 +217,7 @@ export function useMobileSessionNativeChatDictation( nativeChatInputLeaseReady, nativeChatInputLeaseReadyRef, nativeChatInputLockReason, + nativeChatOverlayInputLockReason, markNativeChatInputLeaseReady, clearNativeChatInputLease, nativeChatController, diff --git a/mobile/src/session/use-mobile-session-screen-state.ts b/mobile/src/session/use-mobile-session-screen-state.ts index 107ac3181de..6e2f82124b3 100644 --- a/mobile/src/session/use-mobile-session-screen-state.ts +++ b/mobile/src/session/use-mobile-session-screen-state.ts @@ -23,6 +23,7 @@ import type { MobileSessionTab, Terminal } from './mobile-session-route-types' +import { useMobileSessionTabActionTargets } from './use-mobile-session-tab-action-targets' import type { MobileSessionFoundationModel } from './use-mobile-session-foundation' export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) { @@ -90,19 +91,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) const [createTabAgentOptions, setCreateTabAgentOptions] = useState([]) const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false) const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false) - const [actionTarget, setActionTarget] = useState(null) - const [markdownActionTarget, setMarkdownActionTarget] = useState | null>(null) - const [fileActionTarget, setFileActionTarget] = useState | null>(null) - const [browserActionTarget, setBrowserActionTarget] = useState | null>(null) + const sessionTabActionTargets = useMobileSessionTabActionTargets() const [discardMarkdownTarget, setDiscardMarkdownTarget] = useState +type FileTab = Extract +type BrowserTab = Extract +type AgentSessionTab = Extract +type SetActionTarget = Dispatch> + +export function useMobileSessionTabActionTargets() { + const [actionTarget, setActionTarget] = useState(null) + const [markdownActionTarget, setMarkdownActionTarget] = useState(null) + const [fileActionTarget, setFileActionTarget] = useState(null) + const [browserActionTarget, setBrowserActionTarget] = useState(null) + const [agentSessionActionTarget, setAgentSessionActionTarget] = useState( + null + ) + + return { + actionTarget, + agentSessionActionTarget, + browserActionTarget, + fileActionTarget, + markdownActionTarget, + setActionTarget, + setAgentSessionActionTarget, + setBrowserActionTarget, + setFileActionTarget, + setMarkdownActionTarget + } +} + +export function useMobileSessionTabActionSheetOpener(args: { + activeHandleRef: MutableRefObject + setActionTarget: SetActionTarget + setMarkdownActionTarget: SetActionTarget + setFileActionTarget: SetActionTarget + setBrowserActionTarget: SetActionTarget + setAgentSessionActionTarget: SetActionTarget +}): (tab: MobileSessionTab) => void { + const { + activeHandleRef, + setActionTarget, + setAgentSessionActionTarget, + setBrowserActionTarget, + setFileActionTarget, + setMarkdownActionTarget + } = args + return useCallback( + (tab: MobileSessionTab) => { + if (tab.type === 'terminal') { + if (typeof tab.terminal !== 'string') { + return + } + setActionTarget({ + handle: tab.terminal, + title: tab.title, + isActive: tab.terminal === activeHandleRef.current + }) + } else if (tab.type === 'markdown') { + setMarkdownActionTarget(tab) + } else if (tab.type === 'file') { + setFileActionTarget(tab) + } else if (tab.type === 'agent-session') { + setAgentSessionActionTarget(tab) + } else { + setBrowserActionTarget(tab) + } + }, + [ + activeHandleRef, + setActionTarget, + setAgentSessionActionTarget, + setBrowserActionTarget, + setFileActionTarget, + setMarkdownActionTarget + ] + ) +} diff --git a/mobile/src/session/use-mobile-session-tab-switching.ts b/mobile/src/session/use-mobile-session-tab-switching.ts index 21095b613dd..48c48c2f994 100644 --- a/mobile/src/session/use-mobile-session-tab-switching.ts +++ b/mobile/src/session/use-mobile-session-tab-switching.ts @@ -136,6 +136,9 @@ export function useMobileSessionTabSwitching(scope: MobileSessionKeyboardStateMo void readFileTab(tab) return } + if (tab.type === 'agent-session') { + return + } const cached = markdownDocs.get(tab.id) if (cached?.status === 'ready' && cached.isDirty) { return diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts new file mode 100644 index 00000000000..c0a4e8368c5 --- /dev/null +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.test.ts @@ -0,0 +1,231 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions' + +vi.mock('../platform/haptics', () => ({ + triggerSuccess: vi.fn(), + triggerError: vi.fn() +})) + +function clientReturning(...responses: unknown[]): RpcClient { + let responseIndex = 0 + return { + sendRequest: vi.fn(async () => responses[responseIndex++]) + } as unknown as RpcClient +} + +function terminalCreateResponse() { + return { + ok: true, + result: { + tab: { + type: 'terminal', + id: 'terminal-tab-1', + title: 'Codex', + terminal: 'terminal-1', + isActive: true + } + } + } +} + +function createScope(client: RpcClient) { + return { + worktreeId: 'workspace-1', + client, + connState: 'connected', + setTerminals: vi.fn(), + terminalsRef: { current: [] }, + setSessionTabs: vi.fn(), + defaultTerminalHandlesToLiveInput: vi.fn(), + setActiveHandle: vi.fn(), + activeSessionTabId: 'existing-tab', + activeSessionTabIdRef: { current: 'existing-tab' }, + setActiveSessionTabId: vi.fn(), + setCreating: vi.fn(), + creatingTerminalRef: { current: false }, + creatingBrowser: false, + creatingMarkdown: false, + setCreateError: vi.fn(), + deviceTokenRef: { current: null }, + initializedHandlesRef: { current: new Set() }, + activeHandleRef: { current: 'existing-terminal' }, + activeSessionTabTypeRef: { current: 'terminal' }, + pendingActiveSessionTabIdRef: { current: null }, + pendingActiveTerminalHandleRef: { current: null }, + scheduleDelayedAction: vi.fn(), + showToast: vi.fn(), + unsubscribeTerminal: vi.fn(), + subscribeToTerminal: vi.fn(), + fetchSessionTabs: vi.fn(async () => {}) + } +} + +describe('mobile + Codex tab creation routing', () => { + let renderer: ReactTestRenderer | undefined + afterEach(() => renderer?.unmount()) + + it('uses the structured agent-session path for a bare Codex launch', async () => { + const client = clientReturning( + { ok: true, result: { supported: true } }, + { + ok: true, + result: { + ok: true, + value: { sessionId: 'codex_session_1' } + } + } + ) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal('codex') + }) + + expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', { + worktree: 'id:workspace-1', + agent: 'codex' + }) + expect(client.sendRequest).toHaveBeenNthCalledWith( + 2, + 'agentSession.create', + expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }), + expect.anything() + ) + expect(client.sendRequest).not.toHaveBeenCalledWith( + 'session.tabs.createTerminal', + expect.anything() + ) + expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('agent-session:codex_session_1') + expect(scope.setActiveHandle).toHaveBeenCalledWith(null) + expect(scope.unsubscribeTerminal).toHaveBeenCalledWith('existing-terminal') + }) + + it('keeps the legacy terminal path when structured support is disabled', async () => { + const client = clientReturning( + { ok: false, error: { code: 'structured_agent_session_unsupported', message: 'off' } }, + terminalCreateResponse() + ) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal('codex') + }) + + expect(client.sendRequest).toHaveBeenNthCalledWith( + 2, + 'session.tabs.createTerminal', + expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }) + ) + expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1') + }) + + it('falls back to a terminal when structured creation is refused', async () => { + const client = clientReturning( + { ok: true, result: { supported: true } }, + { + ok: true, + result: { + ok: false, + refusal: { code: 'agent_session_ownership_unknown', message: 'provider unavailable' } + } + }, + terminalCreateResponse() + ) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal('codex') + }) + + expect(client.sendRequest).toHaveBeenNthCalledWith( + 3, + 'session.tabs.createTerminal', + expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }) + ) + expect(scope.setActiveSessionTabId).toHaveBeenCalledWith('terminal-tab-1') + }) + + it('keeps prompted Codex launches on the legacy terminal path', async () => { + const client = clientReturning(terminalCreateResponse(), { + ok: true, + result: { send: { accepted: true } } + }) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal('codex', { initialPrompt: 'Inspect this diff' }) + }) + + expect(client.sendRequest).toHaveBeenCalledWith( + 'session.tabs.createTerminal', + expect.objectContaining({ agent: 'codex' }) + ) + expect(client.sendRequest).not.toHaveBeenCalledWith( + 'agentSession.createSupport', + expect.anything() + ) + }) + + it('does not create a legacy sibling after an unknown structured outcome', async () => { + const client = clientReturning({ ok: true, result: { supported: true } }) + const sendRequest = client.sendRequest as unknown as ReturnType + sendRequest.mockImplementationOnce(async () => ({ + ok: true, + result: { supported: true } + })) + sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost'))) + sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('still unknown'))) + const scope = createScope(client) + let actions: ReturnType | undefined + function Harness() { + actions = useMobileSessionTerminalCreateActions(scope as never) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) + await act(async () => { + await actions?.handleCreateTerminal('codex') + }) + + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.create' + ]) + expect(scope.setCreateError).toHaveBeenCalledWith('still unknown') + expect(scope.showToast).toHaveBeenCalledWith('still unknown', 1800) + }) +}) diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.ts index 895b9a5a256..0ccd3591011 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.ts @@ -10,6 +10,7 @@ import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options' import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types' import type { Terminal, TerminalCreateResult } from './mobile-session-route-types' import type { MobileSessionAttachmentsModel } from './use-mobile-session-attachments' +import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch' export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttachmentsModel) { const { @@ -22,6 +23,7 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach defaultTerminalHandlesToLiveInput, setActiveHandle, activeSessionTabId, + activeSessionTabIdRef, setActiveSessionTabId, setCreating, creatingTerminalRef, @@ -61,6 +63,35 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach .slice(2, 10)}` try { + // Bare Codex launches follow structured support; prompted launches keep their startup semantics. + if (agent === 'codex' && options === undefined) { + const structured = await createMobileStructuredCodexSession(client, worktreeId) + if (structured.kind === 'created') { + const previous = activeHandleRef.current + if (previous) { + unsubscribeTerminal(previous) + initializedHandlesRef.current.delete(previous) + } + const tabId = `agent-session:${structured.sessionId}` + pendingActiveSessionTabIdRef.current = tabId + pendingActiveTerminalHandleRef.current = null + activeSessionTabTypeRef.current = 'agent-session' + activeSessionTabIdRef.current = tabId + setActiveSessionTabId(tabId) + activeHandleRef.current = null + setActiveHandle(null) + // Refresh if the create response beats its published tab frame. + scheduleDelayedAction(() => void fetchSessionTabs(), 500) + return + } + if (structured.kind === 'unknown') { + // Never create a legacy sibling when the host may already have committed. + setCreateError(structured.message) + triggerError() + showToast(structured.message, 1800) + return + } + } const response = await client.sendRequest('session.tabs.createTerminal', { worktree: `id:${worktreeId}`, afterTabId: activeSessionTabId ?? undefined, diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts index 8ef472fb845..5086efe1ba1 100644 --- a/mobile/src/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts @@ -27,6 +27,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM worktreeId, connState, client, + clientId, sessionTabs, setLiveInputCapture, liveInputTerminalHandles, @@ -42,7 +43,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM const terminalGestureInputInFlightRef = useRef>(new Set()) const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) - const deviceTokenRef = useRef(null) + const deviceTokenRef = useRef(clientId) + // Keep the authenticated identity synchronous with the client exposed to downstream hooks. + deviceTokenRef.current = clientId // Why: state (not a ref) so the connection verdict re-renders when the endpoint loads and the Tailscale hint can appear. const [hostEndpoint, setHostEndpoint] = useState(null) const clientRef = useRef(null) @@ -123,11 +126,13 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM sendLiveTerminalInputRef, setLiveInputCapture }) - const { canCompose, canSend } = resolveMobileTerminalInputGate({ + const inputGate = resolveMobileTerminalInputGate({ connState, activeHandle, activeSessionTabType: activeSessionTab?.type }) + const canCompose = inputGate.canCompose + const canSend = inputGate.canSend && clientId !== null const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({ activeHandleRef, diff --git a/mobile/src/session/use-mobile-session-terminal-send-actions.ts b/mobile/src/session/use-mobile-session-terminal-send-actions.ts index c80edee9271..6909f71ca63 100644 --- a/mobile/src/session/use-mobile-session-terminal-send-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-send-actions.ts @@ -16,6 +16,7 @@ import { import { normalizeTerminalTextInput } from '../terminal/terminal-text-input-normalization' import { useAgentSendKeyboardDismissal } from './use-agent-send-keyboard-dismissal' import type { MobileSessionTab } from './mobile-session-route-types' +import { useMobileSessionTabActionSheetOpener } from './use-mobile-session-tab-action-targets' import type { MobileSessionTerminalWebviewModel } from './use-mobile-session-terminal-webview' export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminalWebviewModel) { @@ -27,6 +28,7 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal setMarkdownActionTarget, setFileActionTarget, setBrowserActionTarget, + setAgentSessionActionTarget, keyboardHeight, deviceTokenRef, clientRef, @@ -175,24 +177,14 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal sessionTabActionSheetKeyboardHideSubRef.current = null }, []) - const openSessionTabActionSheet = useCallback((tab: MobileSessionTab) => { - if (tab.type === 'terminal') { - if (typeof tab.terminal !== 'string') { - return - } - setActionTarget({ - handle: tab.terminal, - title: tab.title, - isActive: tab.terminal === activeHandleRef.current - }) - } else if (tab.type === 'markdown') { - setMarkdownActionTarget(tab) - } else if (tab.type === 'file') { - setFileActionTarget(tab) - } else { - setBrowserActionTarget(tab) - } - }, []) + const openSessionTabActionSheet = useMobileSessionTabActionSheetOpener({ + activeHandleRef, + setActionTarget, + setMarkdownActionTarget, + setFileActionTarget, + setBrowserActionTarget, + setAgentSessionActionTarget + }) const openSessionTabActionSheetAfterKeyboardDismiss = useCallback( (tab: MobileSessionTab) => { diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index 8ad0bb7383a..333d472a30e 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -15,9 +15,9 @@ export function useMobileSessionTerminalSubscription( ) { const { client, + clientId, setTerminalModes, terminalCwdRef, - deviceTokenRef, viewportRef, viewportMeasuredRef, terminalUnsubsRef, @@ -49,6 +49,10 @@ export function useMobileSessionTerminalSubscription( logSkippedGate('no-client') return } + if (clientId === null) { + logSkippedGate('no-client-identity') + return + } if (terminalUnsubsRef.current.has(handle)) { logSkippedGate('already-subscribed') return @@ -89,7 +93,7 @@ export function useMobileSessionTerminalSubscription( client, { terminal: handle, - client: { id: deviceTokenRef.current!, type: 'mobile' as const }, + client: { id: clientId, type: 'mobile' as const }, viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport( covered, viewportRef.current @@ -263,6 +267,7 @@ export function useMobileSessionTerminalSubscription( }, [ client, + clientId, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts new file mode 100644 index 00000000000..108275223be --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getAgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' +import type { + AgentSessionOptionResult, + AgentSessionOptionsResult +} from '../../../src/shared/agent-session-wire' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import { + applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOption, + commitStructuredAgentSessionOptionValues, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../../src/shared/structured-agent-session-options' +import type { RpcClient } from '../transport/rpc-client' +import { + callAgentSession, + type StructuredAgentSessionMutate +} from './mobile-structured-agent-session-rpc' + +type StructuredOptionsController = { + optionSnapshot: SessionOptionDescriptor[] + optionSurface: SessionOptionsSurface + pendingOptionId: string | null + setStructuredOption: (id: string, value: SessionOptionValue) => Promise + invokeStructuredOption: (id: string) => Promise +} + +export function useMobileStructuredAgentOptions(args: { + agent: string | null + client: RpcClient | null + sessionId: string | null + enabled: boolean + fence: number | null + mutate: StructuredAgentSessionMutate +}): StructuredOptionsController { + const { agent, client, enabled, fence, mutate, sessionId } = args + const [optionState, setOptionState] = useState(() => + createStructuredAgentSessionOptionState(agent ?? 'codex') + ) + const activeOptionRecordRef = useRef(optionState.record) + const optionCatalog = useMemo( + () => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null), + [agent] + ) + + useEffect(() => { + const next = createStructuredAgentSessionOptionState(agent ?? 'codex') + activeOptionRecordRef.current = next.record + setOptionState(next) + }, [agent, enabled, fence, sessionId]) + + useEffect(() => { + if (!client || !sessionId || !enabled || !optionCatalog) { + return + } + let stale = false + void callAgentSession(client, 'agentSession.options', { sessionId }) + .then((result) => { + if (!stale) { + setOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + : current + ) + } + }) + .catch(() => undefined) + return () => { + stale = true + } + }, [client, enabled, optionCatalog, sessionId, fence]) + + const optionSnapshot = useMemo( + () => structuredAgentSessionOptionSnapshot(optionState), + [optionState] + ) + + const setStructuredOption = useCallback( + async (id: string, value: SessionOptionValue): Promise => { + if ( + !canSetStructuredAgentSessionOption(optionState, id, value) || + typeof value !== 'string' + ) { + return false + } + const targetRecord = optionState.record + setOptionState((current) => ({ ...current, pendingId: id })) + try { + const result = await mutate( + 'agentSession.setOption', + 'agentSession.setOption', + { key: id, value } + ) + if (activeOptionRecordRef.current !== targetRecord) { + return result.status !== 'rejected' + } + if (result.status === 'accepted') { + setOptionState((current) => + current.record === targetRecord && result.sameFence + ? commitStructuredAgentSessionOptionValues( + current, + result.value.options ?? { [id]: value } + ) + : current + ) + return true + } + if (result.status === 'unknown') { + setOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOption(current, id, value) + : current + ) + return true + } + return false + } finally { + setOptionState((current) => + current.record === targetRecord && current.pendingId === id + ? { ...current, pendingId: null } + : current + ) + } + }, + [mutate, optionState] + ) + + const invokeStructuredOption = useCallback(async () => false, []) + + const setOption = useCallback( + async (id: string, value: SessionOptionValue) => { + await setStructuredOption(id, value) + return { snapshot: optionSnapshot } + }, + [optionSnapshot, setStructuredOption] + ) + + const optionSurface = useMemo( + () => ({ + getSnapshot: () => optionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: optionSnapshot }), + subscribe: () => () => {} + }), + [optionSnapshot, setOption] + ) + + return { + optionSnapshot, + optionSurface, + pendingOptionId: optionState.pendingId, + setStructuredOption, + invokeStructuredOption + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx new file mode 100644 index 00000000000..83562839363 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -0,0 +1,849 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalResolution +} from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +function ok(result: unknown) { + return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function snapshotEvent(fence = 3): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: 'session-1', + fence, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch-1', sequence: 0 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false + } + } +} + +function snapshotWithMessage(): AgentSessionSubscribeEvent { + const event = snapshotEvent() + return { + ...event, + page: { + ...event.page, + items: [ + { + itemId: 'msg-1', + revision: 1, + sequence: 1, + observedAt: 10, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'sent before the blip' }] + } + } + ], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 1 }, + nextCursor: { epoch: 'epoch-1', sequence: 2 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 1 } + } + } as AgentSessionSubscribeEvent +} + +function pendingResolution(): AgentJournalResolution { + return { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } +} + +function approvalItem(): AgentJournalRenderItem { + return { + itemId: 'approval-1', + revision: 2, + sequence: 1, + observedAt: 10, + body: { + kind: 'approval', + title: 'Allow Bash?', + detail: 'rm -rf build', + options: [ + { id: 'allow-once', label: 'Allow once' }, + { id: 'deny', label: 'Deny' } + ], + resolution: pendingResolution() + } + } +} + +function approvalItemWithIdentity(itemId: string, revision: number): AgentJournalRenderItem { + return { ...approvalItem(), itemId, revision } +} + +function questionItem(): AgentJournalRenderItem { + return { + itemId: 'question-1', + revision: 7, + sequence: 2, + observedAt: 12, + body: { + kind: 'question', + question: 'Pick destination', + freeTextQuestionId: 'free-q', + options: [ + { id: 'choice-a', label: 'Choice A' }, + { id: 'choice-b', label: 'Choice B' } + ], + resolution: pendingResolution() + } + } +} + +function questionItemWithIdentity(itemId: string, revision: number): AgentJournalRenderItem { + return { ...questionItem(), itemId, revision } +} + +function runningStatusItem(): AgentJournalRenderItem { + return { + itemId: 'status-1', + revision: 1, + sequence: 3, + observedAt: 14, + body: { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } + } +} + +function defaultSendRequest(method: string, params?: Record) { + if (method === 'agentSession.send') { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { turnId: 'turn-1' } + }) + } + if (method === 'agentSession.options') { + return ok({ + models: [ + { + id: 'gpt-fast', + label: 'GPT Fast', + isDefault: true, + defaultEffort: 'low', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'gpt-slow', + label: 'GPT Slow', + isDefault: false, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ], + current: { + model: 'gpt-fast', + effort: 'low' + } + }) + } + if (method === 'agentSession.setOption') { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + key: 'model', + value: 'gpt-fast', + options: { model: 'gpt-fast' } + } + }) + } + if (method === 'agentSession.respondToApproval' || method === 'agentSession.respondToQuestion') { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 3 }, + value: { + itemId: String(params?.itemId ?? ''), + revision: 2, + resolution: { + state: 'resolved', + selectedOptionId: String(params?.optionId ?? ''), + resolvedBy: 'mobile', + resolvedAt: 123 + } + } + }) + } + return ok({}) +} + +describe('useMobileStructuredAgentSession', () => { + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let listener: ((value: unknown) => void) | null = null + const onSendError = vi.fn() + const unsubscribe = vi.fn() + const sendRequest = vi.fn(defaultSendRequest) + const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return unsubscribe + }) + const client = { + sendRequest, + subscribe + } as unknown as RpcClient + + function Harness({ + sessionId = 'session-1', + agent = 'codex', + connected = true, + sourceIdentity = 'host-a\0workspace-a' + }: { + sessionId?: string | null + agent?: string | null + connected?: boolean + sourceIdentity?: string + }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId, + sourceIdentity, + enabled: true, + connected, + agent, + onSendError + } as never) + return null + } + + beforeEach(() => { + vi.clearAllMocks() + sendRequest.mockImplementation(defaultSendRequest) + listener = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + }) + + it('subscribes and holds structured sessions without nativeChat or terminal RPCs', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + + await vi.waitFor(() => + expect(subscribe).toHaveBeenCalledWith( + 'agentSession.subscribe', + { sessionId: 'session-1' }, + expect.any(Function) + ) + ) + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.hold', + expect.objectContaining({ sessionId: 'session-1', holderId: expect.any(String) }), + expect.any(Object) + ) + ) + expect(sendRequest).not.toHaveBeenCalledWith( + expect.stringMatching(/^(nativeChat|terminal)\./), + expect.anything(), + expect.anything() + ) + }) + + it('re-holds after a reconnect that outlives the host release grace', async () => { + act(() => { + renderer = create(createElement(Harness, { connected: true })) + }) + await vi.waitFor(() => + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.hold') + ).toHaveLength(1) + ) + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)) + + // A transport loss retires the connection-scoped hold; after the host's 15s grace + // it may evict the provider child. Reconnect must acquire before replaying the stream. + await act(async () => { + renderer?.update(createElement(Harness, { connected: false })) + }) + expect(unsubscribe).toHaveBeenCalledTimes(1) + await act(async () => { + renderer?.update(createElement(Harness, { connected: true })) + }) + + await vi.waitFor(() => + expect( + sendRequest.mock.calls.filter(([method]) => method === 'agentSession.hold') + ).toHaveLength(2) + ) + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2)) + const holdOrders = sendRequest.mock.calls + .map((call, index) => + call[0] === 'agentSession.hold' ? sendRequest.mock.invocationCallOrder[index] : null + ) + .filter((order): order is number => order !== null) + const subscribeOrders = subscribe.mock.invocationCallOrder + const secondHoldOrder = holdOrders[1] + const secondSubscribeOrder = subscribeOrders[1] + if (secondHoldOrder === undefined || secondSubscribeOrder === undefined) { + throw new Error('reconnect calls were not recorded') + } + expect(secondHoldOrder).toBeLessThan(secondSubscribeOrder) + }) + + it('sends with the shared structured mutation envelope after the stream fence lands', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent())) + + let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected' + await act(async () => { + outcome = await hook!.sendWithOutcome('hello') + }) + + expect(outcome).toBe('accepted') + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.send', + expect.objectContaining({ + envelope: expect.objectContaining({ + sessionId: 'session-1', + expectedRuntimeFence: 3, + clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), + payloadFingerprint: expect.any(String) + }), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'hello' }] + } + }), + expect.any(Object) + ) + }) + + it('surfaces structured prompt cards and option snapshots', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent(3))) + act(() => listener?.(snapshotEvent(3))) + act(() => + listener?.({ + ...snapshotEvent(3), + page: { + ...snapshotEvent(3).page, + items: [approvalItem(), questionItem()] + } + }) + ) + + if (!hook) { + throw new Error('hook not ready') + } + + await vi.waitFor(() => expect(hook.permission).not.toBeNull()) + await vi.waitFor(() => expect(hook.question).not.toBeNull()) + await vi.waitFor(() => expect(hook.optionSnapshot.length).toBeGreaterThan(0)) + + expect(hook.permission).toMatchObject({ + title: 'Allow Bash?', + detail: 'rm -rf build', + options: [ + { label: 'Allow once', send: expect.any(String) }, + { label: 'Deny', send: expect.any(String) } + ] + }) + expect(hook.question).toMatchObject({ + question: 'Pick destination', + allowOther: true, + optionTokens: [expect.any(String), expect.any(String)], + freeTextToken: expect.any(String) + }) + expect(hook.optionSurface.getSnapshot()).toEqual(hook.optionSnapshot) + + await act(async () => { + expect(await hook.setStructuredOption('model', 'gpt-fast')).toBe(true) + }) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.setOption', + expect.objectContaining({ + envelope: expect.objectContaining({ + sessionId: 'session-1', + expectedRuntimeFence: 3, + clientOperationId: expect.any(String), + payloadFingerprint: expect.any(String) + }), + key: 'model', + value: 'gpt-fast' + }), + expect.any(Object) + ) + + await act(async () => { + expect(await hook.respondPermission(hook.permission!.options[0]!.send)).toBe(true) + }) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.respondToApproval', + expect.objectContaining({ + envelope: expect.objectContaining({ + sessionId: 'session-1', + expectedRuntimeFence: 3 + }), + itemId: 'approval-1', + optionId: 'allow-once' + }), + expect.any(Object) + ) + + await act(async () => { + expect( + await hook.respondQuestion(formatQuestionFreeTextAnswer(hook.question!, 'custom answer')) + ).toBe(true) + }) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.respondToQuestion', + expect.objectContaining({ + envelope: expect.objectContaining({ + sessionId: 'session-1', + expectedRuntimeFence: 3 + }), + itemId: 'question-1', + optionId: `${encodeURIComponent('free-q')}:${encodeURIComponent('custom answer')}` + }), + expect.any(Object) + ) + }) + + it('sends structured image attachments in the message body', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent(3))) + + let outcome: 'accepted' | 'unknown' | 'rejected' = 'rejected' + await act(async () => { + outcome = await hook.sendWithOutcome('look at this', undefined, undefined, [ + { path: '/tmp/a.png', previewUri: 'file:///a.jpg' } + ]) + }) + + expect(outcome).toBe('accepted') + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.send', + expect.objectContaining({ + envelope: expect.objectContaining({ + sessionId: 'session-1', + expectedRuntimeFence: 3, + clientOperationId: expect.any(String), + payloadFingerprint: expect.any(String) + }), + body: { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'look at this' }, + { type: 'image-ref', path: '/tmp/a.png' } + ] + } + }), + expect.any(Object) + ) + }) + + it('rejects preview-only structured image URIs instead of sending them as host paths', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent(3))) + sendRequest.mockClear() + + let outcome: 'accepted' | 'unknown' | 'rejected' = 'accepted' + await act(async () => { + outcome = await hook!.sendWithOutcome('look at this', ['file:///a.jpg']) + }) + + expect(outcome).toBe('rejected') + expect(onSendError).toHaveBeenCalledWith('Message not sent') + expect(sendRequest).not.toHaveBeenCalledWith( + 'agentSession.send', + expect.objectContaining({ + body: expect.objectContaining({ + blocks: expect.arrayContaining([{ type: 'image-ref', path: 'file:///a.jpg' }]) + }) + }), + expect.any(Object) + ) + }) + + it('answers the prompt captured by a structured card after a newer prompt lands', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => + listener?.({ + ...snapshotEvent(3), + page: { + ...snapshotEvent(3).page, + items: [ + approvalItemWithIdentity('approval-old', 4), + questionItemWithIdentity('question-old', 8) + ] + } + }) + ) + const approvalToken = hook!.permission!.options[0]!.send + const questionToken = hook!.question!.optionTokens[0]! + const freeText = formatQuestionFreeTextAnswer(hook!.question!, 'old answer') + + act(() => + listener?.({ + ...snapshotEvent(3), + page: { + ...snapshotEvent(3).page, + items: [ + approvalItemWithIdentity('approval-new', 9), + questionItemWithIdentity('question-new', 10) + ] + } + }) + ) + sendRequest.mockClear() + + await act(async () => { + expect(await hook!.respondPermission(approvalToken)).toBe(true) + expect(await hook!.respondQuestion(questionToken)).toBe(true) + expect(await hook!.respondQuestion(freeText)).toBe(true) + }) + + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.respondToApproval', + expect.objectContaining({ + itemId: 'approval-old', + expectedRevision: 4, + optionId: 'allow-once' + }), + expect.any(Object) + ) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.respondToQuestion', + expect.objectContaining({ + itemId: 'question-old', + expectedRevision: 8, + optionId: 'choice-a' + }), + expect.any(Object) + ) + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.respondToQuestion', + expect.objectContaining({ + itemId: 'question-old', + expectedRevision: 8, + optionId: `${encodeURIComponent('free-q')}:${encodeURIComponent('old answer')}` + }), + expect.any(Object) + ) + }) + + it('surfaces unknown structured prompt responses as unconfirmed', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => + listener?.({ + ...snapshotEvent(3), + page: { + ...snapshotEvent(3).page, + items: [approvalItem(), questionItem()] + } + }) + ) + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.respondToApproval') { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + onSendError.mockClear() + + await act(async () => { + expect(await hook!.respondPermission(hook!.permission!.options[0]!.send)).toBe(false) + }) + expect(onSendError).toHaveBeenCalledWith('Response unconfirmed — check chat before retrying') + + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.respondToQuestion') { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + onSendError.mockClear() + + await act(async () => { + expect(await hook!.respondQuestion(hook!.question!.optionTokens[0]!)).toBe(false) + }) + expect(onSendError).toHaveBeenCalledWith('Answer unconfirmed — check chat before retrying') + }) + + it('uses a fresh operation id when a prompt response delivery is unknown', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => + listener?.({ + ...snapshotEvent(3), + page: { ...snapshotEvent(3).page, items: [approvalItem()] } + }) + ) + let attempts = 0 + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.respondToApproval' && attempts++ === 0) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + + const token = hook!.permission!.options[0]!.send + await act(async () => { + expect(await hook!.respondPermission(token)).toBe(false) + expect(await hook!.respondPermission(token)).toBe(true) + }) + + const calls = sendRequest.mock.calls.filter( + ([method]) => method === 'agentSession.respondToApproval' + ) + expect(calls).toHaveLength(2) + const firstId = (calls[0]![1] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + const retryId = (calls[1]![1] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + expect(firstId).toMatch(/^\d{13}-[0-9a-f]{32}$/) + expect(retryId).toMatch(/^\d{13}-[0-9a-f]{32}$/) + expect(retryId).not.toBe(firstId) + }) + + it('marks a retried send as retryUnknown after ambiguous delivery', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent(3))) + let attempts = 0 + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.send' && attempts++ === 0) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + + await act(async () => { + expect(await hook!.sendWithOutcome('retry me')).toBe('unknown') + expect(await hook!.sendWithOutcome('retry me')).toBe('accepted') + }) + + const calls = sendRequest.mock.calls.filter(([method]) => method === 'agentSession.send') + expect(calls).toHaveLength(2) + expect(calls[0]![1]).not.toHaveProperty('retryUnknown') + expect(calls[1]![1]).toMatchObject({ retryUnknown: true }) + const firstId = (calls[0]![1] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + const retryId = (calls[1]![1] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + expect(retryId).toBe(firstId) + }) + + it('keeps structured option changes dispatched after unknown delivery', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotEvent(3))) + await vi.waitFor(() => expect(hook!.optionSnapshot.length).toBeGreaterThan(0)) + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.setOption') { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + onSendError.mockClear() + + await act(async () => { + expect(await hook!.setStructuredOption('model', 'gpt-slow')).toBe(true) + }) + + const model = hook!.optionSnapshot.find((descriptor) => descriptor.id === 'model') + expect(model).toMatchObject({ + valueSource: 'dispatched', + kind: expect.objectContaining({ currentValue: 'gpt-slow' }) + }) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('reports structured Stop as unconfirmed after unknown delivery', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => + listener?.({ + ...snapshotEvent(3), + page: { + ...snapshotEvent(3).page, + items: [runningStatusItem()] + } + }) + ) + sendRequest.mockImplementation(async (method, params) => { + if (method === 'agentSession.cancel') { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return defaultSendRequest(method, params) + }) + onSendError.mockClear() + + await act(async () => { + hook!.cancel() + await Promise.resolve() + }) + + expect(onSendError).toHaveBeenCalledWith('Stop unconfirmed — check chat before retrying') + }) + + it('releases a landed hold when the structured tab unmounts', async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.hold', + expect.objectContaining({ sessionId: 'session-1' }), + expect.any(Object) + ) + ) + const held = sendRequest.mock.calls.find((call) => call[0] === 'agentSession.hold')?.[1] as { + holderId: string + } + + act(() => renderer?.unmount()) + + await vi.waitFor(() => + expect(sendRequest).toHaveBeenCalledWith( + 'agentSession.release', + { sessionId: 'session-1', holderId: held.holderId }, + expect.any(Object) + ) + ) + }) + + it('keeps the transcript visible while reconnecting', async () => { + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false })) + }) + expect(hook?.session.messages).toHaveLength(1) + expect(hook?.session.status).toBe('ready') + + await act(async () => { + renderer?.update(createElement(Harness, { connected: true })) + }) + expect(hook?.session.messages).toHaveLength(1) + }) + + it('restores the correct cached transcript when switching tabs offline', async () => { + await act(async () => { + renderer = create(createElement(Harness, { connected: true, sessionId: 'session-1' })) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false, sessionId: 'session-2' })) + }) + expect(hook?.session.messages).toEqual([]) + expect(hook?.session.status).toBe('idle') + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false, sessionId: 'session-1' })) + }) + expect(hook?.session.messages).toHaveLength(1) + }) + + it('isolates matching provider session ids across host and workspace sources', async () => { + await act(async () => { + renderer = create( + createElement(Harness, { + connected: true, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a' + }) + ) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update( + createElement(Harness, { + connected: false, + sessionId: 'session-1', + sourceIdentity: 'host-b\0workspace-b' + }) + ) + }) + expect(hook?.session.messages).toEqual([]) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts new file mode 100644 index 00000000000..d9cabf1f2d0 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { + AgentSessionCancelResult, + AgentSessionPromptResult, + AgentSessionSendResult +} from '../../../src/shared/agent-session-wire' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../src/shared/native-chat-session-options' +import { + structuredAgentSessionSendBody, + type StructuredAgentSessionAttachment +} from '../../../src/shared/structured-agent-session-outbox' +import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-projection' +import { + pendingStructuredApproval, + pendingStructuredQuestion, + projectStructuredPermission, + projectStructuredQuestion, + structuredApprovalResponseTarget, + structuredQuestionResponseTarget +} from './mobile-structured-agent-prompts' +import { + requestStructuredAgentSessionMutation, + retainStructuredSessionOperationId as retainStructuredOpId, + timeoutForDeadline, + type StructuredAgentSessionMutationResult +} from './mobile-structured-agent-session-rpc' +import type { RpcClient } from '../transport/rpc-client' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import type { MobileChatQuestion } from './mobile-native-chat-question' +import type { MobileNativeChatSession } from './use-mobile-native-chat-session' +import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' +import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' + +type StructuredMobileAttachment = StructuredAgentSessionAttachment & { id?: string } + +type StructuredMobileSession = { + session: MobileNativeChatSession + isWorking: boolean + turnId: string | null + sendWithOutcome: ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredMobileAttachment[] + ) => Promise + cancel: () => void + permission: MobileChatPermission | null + question: MobileChatQuestion | null + optionSnapshot: SessionOptionDescriptor[] + optionSurface: SessionOptionsSurface + pendingOptionId: string | null + respondPermission: (optionId: string) => Promise + respondQuestion: (answer: string) => Promise + setStructuredOption: (id: string, value: SessionOptionValue) => Promise + invokeStructuredOption: (id: string) => Promise +} + +export function useMobileStructuredAgentSession(args: { + client: RpcClient | null + sessionId: string | null + /** Host/workspace scope used to keep same provider ids isolated. */ + sourceIdentity?: string + enabled: boolean + /** Live transport only; gates the connection-scoped hold, nothing else. */ + connected: boolean + agent: string | null + onSendError: (message: string) => void +}): StructuredMobileSession { + const { agent, client, connected, sessionId, sourceIdentity = '', enabled, onSendError } = args + const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) + const operationIdsRef = useRef(new Map()) + useEffect(() => () => operationIdsRef.current.clear(), []) + const retainOperationId = (key: string, operationId?: string): string => + retainStructuredOpId(operationIdsRef.current, key, operationId) + const stateArgs = { client, sessionId, sessionKey, enabled, connected } + const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs) + + const mutate = useCallback( + async ( + method: string, + fingerprintMethod: string, + fields: Record + ): Promise> => { + const current = stateRef.current + if (!client || !sessionId || !enabled || current.fence === null) { + return { status: 'rejected' } + } + const targetFence = current.fence + const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` + const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) + const result = await requestStructuredAgentSessionMutation({ + client, + method, + fingerprintMethod, + sessionId, + expectedRuntimeFence: targetFence, + fields, + clientOperationId + }) + if (result.status === 'accepted') { + operationIdsRef.current.delete(key) + return { + status: 'accepted', + value: result.value, + sameFence: stateRef.current.fence === targetFence + } + } + if (result.status === 'unknown') { + // Prompt/option/cancel plans cannot redispatch an unknown ledger row; + // issue a fresh id so a retry can be admitted after the user checks the + // stream. Sends opt into explicit retryUnknown below. + operationIdsRef.current.delete(key) + return result + } + operationIdsRef.current.delete(key) + onSendError(result.message) + return { status: 'rejected' } + }, + [client, enabled, onSendError, sessionId, sessionKey] + ) + + const { + invokeStructuredOption, + optionSnapshot, + optionSurface, + pendingOptionId, + setStructuredOption + } = useMobileStructuredAgentOptions({ + agent, + client, + sessionId, + enabled, + fence: state.fence, + mutate + }) + + const sendWithOutcome = useCallback( + async ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredMobileAttachment[] + ): Promise => { + const currentFence = stateRef.current.fence + if (!client || !sessionId || !enabled || currentFence === null) { + onSendError('Message not sent (disconnected)') + return 'rejected' + } + const timeoutMs = timeoutForDeadline(deadline) + if (timeoutMs === null) { + onSendError('Message not sent') + return 'rejected' + } + if (attachments === undefined && images !== undefined && images.length > 0) { + onSendError('Message not sent') + return 'rejected' + } + const sendAttachments = attachments ?? [] + const body = structuredAgentSessionSendBody(text, sendAttachments) + if (body.blocks.length === 0) { + return 'rejected' + } + const fields = { body } + const key = `${sessionKey}:agentSession.send:${JSON.stringify(fields)}` + const priorOperationId = operationIdsRef.current.get(key) + const clientOperationId = retainOperationId(key, priorOperationId) + const result = await requestStructuredAgentSessionMutation({ + client, + method: 'agentSession.send', + fingerprintMethod: 'agentSession.send', + sessionId, + expectedRuntimeFence: currentFence, + fields, + clientOperationId, + ...(priorOperationId ? { retryUnknown: true } : {}), + timeoutMs + }) + if (result.status === 'accepted') { + operationIdsRef.current.delete(key) + return 'accepted' + } + if (result.status === 'unknown') { + return 'unknown' + } + operationIdsRef.current.delete(key) + onSendError(result.message === 'Request not sent' ? 'Message not sent' : result.message) + return 'rejected' + }, + [client, enabled, onSendError, sessionId, sessionKey] + ) + + const respondPermission = useCallback( + async (optionId: string): Promise => { + const target = structuredApprovalResponseTarget( + optionId, + stateRef.current.items.find(pendingStructuredApproval) ?? null + ) + if (!target) { + return false + } + const result = await mutate( + 'agentSession.respondToApproval', + 'agentSession.respondTo:approval', + target + ) + if (result.status === 'unknown') { + onSendError('Response unconfirmed — check chat before retrying') + return false + } + return result.status === 'accepted' + }, + [mutate, onSendError] + ) + + const respondQuestion = useCallback( + async (answer: string): Promise => { + const target = structuredQuestionResponseTarget( + answer, + stateRef.current.items.find(pendingStructuredQuestion) ?? null + ) + if (!target) { + return false + } + const result = await mutate( + 'agentSession.respondToQuestion', + 'agentSession.respondTo:question', + target + ) + if (result.status === 'unknown') { + onSendError('Answer unconfirmed — check chat before retrying') + return false + } + return result.status === 'accepted' + }, + [mutate, onSendError] + ) + + const cancel = useCallback(() => { + const current = stateRef.current + const turnId = activeStructuredAgentSessionTurnId(current.items) + if (!client || !sessionId || !enabled || current.fence === null || !turnId) { + onSendError('Stop not sent') + return + } + const fields = { turnId } + const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}` + const clientOperationId = retainOperationId(key, operationIdsRef.current.get(key)) + void requestStructuredAgentSessionMutation({ + client, + method: 'agentSession.cancel', + fingerprintMethod: 'agentSession.cancel', + sessionId, + expectedRuntimeFence: current.fence, + fields, + clientOperationId + }).then((result) => { + if (result.status !== 'unknown') { + operationIdsRef.current.delete(key) + } + if (result.status === 'unknown') { + onSendError('Stop unconfirmed — check chat before retrying') + } else if (result.status === 'refused') { + onSendError(result.message) + } else if (result.status === 'failed') { + onSendError(result.message === 'Request not sent' ? 'Stop not sent' : result.message) + } + }) + }, [client, enabled, onSendError, sessionId, sessionKey]) + + const messages = useMemo( + () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), + [state.items, state.submissions] + ) + const status = state.status === 'idle' ? 'idle' : state.status + const approvalPrompt = useMemo( + () => state.items.find(pendingStructuredApproval) ?? null, + [state.items] + ) + const questionPrompt = useMemo( + () => state.items.find(pendingStructuredQuestion) ?? null, + [state.items] + ) + + return { + session: { + messages, + status, + transcriptLoading: status === 'loading', + error: state.error, + hasMore: state.hasOlder, + loadingEarlier: loadingOlder, + loadEarlier + }, + isWorking: activeStructuredAgentSessionTurnId(state.items) !== null, + turnId: activeStructuredAgentSessionTurnId(state.items), + sendWithOutcome, + cancel, + permission: projectStructuredPermission(approvalPrompt), + question: projectStructuredQuestion(questionPrompt), + optionSnapshot, + optionSurface, + pendingOptionId, + respondPermission, + respondQuestion, + setStructuredOption, + invokeStructuredOption + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts new file mode 100644 index 00000000000..52aefab24aa --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../src/shared/agent-session-wire' +import { structuredAgentSessionHolderId } from '../../../src/shared/structured-agent-session-holder' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + oldestStructuredAgentSessionCursor, + reduceStructuredAgentSession, + type StructuredAgentSessionAction, + type StructuredAgentSessionState +} from '../../../src/shared/structured-agent-session-reducer' +import type { RpcClient } from '../transport/rpc-client' +import { callAgentSession } from './mobile-structured-agent-session-rpc' + +const MAX_RETAINED_SESSION_STATES = 32 + +function isSubscribeEvent(value: unknown): value is AgentSessionSubscribeEvent { + if (typeof value !== 'object' || value === null) { + return false + } + const type = (value as { type?: unknown }).type + return type === 'snapshot' || type === 'batch' || type === 'reset' || type === 'end' +} + +export function useMobileStructuredAgentState(args: { + client: RpcClient | null + sessionId: string | null + sessionKey: string | null + enabled: boolean + /** Live transport only. The hold dies with the connection and has to be retaken, + * but the transcript must survive the outage rather than blank out with it. */ + connected: boolean +}): { + state: StructuredAgentSessionState + stateRef: { readonly current: StructuredAgentSessionState } + loadingOlder: boolean + loadEarlier: () => void +} { + const { client, connected, enabled, sessionId, sessionKey } = args + // Keep a bounded cache so offline tab switches select the right transcript + // synchronously without growing for the lifetime of the app. + const [sessionStates, setSessionStates] = useState>( + () => new Map() + ) + const state = + enabled && sessionKey + ? (sessionStates.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION) + : EMPTY_STRUCTURED_AGENT_SESSION + const [loadingOlder, setLoadingOlder] = useState(false) + const stateRef = useRef(state) + const sessionKeyRef = useRef(sessionKey) + const streamGenerationRef = useRef(0) + useLayoutEffect(() => { + stateRef.current = state + sessionKeyRef.current = sessionKey + }, [sessionKey, state]) + + const apply = useCallback( + (action: StructuredAgentSessionAction) => { + if (!sessionKey) { + return + } + setSessionStates((current) => { + const previous = current.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION + const next = reduceStructuredAgentSession(previous, action) + if (next === previous) { + return current + } + const updated = new Map(current) + updated.delete(sessionKey) + updated.set(sessionKey, next) + while (updated.size > MAX_RETAINED_SESSION_STATES) { + const oldest = updated.keys().next().value + if (oldest === undefined) { + break + } + updated.delete(oldest) + } + return updated + }) + }, + [sessionKey] + ) + + useEffect(() => { + streamGenerationRef.current += 1 + sessionKeyRef.current = sessionKey + setLoadingOlder(false) + if (!client || !sessionId || !enabled) { + return + } + if (!connected) { + // The cleanup above drops the dead hold and stream; keyed state keeps this + // session's transcript visible while another tab can be selected. + return + } + apply({ type: 'loading' }) + const holderId = structuredAgentSessionHolderId('mobile-chat') + let cancelled = false + let unsubscribe = (): void => {} + const held = callAgentSession(client, 'agentSession.hold', { + sessionId, + holderId + }) + void held + .then(() => { + if (cancelled) { + return + } + unsubscribe = client.subscribe('agentSession.subscribe', { sessionId }, (raw) => { + if ( + typeof raw === 'object' && + raw !== null && + (raw as { type?: unknown }).type === 'error' + ) { + apply({ type: 'error', message: String((raw as { message?: unknown }).message ?? '') }) + return + } + if (isSubscribeEvent(raw)) { + apply({ type: 'event', event: raw }) + } + }) + }) + .catch((error: unknown) => { + if (!cancelled) { + apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + } + }) + return () => { + cancelled = true + unsubscribe() + void held + .then(() => + callAgentSession( + client, + 'agentSession.release', + { + sessionId, + holderId + }, + undefined, + { failWhenDisconnected: true } + ).catch(() => undefined) + ) + .catch(() => undefined) + } + }, [apply, client, connected, enabled, sessionId, sessionKey]) + + const loadEarlier = useCallback(() => { + const current = stateRef.current + if (!client || !sessionId || !sessionKey || loadingOlder || !current.hasOlder) { + return + } + const cursor = oldestStructuredAgentSessionCursor(current) + if (!cursor) { + return + } + const requestSessionKey = sessionKey + const requestGeneration = streamGenerationRef.current + setLoadingOlder(true) + void callAgentSession(client, 'agentSession.history', { + sessionId, + direction: 'before', + cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + .then((result) => { + if ( + result.ok && + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page }) + } + }) + .catch((error: unknown) => { + if ( + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + } + }) + .finally(() => { + if ( + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + setLoadingOlder(false) + } + }) + }, [apply, client, loadingOlder, sessionId, sessionKey]) + + return { state, stateRef, loadingOlder, loadEarlier } +} diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts new file mode 100644 index 00000000000..fa786867fc7 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -0,0 +1,100 @@ +import { useCallback } from 'react' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' + +type StructuredNativeChatAttachment = { + id?: string + path: string + previewUri: string +} + +export function useMobileStructuredNativeChatSendBridge(args: { + sendStructured: ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredNativeChatAttachment[] + ) => Promise + captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void + acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void + holdUnconfirmedSend: ( + origin: MobileNativeChatSendOrigin, + text: string, + onUnconfirmed: () => void + ) => void + restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void + onSendError: (message: string) => void +}): { + send: (text: string, images?: string[]) => Promise + sendWithOutcome: ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredNativeChatAttachment[] + ) => Promise +} { + const { + acceptSend, + captureSendOrigin, + clearDraftForSend, + holdUnconfirmedSend, + onSendError, + restoreRejectedDraft, + sendStructured + } = args + const sendWithOutcome = useCallback( + async ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredNativeChatAttachment[] + ): Promise => { + const origin = captureSendOrigin(text.trimEnd()) + if (!origin) { + onSendError('Message not sent (disconnected)') + return 'rejected' + } + clearDraftForSend(origin, text) + const outcome = + attachments !== undefined + ? await sendStructured(text, images, deadline, attachments) + : deadline !== undefined + ? await sendStructured(text, images, deadline) + : images !== undefined + ? await sendStructured(text, images) + : await sendStructured(text) + if (outcome === 'accepted') { + acceptSend(origin, text.trimEnd(), images) + return 'accepted' + } + if (outcome === 'unknown') { + holdUnconfirmedSend(origin, text.trimEnd(), () => + onSendError('Delivery unconfirmed — check chat before retrying') + ) + return 'unknown' + } + restoreRejectedDraft(origin, text) + return 'rejected' + }, + [ + acceptSend, + captureSendOrigin, + clearDraftForSend, + holdUnconfirmedSend, + onSendError, + restoreRejectedDraft, + sendStructured + ] + ) + const send = useCallback( + async ( + text: string, + images?: string[], + deadline?: number, + attachments?: readonly StructuredNativeChatAttachment[] + ) => (await sendWithOutcome(text, images, deadline, attachments)) !== 'rejected', + [sendWithOutcome] + ) + return { send, sendWithOutcome } +} diff --git a/mobile/src/transport/cellular-connecting-label-stall.test.ts b/mobile/src/transport/cellular-connecting-label-stall.test.ts index e9a46d3b406..358b0abab41 100644 --- a/mobile/src/transport/cellular-connecting-label-stall.test.ts +++ b/mobile/src/transport/cellular-connecting-label-stall.test.ts @@ -19,6 +19,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + type CarrierBehavior = // Carrier silently drops the SYN to a LAN/CGNAT destination: the socket sits // CONNECTING until the client's 12s connect timeout fires. diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index a311f362f85..4a7d5d7b0e8 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -146,8 +146,8 @@ beforeEach(() => { }) describe('useHostClient', () => { - it('rebinds when Expo reuses a screen between two connected cached hosts', async () => { - const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + it('rebinds the client and its authenticated identity together across cached hosts', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2', deviceToken: 'token-2' } const client1 = makeFakeClient('connected') const client2 = makeFakeClient('connected') connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2) @@ -155,11 +155,13 @@ describe('useHostClient', () => { let selectedHostId = HOST.id let selectedClient: RpcClient | null = null + let selectedClientId: string | null = null let selectedState: ConnectionState = 'disconnected' let renderer: ReactTestRenderer | null = null function Probe(): null { const selected = useHostClient(selectedHostId) selectedClient = selected.client + selectedClientId = selected.clientId selectedState = selected.state useHostClient(host2.id) return null @@ -171,16 +173,18 @@ describe('useHostClient', () => { await Promise.resolve() }) expect(selectedClient).toBe(client1) + expect(selectedClientId).toBe(HOST.deviceToken) expect(selectedState).toBe('connected') selectedHostId = host2.id - client2.emitState('disconnected') await act(async () => { + client2.emitState('disconnected') renderer?.update(createElement(RpcClientProvider, null, createElement(Probe))) await Promise.resolve() }) expect(selectedClient).toBe(client2) + expect(selectedClientId).toBe(host2.deviceToken) expect(selectedState).toBe('disconnected') expect(connectMock).toHaveBeenCalledTimes(2) } finally { diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index f83519940c1..76d26c1bab5 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -7,7 +7,6 @@ import { useEffect, useMemo, useRef, - useState, type ReactNode } from 'react' import type { RpcClient } from './rpc-client' @@ -35,6 +34,15 @@ import { import type { ConnectionState, HostProfile } from './types' import type { RpcClientContextValue } from './rpc-client-context-contract' +export { + useDisconnectHostClient, + useForceReconnect, + useForgetHostClient, + useHostClient, + usePrimeHosts, + useRefreshHostClient +} from './host-client-hooks' + type StoreEntry = HostClientStoreEntry const Ctx = createContext(null) @@ -364,99 +372,3 @@ export function useRpcClientContext(): RpcClientContextValue { } return ctx } - -// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change. -export function useHostClient(hostId: string | undefined): { - client: RpcClient | null - state: ConnectionState -} { - const ctx = useRpcClientContext() - const [, force] = useState(0) - // Why: an absent entry at mount is almost always the open racing the render, not a - // dead host — seed amber; a failed open notifies 'disconnected' moments later. - const [state, setState] = useState(() => - hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected' - ) - const clientRef = useRef(null) - const clientHostIdRef = useRef(hostId) - const acquisitionRef = useRef({}) - - useEffect(() => { - if (!hostId) { - clientRef.current = null - clientHostIdRef.current = undefined - setState('disconnected') - return - } - clientHostIdRef.current = hostId - let cancelled = false - // Subscribe before acquire so any state change during open is captured. - const unsub = ctx.subscribeHostState(hostId, (next) => { - if (cancelled) { - return - } - setState(next) - // Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one. - const found = ctx.getAllClients().find((entry) => entry.hostId === hostId) - if (found && found.client !== clientRef.current) { - clientRef.current = found.client - force((n) => n + 1) - } else if (!found && clientRef.current) { - // Why: disconnect/forget deletes the entry; never retain a dead client (STA-1511). - clientRef.current = null - force((n) => n + 1) - } - }) - const initial = ctx.acquire(hostId, acquisitionRef.current) - clientRef.current = initial - setState(ctx.getKnownState(hostId) ?? 'connecting') - if (initial) { - // Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client. - force((n) => n + 1) - } - return () => { - cancelled = true - unsub() - ctx.release(hostId, acquisitionRef.current) - clientRef.current = null - clientHostIdRef.current = undefined - } - }, [ctx, hostId]) - - // Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render. - const bound = clientHostIdRef.current === hostId - const boundState = bound - ? state - : hostId - ? (ctx.getKnownState(hostId) ?? 'connecting') - : 'disconnected' - return { client: bound ? clientRef.current : null, state: boundState } -} - -// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it. -export function useRefreshHostClient(): (hostId: string) => void { - const ctx = useRpcClientContext() - return ctx.refreshHostClient -} - -export function useForgetHostClient(): (hostId: string) => void { - const ctx = useRpcClientContext() - return ctx.forgetHostClient -} - -export function useDisconnectHostClient(): (hostId: string) => void { - const ctx = useRpcClientContext() - return ctx.disconnectHostClient -} - -// Why: future-proof "Connection issues — try again" affordance. -export function useForceReconnect(): (hostId: string) => Promise { - const ctx = useRpcClientContext() - return ctx.forceReconnect -} - -// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start. -export function usePrimeHosts(): (hosts: HostProfile[]) => void { - const ctx = useRpcClientContext() - return ctx.primeHosts -} diff --git a/mobile/src/transport/direct-connection-log.ts b/mobile/src/transport/direct-connection-log.ts index ef805e643c5..2630b008459 100644 --- a/mobile/src/transport/direct-connection-log.ts +++ b/mobile/src/transport/direct-connection-log.ts @@ -43,4 +43,8 @@ export class DirectConnectionLog { { code: 'liveness-timeout' } ) } + + connected = (): void => { + this.emit('success', 'Authenticated', 'Channel ready for RPC', { code: 'direct-connected' }) + } } diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts index 36ed573aa5b..16306f95cdd 100644 --- a/mobile/src/transport/direct-rpc-client.ts +++ b/mobile/src/transport/direct-rpc-client.ts @@ -18,6 +18,7 @@ import { import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog' import { isStaleForegroundDial } from './rpc-stale-dial' import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types' +import { negotiateMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation' const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-' @@ -226,17 +227,22 @@ export class DirectRpcClient implements RpcClient { } private handleAuthenticated(session: RpcClientSocketSession): void { - console.log('[net] e2ee_authenticated — connected', { streamCount: this.streams.size() }) this.livenessSession = session this.liveness.start(session) - this.authenticationGeneration++ - this.reconnect.authenticated() - this.authenticationRetry.accepted() - this.connectionState.publish('connected') - this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', { - code: 'direct-connected' + const generation = ++this.authenticationGeneration + negotiateMobileRuntimeCapabilities({ + sendRequest: (method, params) => + this.requests.sendAuthenticatedRequest(method, params, 5_000), + current: () => this.socketSession === session && this.authenticationGeneration === generation, + onReady: () => { + this.reconnect.authenticated() + this.authenticationRetry.accepted() + this.connectionState.publish('connected') + this.connectionLog.connected() + this.streams.replayAfterAuthentication() + }, + onFailure: () => this.socketClose.forceClose(session) }) - this.streams.replayAfterAuthentication() } private handleRpcResponse(response: RpcResponse): void { diff --git a/mobile/src/transport/foreground-stale-dial-restart.test.ts b/mobile/src/transport/foreground-stale-dial-restart.test.ts index 8c9ebd94d7e..39410e8fc3f 100644 --- a/mobile/src/transport/foreground-stale-dial-restart.test.ts +++ b/mobile/src/transport/foreground-stale-dial-restart.test.ts @@ -25,6 +25,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + // Mirrors React Native's WebSocket: readyState lives in JS and only advances on // a delivered event, so a socket the OS killed while the app was suspended stays // CONNECTING forever from the client's point of view. diff --git a/mobile/src/transport/host-client-context-state.ts b/mobile/src/transport/host-client-context-state.ts index 972a810af21..859e5d847f9 100644 --- a/mobile/src/transport/host-client-context-state.ts +++ b/mobile/src/transport/host-client-context-state.ts @@ -79,6 +79,7 @@ export function createHostClientSelectors( return { getKnownState, getState: (hostId: string): ConnectionState => getKnownState(hostId) ?? 'disconnected', + getClientId: (hostId: string): string | null => entries.get(hostId)?.clientId ?? null, getReconnectAttempt: (hostId: string): number => entries.get(hostId)?.client.getReconnectAttempt() ?? 0, getLastConnectedAt: (hostId: string): number | null => diff --git a/mobile/src/transport/host-client-hooks.ts b/mobile/src/transport/host-client-hooks.ts new file mode 100644 index 00000000000..c7f885034c2 --- /dev/null +++ b/mobile/src/transport/host-client-hooks.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, HostProfile } from './types' +import type { HostClientAcquisition } from './host-client-acquisition-registry' +import { useRpcClientContext } from './client-context' + +// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change. +export function useHostClient(hostId: string | undefined): { + client: RpcClient | null + clientId: string | null + state: ConnectionState +} { + const ctx = useRpcClientContext() + const [, force] = useState(0) + const [state, setState] = useState(() => + hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected' + ) + const clientRef = useRef(null) + const clientHostIdRef = useRef(hostId) + const acquisitionRef = useRef({}) + + useEffect(() => { + if (!hostId) { + clientRef.current = null + clientHostIdRef.current = undefined + setState('disconnected') + return + } + clientHostIdRef.current = hostId + let cancelled = false + const unsub = ctx.subscribeHostState(hostId, (next) => { + if (cancelled) { + return + } + setState(next) + const found = ctx.getAllClients().find((entry) => entry.hostId === hostId) + if (found && found.client !== clientRef.current) { + clientRef.current = found.client + force((n) => n + 1) + } else if (!found && clientRef.current) { + clientRef.current = null + force((n) => n + 1) + } + }) + const initial = ctx.acquire(hostId, acquisitionRef.current) + clientRef.current = initial + setState(ctx.getKnownState(hostId) ?? 'connecting') + if (initial) { + force((n) => n + 1) + } + return () => { + cancelled = true + unsub() + ctx.release(hostId, acquisitionRef.current) + clientRef.current = null + clientHostIdRef.current = undefined + } + }, [ctx, hostId]) + + const bound = clientHostIdRef.current === hostId + const boundClient = bound ? clientRef.current : null + const boundState = bound + ? state + : hostId + ? (ctx.getKnownState(hostId) ?? 'connecting') + : 'disconnected' + return { + client: boundClient, + clientId: boundClient && hostId ? ctx.getClientId(hostId) : null, + state: boundState + } +} + +export function useRefreshHostClient(): (hostId: string) => void { + return useRpcClientContext().refreshHostClient +} + +export function useForgetHostClient(): (hostId: string) => void { + return useRpcClientContext().forgetHostClient +} + +export function useDisconnectHostClient(): (hostId: string) => void { + return useRpcClientContext().disconnectHostClient +} + +export function useForceReconnect(): (hostId: string) => Promise { + return useRpcClientContext().forceReconnect +} + +export function usePrimeHosts(): (hosts: HostProfile[]) => void { + return useRpcClientContext().primeHosts +} diff --git a/mobile/src/transport/host-entry-opener.ts b/mobile/src/transport/host-entry-opener.ts index 6e29f55d985..03d6363c7c7 100644 --- a/mobile/src/transport/host-entry-opener.ts +++ b/mobile/src/transport/host-entry-opener.ts @@ -12,6 +12,7 @@ import type { ConnectionState, HostProfile } from './types' export type HostClientStoreEntry = { client: RpcClient + clientId: string state: ConnectionState refCount: number unsubState: () => void @@ -130,6 +131,7 @@ export async function openHostClientEntry( }) ?? (() => {}) const entry: HostClientStoreEntry = { client, + clientId: host.deviceToken, state: client.getState(), refCount: state.pendingAcquisitions.get(hostId) ?? 0, unsubState, diff --git a/mobile/src/transport/mobile-endpoint-supervisor-stalled-cell.test.ts b/mobile/src/transport/mobile-endpoint-supervisor-stalled-cell.test.ts new file mode 100644 index 00000000000..be4050cee5b --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor-stalled-cell.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' +import { + dependencies, + FakeLogicalClient, + FakeRelaySession, + host, + relay +} from './mobile-endpoint-supervisor-test-fakes' +import { ReplacementAuthenticationTimeoutError } from './replacement-session-authentication' +import type { RpcClient } from './rpc-client' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +// The 2026-09-03 incident: five consecutive "authentication timed out" dials against a +// live desktop while the cell's assignment tables were lock-contended. Each logged +// failure was two dials — the timeout counted as a director-class failure, so the phone +// re-resolved the same cell and waited the full bound again. +describe('relay dial against a cell that took the dial and stalled', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + function timingOut(logical: FakeLogicalClient, error: Error): void { + logical.migrateTo.mockImplementation(async (session: RpcClient) => { + session.close() + throw error + }) + } + + it('does not re-resolve the director and names the stalled stage', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + timingOut(logical, new ReplacementAuthenticationTimeoutError('awaiting-hello', 30_000)) + const openRelay = vi.fn(() => new FakeRelaySession('connecting')) + const resolveRelay = vi.fn(async () => relay) + const onLog = vi.fn() + const supervisor = new MobileEndpointSupervisor( + logical, + host, + dependencies({ openRelay, resolveRelay, onLog }) + ) + + await supervisor.start() + + expect(openRelay).toHaveBeenCalledOnce() + expect(resolveRelay).not.toHaveBeenCalled() + expect(onLog).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'relay-dial-failed', + detail: expect.stringContaining('timed out (awaiting-hello, 30s)') + }) + ) + supervisor.stop() + }) + + it('still re-resolves the director when the cell socket never opened', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + timingOut(logical, new ReplacementAuthenticationTimeoutError('opening', 12_000)) + const openRelay = vi.fn(() => new FakeRelaySession('connecting')) + const resolveRelay = vi.fn(async () => relay) + const supervisor = new MobileEndpointSupervisor( + logical, + host, + dependencies({ openRelay, resolveRelay }) + ) + + await supervisor.start() + + expect(resolveRelay).toHaveBeenCalledOnce() + expect(openRelay).toHaveBeenCalledTimes(2) + supervisor.stop() + }) +}) diff --git a/mobile/src/transport/mobile-endpoint-supervisor-support.ts b/mobile/src/transport/mobile-endpoint-supervisor-support.ts index 6ee0a6b42cb..1a2c00f12de 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-support.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-support.ts @@ -1,5 +1,6 @@ import { RelayOuterError } from './mobile-relay-e2ee-link' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' +import { ReplacementAuthenticationTimeoutError } from './replacement-session-authentication' import type { RelayReconnectController } from './mobile-relay-reconnect-controller' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { HostProfile } from './types' @@ -68,6 +69,11 @@ export async function dialRelayThroughDirectorFallback(args: { } export function isDirectorResolutionFailure(error: Error): boolean { + // Why: a cell that took relay-auth and went quiet is the right cell working slowly; + // re-resolving it just doubles the wait against the same contended window. + if (error instanceof ReplacementAuthenticationTimeoutError) { + return error.stage === null || error.stage === 'opening' + } return ( !(error instanceof MobileE2EEAuthenticationError) && (!(error instanceof RelayOuterError) || [4409, 4503, 1006].includes(error.code)) diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index 4023a1a8e39..1dc1473d9db 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -1,6 +1,7 @@ import { vi } from 'vitest' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage' import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' @@ -51,6 +52,10 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi // Why: production-realistic defaults — fictional fake values hid three // live defects in this subsystem (latch, churn, int32 timer overflow). getAttachDeadlineAt = () => Date.now() + 10_000 + readonly dialStage = new RelayDialStageTracker() + getDialStage = () => this.dialStage.getDialStage() + onDialStageChange = (listener: (stage: RelayDialStage) => void) => + this.dialStage.onDialStageChange(listener) getResumeExpiresAt = () => this.resumeExpiry getResumeConfirmation = () => ({ v: 1 as const, diff --git a/mobile/src/transport/mobile-relay-e2ee-link.test.ts b/mobile/src/transport/mobile-relay-e2ee-link.test.ts index aa2d42b13f0..965135511eb 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.test.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.test.ts @@ -60,6 +60,61 @@ describe('MobileRelayE2eeLink', () => { expect(socket.close).toHaveBeenCalledOnce() }) + it('reports open only once relay-auth is on the wire', () => { + const socket = new ThrowingSocket() + const onOpen = vi.fn() + const sent: string[] = [] + socket.send.mockImplementation((frame: string) => { + sent.push(frame) + }) + new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onOpen, + onError: vi.fn(), + createSocket: () => socket as unknown as WebSocket + }) + + expect(onOpen).not.toHaveBeenCalled() + socket.onopen?.() + expect(sent).toHaveLength(1) + expect(JSON.parse(sent[0]!)).toMatchObject({ type: 'relay-auth', mode: 'connect' }) + expect(onOpen).toHaveBeenCalledOnce() + }) + + it('does not report open when the relay-auth write fails', () => { + const socket = new ThrowingSocket() + const onOpen = vi.fn() + new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onOpen, + onError: vi.fn(), + createSocket: () => socket as unknown as WebSocket + }) + + socket.onopen?.() + expect(onOpen).not.toHaveBeenCalled() + }) + it('keeps a typed close code when transport error precedes close', () => { const socket = new ThrowingSocket() const onError = vi.fn() diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index f19417a60f1..7743deb23dd 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -26,6 +26,8 @@ type MobileRelayE2eeLinkOptions = { onText: (plaintext: string) => void onBinary: (plaintext: Uint8Array) => void onHello?: (hello: Extract) => void + // Fired once relay-auth is on the wire: from here the cell owns the wait. + onOpen?: () => void onError: (error: Error) => void createSocket?: (url: string) => WebSocket } @@ -96,7 +98,9 @@ export class MobileRelayE2eeLink { ) } catch (error) { this.fail(asError(error)) + return } + this.options.onOpen?.() } this.socket.onmessage = (event) => { this.inboundChain = this.inboundChain diff --git a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts index a3885a226c0..b811721e562 100644 --- a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts @@ -74,6 +74,16 @@ async function authenticateSession(onLog?: ConnectionLogSink) { _meta: { runtimeId: 'runtime-1' } }) ) + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2)) + const capabilities = sentRequests()[1]! + fakes.linkOptions!.onText( + JSON.stringify({ + id: capabilities.id, + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) await vi.waitFor(() => expect(session.getState()).toBe('connected')) fakes.sendText.mockClear() return session diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index d5b547885cf..5887dffc73d 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -11,6 +11,7 @@ const fakes = vi.hoisted(() => ({ endpoint: { cellUrl: string; relayHostId: string } credential: string expectedCredentialKind: string + onOpen(): void onHello(value: unknown): void onAuthenticated(): void onText(value: string): void @@ -54,7 +55,7 @@ function openSession() { }) } -async function authenticateSession() { +async function confirmResume() { const session = openSession() fakes.linkOptions!.onHello({ type: 'relay-hello', @@ -92,9 +93,39 @@ async function authenticateSession() { _meta: { runtimeId: 'runtime-1' } }) ) + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2)) + const capabilityRequest = JSON.parse(fakes.sendText.mock.calls[1]![0] as string) as { + id: string + method: string + deviceToken: string + params: { clientCapabilities?: string[] } + } + return { session, confirmationRequest: request, capabilityRequest } +} + +async function authenticateSession(capabilitySupported = true) { + const { session, confirmationRequest, capabilityRequest } = await confirmResume() + expect(session.getState()).toBe('handshaking') + fakes.linkOptions!.onText( + JSON.stringify( + capabilitySupported + ? { + id: capabilityRequest.id, + ok: true, + result: capabilityRequest.params, + _meta: { runtimeId: 'runtime-1' } + } + : { + id: capabilityRequest.id, + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + ) + ) await vi.waitFor(() => expect(session.getState()).toBe('connected')) fakes.sendText.mockClear() - return { session, confirmationRequest: request } + return { session, confirmationRequest, capabilityRequest } } describe('mobile relay RPC session', () => { @@ -106,7 +137,7 @@ describe('mobile relay RPC session', () => { afterEach(() => vi.useRealTimers()) it('requires exact resume observations and confirms by request ID before becoming connected', async () => { - const { session, confirmationRequest } = await authenticateSession() + const { session, confirmationRequest, capabilityRequest } = await authenticateSession() expect(fakes.linkOptions).toMatchObject({ endpoint: relay, @@ -120,9 +151,61 @@ describe('mobile relay RPC session', () => { }) expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId') expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion') + expect(capabilityRequest).toMatchObject({ + method: 'runtime.clientCapabilities.update', + params: { + clientCapabilities: expect.arrayContaining(['agent-session.structured.v1']) + }, + deviceToken: 'device-token' + }) expect(session.getAttachDeadlineAt()).toEqual(expect.any(Number)) }) + it('connects when an older runtime rejects capability negotiation', async () => { + const { session } = await authenticateSession(false) + + expect(session.getState()).toBe('connected') + expect(session.getFailure()).toBeNull() + }) + + it('connects when the relay never answers capability negotiation', async () => { + const { session } = await confirmResume() + + // Why: the advisory's own deadline used to fail confirmResume, so a link too slow to + // answer within the request timeout never published 'connected' — it just redialled. + await vi.waitFor(() => expect(session.getState()).toBe('connected'), { timeout: 5_000 }) + expect(session.getFailure()).toBeNull() + }) + + // Why: ConnectionState stays 'connecting' until relay-hello, so the migration bound + // needs a separate signal to tell "cell never answered the upgrade" from "cell took + // relay-auth and is still resolving the assignment". + it('reports the dial stage as the link opens, receives hello, and authenticates', async () => { + const session = openSession() + const stages: string[] = [] + session.onDialStageChange((stage) => stages.push(stage)) + expect(session.getDialStage()).toBe('opening') + + fakes.linkOptions!.onOpen() + expect(session.getDialStage()).toBe('awaiting-hello') + expect(session.getState()).toBe('connecting') + fakes.linkOptions!.onHello({ + type: 'relay-hello', + ok: true, + credentialKind: 'resume', + leaseExpiresAt: Date.now() + 10_000, + acceptedCredentialVersion: 3, + acceptedAs: 'current', + resumeExpiresAt: Date.now() + 300_000 + }) + expect(session.getDialStage()).toBe('handshaking') + fakes.linkOptions!.onAuthenticated() + expect(session.getDialStage()).toBe('confirming') + await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce()) + expect(stages).toEqual(['awaiting-hello', 'handshaking', 'confirming']) + session.close() + }) + it('rejects a mismatched outer credential version and closes the physical link', () => { const session = openSession() fakes.linkOptions!.onHello({ diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index f242fce07ba..947a1d23ce8 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -9,7 +9,10 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget' import { isRpcResponse } from './rpc-response-shape' +import { RelayDialStageTracker, type RelayDialStageSource } from './relay-dial-stage' +import { RelayPendingRequests } from './relay-pending-requests' import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog' +import { settleMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation' import type { RpcClient } from './rpc-client' import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types' @@ -18,20 +21,15 @@ const RELAY_MISSED_PROBE_LIMIT = 2 const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000 let relayRpcSessionSequence = 0 -type PendingRequest = { - resolve: (response: RpcResponse) => void - reject: (error: Error) => void - timer: ReturnType -} - -export type MobileRelayRpcSession = RpcClient & { - // The cell's attach-reservation deadline (~10s). Diagnostics only — never - // schedule anything from it; rotation keys off getResumeExpiresAt(). - getAttachDeadlineAt(): number | null - getResumeExpiresAt(): number | null - getResumeConfirmation(): DeviceResumeConfirmed | null - getFailure(): Error | null -} +export type MobileRelayRpcSession = RpcClient & + RelayDialStageSource & { + // The cell's attach-reservation deadline (~10s). Diagnostics only — never + // schedule anything from it; rotation keys off getResumeExpiresAt(). + getAttachDeadlineAt(): number | null + getResumeExpiresAt(): number | null + getResumeConfirmation(): DeviceResumeConfirmed | null + getFailure(): Error | null + } export function connectMobileRelayRpcSession(args: { relay: MobileRelayEndpoint @@ -45,10 +43,9 @@ export function connectMobileRelayRpcSession(args: { onLog?: ConnectionLogSink }): MobileRelayRpcSession { const requestTimeoutMs = args.requestTimeoutMs ?? 30_000 - const pending = new Map() + const pending = new RelayPendingRequests() const stateListeners = new Set<(state: ConnectionState) => void>() let state: ConnectionState = 'connecting' - let requestCounter = 0 let lastConnectedAt: number | null = null let attachDeadlineAt: number | null = null let resumeExpiresAt: number | null = null @@ -58,8 +55,9 @@ export function connectMobileRelayRpcSession(args: { let logSequence = 0 const logSessionId = `${Date.now().toString(36)}-${(++relayRpcSessionSequence).toString(36)}` const livenessIdentity = {} + const dialStage = new RelayDialStageTracker() const streams = new MobileRelayRpcStreams({ - nextId, + nextId: () => pending.nextId(), sendFrame, waitForConnected: () => waitForConnected() }) @@ -71,6 +69,7 @@ export function connectMobileRelayRpcSession(args: { deviceToken: args.deviceToken, desktopPublicKeyB64: args.desktopPublicKeyB64, createSocket: args.createSocket, + onOpen: () => dialStage.advance('awaiting-hello'), onHello: (hello) => { if ( hello.credentialKind !== 'resume' || @@ -81,6 +80,7 @@ export function connectMobileRelayRpcSession(args: { } attachDeadlineAt = hello.leaseExpiresAt resumeExpiresAt = hello.resumeExpiresAt + dialStage.advance('handshaking') publishState('handshaking') }, onAuthenticated: () => void confirmResume(), @@ -132,10 +132,12 @@ export function connectMobileRelayRpcSession(args: { closed = true livenessWatchdog.stop(livenessIdentity) link.close() - rejectPending(new Error('Client closed')) + pending.rejectAll(new Error('Client closed')) streams.clear() publishState('disconnected') }, + getDialStage: () => dialStage.getDialStage(), + onDialStageChange: (listener) => dialStage.onDialStageChange(listener), getAttachDeadlineAt: () => attachDeadlineAt, getResumeExpiresAt: () => resumeExpiresAt, getResumeConfirmation: () => resumeConfirmation, @@ -148,7 +150,8 @@ export function connectMobileRelayRpcSession(args: { missedProbeLimit: RELAY_MISSED_PROBE_LIMIT, voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS, sendProbe: () => - state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }), + state === 'connected' && + sendFrame({ id: pending.nextId(), method: 'status.get', params: undefined }), onTimeout: (evidence) => { args.onLog?.({ id: `relay-liveness-${logSessionId}-${++logSequence}`, @@ -165,6 +168,7 @@ export function connectMobileRelayRpcSession(args: { return client async function confirmResume(): Promise { + dialStage.advance('confirming') try { const response = await sendRpc( 'pairing.getEndpoints', @@ -182,6 +186,10 @@ export function connectMobileRelayRpcSession(args: { resumeConfirmation = result.resumeConfirmation resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt lastConnectedAt = Date.now() + // Why: an unanswered advisory must not keep a slow relay from ever reaching connected. + await settleMobileRuntimeCapabilities((method, params) => + sendRpc(method, params, requestTimeoutMs, true) + ) livenessWatchdog.start(livenessIdentity) publishState('connected') } catch (error) { @@ -198,17 +206,17 @@ export function connectMobileRelayRpcSession(args: { if (closed || (!beforeConnected && state !== 'connected')) { return Promise.reject(new Error('relay session not connected')) } - const id = nextId() + const id = pending.nextId() return new Promise((resolve, reject) => { const timer = setTimeout(() => { - pending.delete(id) + pending.drop(id) // Why: the frame was written long ago — the desktop may have processed it. reject(markRpcDeliveryUnknown(new Error(`relay RPC timed out: ${method}`))) }, timeoutMs) - pending.set(id, { resolve, reject, timer }) + pending.track(id, { resolve, reject, timer }) if (!sendFrame({ id, method, params })) { clearTimeout(timer) - pending.delete(id) + pending.drop(id) reject(new Error('relay E2EE channel not ready')) } }) @@ -228,11 +236,7 @@ export function connectMobileRelayRpcSession(args: { if (!isRpcResponse(value)) { return } - const request = pending.get(value.id) - if (request) { - clearTimeout(request.timer) - pending.delete(value.id) - request.resolve(value) + if (pending.settle(value)) { return } streams.handleResponse(value) @@ -288,28 +292,9 @@ export function connectMobileRelayRpcSession(args: { failure = error livenessWatchdog.stop(livenessIdentity) link.close() - rejectPending(error) + pending.rejectAll(error) publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected') } - - function rejectPending(error: Error): void { - if (pending.size === 0) { - return - } - // Why: pending entries only exist after their frame reached the authenticated - // link (sendFrame failures delete them synchronously), so the desktop may - // have processed them — mark the ambiguity for callers. - markRpcDeliveryUnknown(error) - for (const request of pending.values()) { - clearTimeout(request.timer) - request.reject(error) - } - pending.clear() - } - - function nextId(): string { - return `relay-rpc-${++requestCounter}-${Date.now()}` - } } function asError(error: unknown): Error { diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index 795f2618dfb..01f4d45feb0 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -9,6 +9,7 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel import { RelayOuterError } from './mobile-relay-e2ee-link' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage' import { MobileEndpointSupervisor, type MobileEndpointSupervisorDependencies @@ -81,6 +82,10 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { // Why: production-realistic constants — fictional fake values hid three // live defects in this subsystem (latch, churn, int32 timer overflow). getAttachDeadlineAt = () => Date.now() + 10_000 + readonly dialStage = new RelayDialStageTracker() + getDialStage = () => this.dialStage.getDialStage() + onDialStageChange = (listener: (stage: RelayDialStage) => void) => + this.dialStage.onDialStageChange(listener) getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000 getResumeConfirmation = () => null getFailure = () => this.failure diff --git a/mobile/src/transport/mobile-runtime-capability-negotiation.test.ts b/mobile/src/transport/mobile-runtime-capability-negotiation.test.ts new file mode 100644 index 00000000000..6eb659d14a6 --- /dev/null +++ b/mobile/src/transport/mobile-runtime-capability-negotiation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import { negotiateMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation' +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import type { RpcResponse } from './types' + +function negotiate(args: { reject: unknown; current?: boolean }): { + onReady: ReturnType + onFailure: ReturnType +} { + const onReady = vi.fn() + const onFailure = vi.fn() + negotiateMobileRuntimeCapabilities({ + sendRequest: () => Promise.reject(args.reject), + current: () => args.current ?? true, + onReady, + onFailure + }) + return { onReady, onFailure } +} + +describe('mobile runtime capability negotiation', () => { + it('proceeds when the host never answers, so a slow link still reaches connected', async () => { + const timedOut = markRpcDeliveryUnknown( + new Error('Request timed out: runtime.clientCapabilities.update') + ) + const { onReady, onFailure } = negotiate({ reject: timedOut }) + + await vi.waitFor(() => expect(onReady).toHaveBeenCalledTimes(1)) + expect(onFailure).not.toHaveBeenCalled() + }) + + it('proceeds when the socket drops the request mid-flight', async () => { + const interrupted = markRpcDeliveryUnknown(new Error('Connection interrupted')) + const { onReady, onFailure } = negotiate({ reject: interrupted }) + + await vi.waitFor(() => expect(onReady).toHaveBeenCalledTimes(1)) + expect(onFailure).not.toHaveBeenCalled() + }) + + it('fails a socket that could not put the advisory on the wire', async () => { + const { onReady, onFailure } = negotiate({ reject: new Error('Connection interrupted') }) + + await vi.waitFor(() => expect(onFailure).toHaveBeenCalledTimes(1)) + expect(onReady).not.toHaveBeenCalled() + }) + + it('leaves a replaced session alone on an unanswered request', async () => { + const timedOut = markRpcDeliveryUnknown(new Error('Request timed out')) + const { onReady, onFailure } = negotiate({ reject: timedOut, current: false }) + + await vi.waitFor(() => expect(onReady).not.toHaveBeenCalled()) + expect(onFailure).not.toHaveBeenCalled() + }) + + it('leaves a replaced session alone on a successful response', async () => { + const onReady = vi.fn() + const onFailure = vi.fn() + negotiateMobileRuntimeCapabilities({ + sendRequest: () => + Promise.resolve({ id: 'capability-1', ok: true, result: {} } as RpcResponse), + current: () => false, + onReady, + onFailure + }) + + await vi.waitFor(() => expect(onReady).not.toHaveBeenCalled()) + expect(onFailure).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-runtime-capability-negotiation.ts b/mobile/src/transport/mobile-runtime-capability-negotiation.ts new file mode 100644 index 00000000000..7221f8e085a --- /dev/null +++ b/mobile/src/transport/mobile-runtime-capability-negotiation.ts @@ -0,0 +1,57 @@ +import { + MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD, + mobileRuntimeClientCapabilityUpdateParams +} from './mobile-runtime-client-capabilities' +import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import type { RpcResponse } from './types' + +type CapabilityRequest = (method: string, params: unknown) => Promise + +/** + * The advisory is one-way and its result is discarded, so an unanswered request says nothing about + * the link — only a frame that never reached the wire proves the socket cannot carry traffic. + * Everything else (timeout, mid-flight drop) settles like an explicit rejection: capabilities + * unavailable, proceed. Rejects for the unsent case alone. + */ +export async function settleMobileRuntimeCapabilities( + sendRequest: CapabilityRequest +): Promise { + let response: RpcResponse + try { + response = await sendRequest( + MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD, + mobileRuntimeClientCapabilityUpdateParams() + ) + } catch (error) { + if (!isRpcDeliveryUnknown(error)) { + throw error + } + console.warn('[net] mobile capability negotiation unanswered — proceeding', error) + return + } + if (!response.ok) { + console.warn('[net] mobile capability negotiation unavailable', response.error.code) + } +} + +export function negotiateMobileRuntimeCapabilities(args: { + sendRequest: CapabilityRequest + current: () => boolean + onReady: () => void + onFailure: () => void +}): void { + void settleMobileRuntimeCapabilities(args.sendRequest) + .then(() => { + if (args.current()) { + args.onReady() + } + }) + .catch((error: unknown) => { + if (!args.current()) { + return + } + // Why: nothing else force-closes a socket that cannot send before `connected` is published. + console.warn('[net] mobile capability negotiation could not be sent', error) + args.onFailure() + }) +} diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts new file mode 100644 index 00000000000..5b3dc977240 --- /dev/null +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -0,0 +1,44 @@ +import { + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' +import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities' + +export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY +]) + +export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = + 'runtime.clientCapabilities.update' as const + +export function mobileRuntimeClientCapabilityUpdateParams(): { + clientCapabilities: string[] +} { + return { clientCapabilities: [...MOBILE_RUNTIME_CLIENT_CAPABILITIES] } +} + +export function mobileRuntimeClientCapabilityUpdateRequest(args: { + id: string + deviceToken: string +}): { + id: string + deviceToken: string + method: typeof MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD + params: { clientCapabilities: string[] } +} { + return { + id: args.id, + deviceToken: args.deviceToken, + method: MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD, + params: mobileRuntimeClientCapabilityUpdateParams() + } +} + +export function advertiseMobileRuntimeClientCapabilities( + send: (request: unknown) => boolean | void, + id: string, + deviceToken: string +): void { + send(mobileRuntimeClientCapabilityUpdateRequest({ id, deviceToken })) +} diff --git a/mobile/src/transport/relay-dial-stage.ts b/mobile/src/transport/relay-dial-stage.ts new file mode 100644 index 00000000000..c4a743f84f4 --- /dev/null +++ b/mobile/src/transport/relay-dial-stage.ts @@ -0,0 +1,64 @@ +// Where a relay dial is waiting, so a bound can tell "the cell never answered the +// upgrade" from "the cell took the dial and is slow" — the two look identical from +// ConnectionState, which stays 'connecting' until relay-hello arrives. +export type RelayDialStage = + // WebSocket upgrade not yet open. + | 'opening' + // Socket open and relay-auth sent; the cell is resolving/reserving and asking the + // desktop to attach before it can answer with relay-hello. + | 'awaiting-hello' + // relay-hello accepted; E2EE handshake with the desktop in flight. + | 'handshaking' + // E2EE authenticated; waiting on the desktop's resume confirmation. + | 'confirming' + +export type RelayDialStageSource = { + getDialStage(): RelayDialStage + onDialStageChange(listener: (stage: RelayDialStage) => void): () => void +} + +export function relayDialStageSource(session: object): RelayDialStageSource | null { + const candidate = session as Partial + return typeof candidate.getDialStage === 'function' && + typeof candidate.onDialStageChange === 'function' + ? (candidate as RelayDialStageSource) + : null +} + +export class RelayDialStageTracker implements RelayDialStageSource { + private stage: RelayDialStage = 'opening' + private readonly listeners = new Set<(stage: RelayDialStage) => void>() + + getDialStage(): RelayDialStage { + return this.stage + } + + onDialStageChange(listener: (stage: RelayDialStage) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + advance(stage: RelayDialStage): void { + if (this.stage === stage) { + return + } + this.stage = stage + for (const listener of this.listeners) { + listener(stage) + } + } +} + +// Budget per stage once the cell holds the dial. awaiting-hello covers the cell's +// assignment/reservation transactions (observed 14–16s under lock contention) plus its +// 10s host-attach deadline; handshaking is two E2EE round trips; confirming is bounded +// by the session's own 30s resume-confirmation request, with slack so that error wins. +const RELAY_DIAL_STAGE_BUDGET_MS: Record, number> = { + 'awaiting-hello': 30_000, + handshaking: 12_000, + confirming: 35_000 +} + +export function relayDialStageBudgetMs(stage: Exclude): number { + return RELAY_DIAL_STAGE_BUDGET_MS[stage] +} diff --git a/mobile/src/transport/relay-pending-requests.ts b/mobile/src/transport/relay-pending-requests.ts new file mode 100644 index 00000000000..8260869d73c --- /dev/null +++ b/mobile/src/transport/relay-pending-requests.ts @@ -0,0 +1,53 @@ +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import type { RpcResponse } from './types' + +type PendingRequest = { + resolve: (response: RpcResponse) => void + reject: (error: Error) => void + timer: ReturnType +} + +/** In-flight relay RPC requests awaiting their response frame, keyed by request id. */ +export class RelayPendingRequests { + private readonly pending = new Map() + private requestCounter = 0 + + nextId(): string { + return `relay-rpc-${++this.requestCounter}-${Date.now()}` + } + + track(id: string, request: PendingRequest): void { + this.pending.set(id, request) + } + + drop(id: string): void { + this.pending.delete(id) + } + + /** Settle the waiter for this response; false when no request owns it. */ + settle(response: RpcResponse): boolean { + const request = this.pending.get(response.id) + if (!request) { + return false + } + clearTimeout(request.timer) + this.pending.delete(response.id) + request.resolve(response) + return true + } + + rejectAll(error: Error): void { + if (this.pending.size === 0) { + return + } + // Why: pending entries only exist after their frame reached the authenticated + // link (sendFrame failures delete them synchronously), so the desktop may + // have processed them — mark the ambiguity for callers. + markRpcDeliveryUnknown(error) + for (const request of this.pending.values()) { + clearTimeout(request.timer) + request.reject(error) + } + this.pending.clear() + } +} diff --git a/mobile/src/transport/replacement-session-authentication.test.ts b/mobile/src/transport/replacement-session-authentication.test.ts new file mode 100644 index 00000000000..096721fa02d --- /dev/null +++ b/mobile/src/transport/replacement-session-authentication.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayDialStageTracker } from './relay-dial-stage' +import { + ReplacementAuthenticationTimeoutError, + waitForAuthenticated +} from './replacement-session-authentication' +import type { RpcClient } from './rpc-client' +import type { ConnectionState } from './types' + +class FakeSession implements RpcClient { + readonly sendRequest = vi.fn() + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private readonly listeners = new Set<(state: ConnectionState) => void>() + constructor(private state: ConnectionState = 'connecting') {} + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + setState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +class FakeRelaySession extends FakeSession { + readonly dialStage = new RelayDialStageTracker() + getDialStage = () => this.dialStage.getDialStage() + onDialStageChange = this.dialStage.onDialStageChange.bind(this.dialStage) +} + +// Why: fake timers are active, so "still pending" is decided on the microtask queue. +async function settle( + promise: Promise +): Promise<{ status: 'pending' | 'settled'; error?: Error }> { + let outcome: { status: 'pending' | 'settled'; error?: Error } = { status: 'pending' } + void promise.then( + () => (outcome = { status: 'settled' }), + (error: Error) => (outcome = { status: 'settled', error }) + ) + await Promise.resolve() + await Promise.resolve() + return outcome +} + +describe('waitForAuthenticated', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('keeps the flat bound for a session that reports no dial stages', async () => { + const session = new FakeSession() + const waiting = waitForAuthenticated(session, 12_000) + waiting.catch(() => {}) + await vi.advanceTimersByTimeAsync(11_999) + expect((await settle(waiting)).status).toBe('pending') + await vi.advanceTimersByTimeAsync(1) + const outcome = await settle(waiting) + expect(outcome.error).toBeInstanceOf(ReplacementAuthenticationTimeoutError) + expect(outcome.error?.message).toBe('replacement session authentication timed out') + }) + + // The 2026-09-03 incident: the cell accepted relay-auth and spent 14–16s in its + // lock-contended assignment transactions. The flat 12s bound hung up 2–4s before + // the cell finished, five dials in a row, while the desktop was live the whole time. + it('re-arms the bound per stage once the cell holds the dial', async () => { + const session = new FakeRelaySession() + const waiting = waitForAuthenticated(session, 12_000) + waiting.catch(() => {}) + await vi.advanceTimersByTimeAsync(11_000) + session.dialStage.advance('awaiting-hello') + await vi.advanceTimersByTimeAsync(5_000) + expect((await settle(waiting)).status).toBe('pending') + session.dialStage.advance('handshaking') + session.setState('handshaking') + await vi.advanceTimersByTimeAsync(11_000) + expect((await settle(waiting)).status).toBe('pending') + session.dialStage.advance('confirming') + await vi.advanceTimersByTimeAsync(20_000) + session.setState('connected') + await expect(waiting).resolves.toBeUndefined() + }) + + it('bounds a cell that took the dial and never answers, naming the stage', async () => { + const session = new FakeRelaySession() + const waiting = waitForAuthenticated(session, 12_000) + waiting.catch(() => {}) + await vi.advanceTimersByTimeAsync(2_000) + session.dialStage.advance('awaiting-hello') + await vi.advanceTimersByTimeAsync(29_999) + expect((await settle(waiting)).status).toBe('pending') + await vi.advanceTimersByTimeAsync(1) + const outcome = await settle(waiting) + expect(outcome.error).toBeInstanceOf(ReplacementAuthenticationTimeoutError) + expect((outcome.error as ReplacementAuthenticationTimeoutError).stage).toBe('awaiting-hello') + expect(outcome.error?.message).toBe( + 'replacement session authentication timed out (awaiting-hello, 30s)' + ) + }) + + it('keeps the caller bound while the socket never opens', async () => { + const session = new FakeRelaySession() + const waiting = waitForAuthenticated(session, 12_000) + waiting.catch(() => {}) + await vi.advanceTimersByTimeAsync(12_000) + const outcome = await settle(waiting) + expect((outcome.error as ReplacementAuthenticationTimeoutError).stage).toBe('opening') + expect(outcome.error?.message).toBe( + 'replacement session authentication timed out (opening, 12s)' + ) + }) + + it('ignores stage advances after the wait has settled', async () => { + const session = new FakeRelaySession() + const waiting = waitForAuthenticated(session, 12_000) + session.setState('disconnected') + await expect(waiting).rejects.toThrow('replacement session disconnected') + session.dialStage.advance('awaiting-hello') + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/mobile/src/transport/replacement-session-authentication.ts b/mobile/src/transport/replacement-session-authentication.ts index 0ba54a9d611..5ef9a5f1f46 100644 --- a/mobile/src/transport/replacement-session-authentication.ts +++ b/mobile/src/transport/replacement-session-authentication.ts @@ -1,21 +1,43 @@ import type { RpcClient } from './rpc-client' +import { + relayDialStageBudgetMs, + relayDialStageSource, + type RelayDialStage +} from './relay-dial-stage' + +export class ReplacementAuthenticationTimeoutError extends Error { + constructor( + readonly stage: RelayDialStage | null, + budgetMs: number + ) { + super( + stage + ? `replacement session authentication timed out (${stage}, ${Math.round(budgetMs / 1000)}s)` + : 'replacement session authentication timed out' + ) + this.name = 'ReplacementAuthenticationTimeoutError' + } +} // Why: a migration must not cut over to a session that has only opened a socket — the -// replacement has to reach 'connected' (E2EE authenticated) first, and a relay dial can -// sit in handshaking for seconds, so the wait is bounded by the caller's timeout. +// replacement has to reach 'connected' (E2EE authenticated) first. The caller's bound +// covers reaching an open socket; a relay session that reports dial stages re-arms a +// per-stage budget on every advance, so a cell that accepted the dial and is working +// slowly (lock-contended assignment tables) is not hung up on like a black hole — the +// retry would land in the same window and burn a director round on the way. export function waitForAuthenticated(session: RpcClient, timeoutMs: number): Promise { if (session.getState() === 'connected') { return Promise.resolve() } + const stages = relayDialStageSource(session) return new Promise((resolve, reject) => { let settled = false let unsubscribe: (() => void) | null = null + let unsubscribeStage: (() => void) | null = null + let timer: ReturnType | null = null // Why: armed before subscribing — a synchronous notification during registration // must find a timer to clear, or a settled wait leaves it running for 12s. - const timer = setTimeout(() => { - finish() - reject(new Error('replacement session authentication timed out')) - }, timeoutMs) + arm(stages?.getDialStage() ?? null) unsubscribe = session.onStateChange((state) => { if (state === 'connected') { finish() @@ -29,6 +51,23 @@ export function waitForAuthenticated(session: RpcClient, timeoutMs: number): Pro // Why: the notification fired inside onStateChange, before we held the handle. unsubscribe() unsubscribe = null + } else if (stages) { + unsubscribeStage = stages.onDialStageChange((stage) => arm(stage)) + } + + function arm(stage: RelayDialStage | null): void { + if (settled) { + return + } + if (timer) { + clearTimeout(timer) + } + const budgetMs = + stage === null || stage === 'opening' ? timeoutMs : relayDialStageBudgetMs(stage) + timer = setTimeout(() => { + finish() + reject(new ReplacementAuthenticationTimeoutError(stage, budgetMs)) + }, budgetMs) } function finish(): void { @@ -36,9 +75,14 @@ export function waitForAuthenticated(session: RpcClient, timeoutMs: number): Pro return } settled = true - clearTimeout(timer) + if (timer) { + clearTimeout(timer) + timer = null + } unsubscribe?.() unsubscribe = null + unsubscribeStage?.() + unsubscribeStage = null } }) } diff --git a/mobile/src/transport/rpc-client-capabilities.test.ts b/mobile/src/transport/rpc-client-capabilities.test.ts new file mode 100644 index 00000000000..7107ae6717e --- /dev/null +++ b/mobile/src/transport/rpc-client-capabilities.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSING = MockWebSocket.CLOSING + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onclose: (() => void) | null = null + sent: string[] = [] + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + close(): void { + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } +} + +type SentRpcRequest = { id: string; method: string; params?: unknown } + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +function sentRequest(socket: MockWebSocket, method: string): SentRpcRequest { + const request = socket.sent + .map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRpcRequest) + .find((candidate) => candidate.method === method) + if (!request) { + throw new Error(`Request not sent: ${method}`) + } + return request +} + +describe('mobile rpc-client capabilities', () => { + beforeEach(() => { + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + globalThis.WebSocket = originalWebSocket + }) + + it('waits for mobile capability acknowledgement before replaying streams', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, () => {}) + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + + const capabilityRequest = sentRequest(socket, 'runtime.clientCapabilities.update') + expect(capabilityRequest.params).toMatchObject({ + clientCapabilities: expect.arrayContaining(['agent-session.structured.v1']) + }) + expect(socket.sent.some((payload) => payload.includes('session.tabs.subscribe'))).toBe(false) + + socket.receive( + `encrypted:${JSON.stringify({ + id: capabilityRequest.id, + ok: true, + result: capabilityRequest.params, + _meta: { runtimeId: 'runtime-1' } + })}` + ) + + await vi.waitFor(() => expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined()) + + client.close() + }) + + it('replays streams when an older runtime rejects capability negotiation', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, () => {}) + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + + const capabilityRequest = sentRequest(socket, 'runtime.clientCapabilities.update') + socket.receive( + `encrypted:${JSON.stringify({ + id: capabilityRequest.id, + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + })}` + ) + + await vi.waitFor(() => expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined()) + expect(client.getState()).toBe('connected') + + client.close() + }) + + it('reaches connected when a slow host never answers capability negotiation', async () => { + vi.useFakeTimers() + try { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, () => {}) + + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') + sentRequest(socket, 'runtime.clientCapabilities.update') + + // Why: the 5s capability deadline used to force-close the socket, so a link + // this slow never left 'connecting' — it just redialled forever. + await vi.advanceTimersByTimeAsync(5_001) + + expect(client.getState()).toBe('connected') + expect(sentRequest(socket, 'session.tabs.subscribe')).toBeDefined() + expect(socket.readyState).toBe(MockWebSocket.OPEN) + + client.close() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts index 24015ce829e..55a9dc60311 100644 --- a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts +++ b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts @@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-context-contract.ts b/mobile/src/transport/rpc-client-context-contract.ts index 9eb9efd4444..54e25973c7f 100644 --- a/mobile/src/transport/rpc-client-context-contract.ts +++ b/mobile/src/transport/rpc-client-context-contract.ts @@ -18,6 +18,7 @@ export type RpcClientContextValue = { disconnectHostClient: (hostId: string) => void getState: (hostId: string) => ConnectionState getKnownState: (hostId: string) => ConnectionState | null + getClientId: (hostId: string) => string | null getReconnectAttempt: (hostId: string) => number getLastConnectedAt: (hostId: string) => number | null getActivePath: (hostId: string) => MobileConnectionPath diff --git a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts index 6fb0f7d3610..eca1eca03d1 100644 --- a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts +++ b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts @@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-request-deadline.test.ts b/mobile/src/transport/rpc-client-request-deadline.test.ts index 2ed349f375a..05fe81d7944 100644 --- a/mobile/src/transport/rpc-client-request-deadline.test.ts +++ b/mobile/src/transport/rpc-client-request-deadline.test.ts @@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-request-tracker.ts b/mobile/src/transport/rpc-client-request-tracker.ts index 34edd2631e2..d96d1e97609 100644 --- a/mobile/src/transport/rpc-client-request-tracker.ts +++ b/mobile/src/transport/rpc-client-request-tracker.ts @@ -42,9 +42,28 @@ export class RpcClientRequestTracker { }) } + return this.sendConnectedRequest( + method, + params, + resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS) + ) + } + + sendAuthenticatedRequest( + method: string, + params: unknown, + timeoutMs = REQUEST_TIMEOUT_MS + ): Promise { + return this.sendConnectedRequest(method, params, timeoutMs) + } + + private sendConnectedRequest( + method: string, + params: unknown, + timeoutMs: number + ): Promise { return new Promise((resolve, reject) => { const id = this.options.nextId() - const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS) const timeout = setTimeout(() => { this.pending.delete(id) console.log('[net] sendRequest TIMEOUT', { diff --git a/mobile/src/transport/rpc-client-runtime-events.test.ts b/mobile/src/transport/rpc-client-runtime-events.test.ts index d18739423f7..04b2a90039e 100644 --- a/mobile/src/transport/rpc-client-runtime-events.test.ts +++ b/mobile/src/transport/rpc-client-runtime-events.test.ts @@ -14,6 +14,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class RuntimeEventTestSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts b/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts index 832180ad6c1..5898685bb70 100644 --- a/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts +++ b/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts @@ -17,6 +17,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + // Why: close() deliberately never fires onclose — that is the wedged-transport bug being modelled. class WedgedWebSocket { static CONNECTING = 0 diff --git a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts index f382068b735..83f40113cae 100644 --- a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts +++ b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts @@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client-unauthorized-close.test.ts b/mobile/src/transport/rpc-client-unauthorized-close.test.ts index 464d0b95808..1ba79015331 100644 --- a/mobile/src/transport/rpc-client-unauthorized-close.test.ts +++ b/mobile/src/transport/rpc-client-unauthorized-close.test.ts @@ -17,6 +17,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index a7f3bb9d82a..e88e6c220e6 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -15,6 +15,11 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +// Capability ordering has dedicated coverage; keep connection tests focused on socket behavior. +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static CONNECTING = 0 static OPEN = 1 @@ -64,36 +69,20 @@ class MockWebSocket { const mockSockets: MockWebSocket[] = [] const originalWebSocket = globalThis.WebSocket -function sentRequest(socket: MockWebSocket, method: string): { id: string; params?: unknown } { - for (const payload of socket.sent) { - const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as { - id: string - method: string - params?: unknown - } - if (decoded.method === method) { - return { id: decoded.id, params: decoded.params } - } +type SentRpcRequest = { id: string; method: string; params?: unknown } + +function sentRequest(socket: MockWebSocket, method: string): SentRpcRequest { + const request = sentRequests(socket, method)[0] + if (request) { + return request } throw new Error(`Request not sent: ${method}`) } -function sentRequests( - socket: MockWebSocket, - method: string -): Array<{ id: string; params?: unknown }> { - const requests: Array<{ id: string; params?: unknown }> = [] - for (const payload of socket.sent) { - const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as { - id: string - method: string - params?: unknown - } - if (decoded.method === method) { - requests.push({ id: decoded.id, params: decoded.params }) - } - } - return requests +function sentRequests(socket: MockWebSocket, method: string): SentRpcRequest[] { + return socket.sent + .map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRpcRequest) + .filter((request) => request.method === method) } function encodeBrowserFrame(): Uint8Array { diff --git a/mobile/src/transport/rpc-session-liveness-integration.test.ts b/mobile/src/transport/rpc-session-liveness-integration.test.ts index c446477c365..88e71a0862c 100644 --- a/mobile/src/transport/rpc-session-liveness-integration.test.ts +++ b/mobile/src/transport/rpc-session-liveness-integration.test.ts @@ -15,6 +15,10 @@ vi.mock('./e2ee', () => ({ decryptBytes: (bytes: Uint8Array) => bytes })) +vi.mock('./mobile-runtime-capability-negotiation', () => ({ + negotiateMobileRuntimeCapabilities: (args: { onReady: () => void }) => args.onReady() +})) + class MockWebSocket { static readonly CONNECTING = 0 static readonly OPEN = 1 diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index 0ba7a1f2ea7..faa236a88ca 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage' import type { ConnectionState, RpcResponse } from './types' import type { RpcClient } from './rpc-client' import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' @@ -399,6 +400,34 @@ describe('stable logical RPC client', () => { expect(client.getPendingPath()).toBeNull() }) + // Pins the shipping wiring: migrateTo's bound honors the replacement's dial stages. + it('outlives the flat bound when the relay cell holds the dial', async () => { + vi.useFakeTimers() + try { + const oldSession = new FakeSession('connected') + const replacement = Object.assign(new FakeSession('connecting'), { + dialStage: new RelayDialStageTracker(), + getDialStage(): RelayDialStage { + return this.dialStage.getDialStage() + }, + onDialStageChange(listener: (stage: RelayDialStage) => void) { + return this.dialStage.onDialStageChange(listener) + } + }) + const client = createStableLogicalRpcClient(oldSession, 'lan') + const migrating = client.migrateTo(replacement, 'relay', 12_000) + await vi.advanceTimersByTimeAsync(1_000) + replacement.dialStage.advance('awaiting-hello') + await vi.advanceTimersByTimeAsync(20_000) + expect(replacement.close).not.toHaveBeenCalled() + replacement.setState('connected') + await migrating + expect(client.getActivePath()).toBe('relay') + } finally { + vi.useRealTimers() + } + }) + it('closes a replacement that fails authentication and preserves the active session', async () => { const oldSession = new FakeSession('connected') const replacement = new FakeSession('connecting') diff --git a/package.json b/package.json index 179ffd1a82a..58f4805d937 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.178-rc.2", + "version": "1.4.197", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eac56401344..a045833577f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,7 @@ overrides: patchedDependencies: '@vscode/windows-process-tree@0.8.0': 9217ef36c01ed74127fef5512b0c92089cdbf820fd6c109dd671137eebdc7585 '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 + '@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0 '@xterm/addon-serialize@0.15.0-beta.300': 851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294 '@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e '@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d @@ -319,7 +320,7 @@ importers: version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)) '@xterm/addon-search': specifier: 0.17.0-beta.300 - version: 0.17.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)) + version: 0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)) '@xterm/addon-unicode11': specifier: 0.10.0-beta.300 version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)) @@ -9754,7 +9755,7 @@ snapshots: lru-cache: 11.5.1 opentype.js: 2.0.0 - '@xterm/addon-search@0.17.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))': + '@xterm/addon-search@0.17.0-beta.300(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))': dependencies: '@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8338c47e126..d241459f884 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,6 +44,7 @@ patchedDependencies: node-pty@1.1.0: config/patches/node-pty@1.1.0.patch '@xterm/addon-ligatures@0.11.0-beta.300': config/patches/@xterm__addon-ligatures@0.11.0-beta.300.patch '@xterm/addon-webgl@0.20.0-beta.299': config/patches/@xterm__addon-webgl@0.20.0-beta.299.patch + '@xterm/addon-search@0.17.0-beta.300': config/patches/@xterm__addon-search@0.17.0-beta.300.patch '@xterm/addon-serialize@0.15.0-beta.300': config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch '@xterm/xterm@6.1.0-beta.303': config/patches/@xterm__xterm@6.1.0-beta.303.patch lint-staged@16.4.0: config/patches/lint-staged@16.4.0.patch diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index a6ee5d73246..5a8bd4d3c6c 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -7,6 +7,7 @@ import type { CommandHandler, HandlerContext } from '../dispatch' import { printResult } from '../format' import { RuntimeClientError } from '../runtime-client' import { stripElectronRunAsNode } from '../runtime/launch' +import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentialsStrict, @@ -276,15 +277,11 @@ async function addCodexAccount({ client, json }: HandlerContext): Promise * mistake this feature exists to avoid. A `--help` note does not reach someone who * already typed the flag. */ -function rejectRemoteSelectionFlags(ctx: HandlerContext, command: string): void { - for (const flag of ['environment', 'pairing-code']) { - if (ctx.flags.has(flag)) { - throw new RuntimeClientError( - 'invalid_argument', - `\`--${flag}\` does not retarget \`${command}\`. Run it on the host whose accounts you want to manage.` - ) - } - } +function rejectAccountRemoteSelectionFlags(ctx: HandlerContext, command: string): void { + rejectRemoteSelectionFlags( + ctx.flags, + `\`${command}\`. Run it on the host whose accounts you want to manage.` + ) } async function assertAccountImportSupported({ client }: HandlerContext): Promise { @@ -316,14 +313,14 @@ export const ACCOUNT_HANDLERS: Record = { `Unsupported --agent "${agent}". Use "claude" or "codex".` ) } - rejectRemoteSelectionFlags(ctx, 'orca account add') + rejectAccountRemoteSelectionFlags(ctx, 'orca account add') // Why: fail on runtime version skew before burning a full OAuth round trip. await assertAccountImportSupported(ctx) await ctx.client.call('accounts.list', { refreshUsage: false }) await (agent === 'claude' ? addClaudeAccount(ctx) : addCodexAccount(ctx)) }, 'account list': async (ctx) => { - rejectRemoteSelectionFlags(ctx, 'orca account list') + rejectAccountRemoteSelectionFlags(ctx, 'orca account list') const { client, json } = ctx // Why: this command renders no usage numbers, so skip the forced provider // refresh — it is one serial network round-trip per managed account. diff --git a/src/cli/handlers/artifacts.ts b/src/cli/handlers/artifacts.ts index 2306dcc406a..8546d7997f3 100644 --- a/src/cli/handlers/artifacts.ts +++ b/src/cli/handlers/artifacts.ts @@ -18,6 +18,7 @@ import { ARTIFACT_SHARING_DISABLED_NEXT_STEPS } from '../../shared/artifact-sharing-gate' import type { CommandHandler, HandlerContext } from '../dispatch' +import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { RuntimeClientError } from '../runtime-client' import { formatArtifactListPage, formatArtifactShared } from '../artifact-format' import { printResult } from '../format' @@ -44,15 +45,11 @@ function cloudOptions(ctx: HandlerContext): ArtifactCloudOptions { } } -function rejectRemoteSelectionFlags(ctx: HandlerContext): void { - for (const flag of ['environment', 'pairing-code']) { - if (ctx.flags.has(flag)) { - throw new RuntimeClientError( - 'invalid_argument', - `\`--${flag}\` does not retarget artifact commands; artifacts use the signed-in desktop account.` - ) - } - } +function rejectArtifactRemoteSelectionFlags(ctx: HandlerContext): void { + rejectRemoteSelectionFlags( + ctx.flags, + 'artifact commands; artifacts use the signed-in desktop account.' + ) } function artifactContentType(path: string): ArtifactWriteRequest['contentType'] | null { @@ -165,7 +162,7 @@ function requireOperation(operation: ArtifactCloudOperation): T { export const ARTIFACT_HANDLERS: Record = { 'artifacts list': async (ctx) => { - rejectRemoteSelectionFlags(ctx) + rejectArtifactRemoteSelectionFlags(ctx) const cursor = stringFlag(ctx, 'cursor') const response = await ctx.client.call>( 'artifacts.list', @@ -178,7 +175,7 @@ export const ARTIFACT_HANDLERS: Record = { printResult({ ...response, result: value }, ctx.json, formatArtifactListPage) }, 'artifacts share': async (ctx) => { - rejectRemoteSelectionFlags(ctx) + rejectArtifactRemoteSelectionFlags(ctx) const response = await ctx.client.call>( 'artifacts.share', await readArtifactRequest(ctx) @@ -187,7 +184,7 @@ export const ARTIFACT_HANDLERS: Record = { printResult({ ...response, result: value }, ctx.json, formatArtifactShared) }, 'artifacts update': async (ctx) => { - rejectRemoteSelectionFlags(ctx) + rejectArtifactRemoteSelectionFlags(ctx) const response = await ctx.client.call>( 'artifacts.update', await readArtifactRequest(ctx) @@ -196,7 +193,7 @@ export const ARTIFACT_HANDLERS: Record = { printResult({ ...response, result: value }, ctx.json, formatArtifactShared) }, 'artifacts unshare': async (ctx) => { - rejectRemoteSelectionFlags(ctx) + rejectArtifactRemoteSelectionFlags(ctx) const remoteInput = parseRemoteArtifactInput(process.env[REMOTE_ARTIFACT_INPUT_ENV]) const sourceKey = remoteInput?.sourceKey ?? resolve(ctx.cwd, requireStringFlag(ctx, 'file')) const response = await ctx.client.call>('artifacts.unshare', { @@ -207,7 +204,7 @@ export const ARTIFACT_HANDLERS: Record = { printResult({ ...response, result: { deleted: true } }, ctx.json, () => 'Artifact deleted.') }, 'artifacts delete': async (ctx) => { - rejectRemoteSelectionFlags(ctx) + rejectArtifactRemoteSelectionFlags(ctx) const response = await ctx.client.call>('artifacts.delete', { id: requireStringFlag(ctx, 'id'), ...cloudOptions(ctx) diff --git a/src/cli/handlers/environment.ts b/src/cli/handlers/environment.ts index 2a433021769..37b437af2c3 100644 --- a/src/cli/handlers/environment.ts +++ b/src/cli/handlers/environment.ts @@ -3,6 +3,7 @@ import { formatEnvironment, formatEnvironmentList, formatHostList, printResult } import { listSshTargets } from '../host-selector-alternatives' import { getDefaultUserDataPath, RuntimeClientError } from '../runtime-client' import type { RuntimeRpcSuccess } from '../runtime-client' +import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { redactRuntimeEnvironment } from '../../shared/runtime-environments' import { addEnvironmentFromPairingCode, @@ -33,7 +34,12 @@ export const ENVIRONMENT_HANDLERS: Record = { // Why: an agent told "run it on " had nowhere to look. `orca environment list` showed // paired servers only, and nothing in the CLI listed SSH targets at all, so the wrong-axis // guess was the only move available. This is the one place that answers both. - 'host list': async ({ client, json }) => { + 'host list': async ({ client, flags, json }) => { + rejectLocalPairingStoreRetargeting( + flags, + '`orca host list`. It answers from this machine\u2019s own pairing store, so a routed answer would name servers paired with a different machine.', + 'Run `orca host list` on that machine to see the SSH targets registered there.' + ) const environments = listEnvironments(getDefaultUserDataPath()).map((environment) => ({ kind: 'environment' as const, name: environment.name, @@ -53,7 +59,12 @@ export const ENVIRONMENT_HANDLERS: Record = { ] printResult(localSuccess({ hosts }), json, formatHostList) }, - 'environment list': async ({ json }) => { + 'environment list': async ({ flags, json }) => { + rejectLocalPairingStoreRetargeting( + flags, + '`orca environment list`. Paired servers are stored on this machine, so there is no other host to ask.', + 'Run `orca environment list` on that machine to see the servers paired with it.' + ) const environments = listEnvironments(getDefaultUserDataPath()).map(redactRuntimeEnvironment) printResult(localSuccess({ environments }), json, formatEnvironmentList) }, @@ -78,6 +89,23 @@ export const ENVIRONMENT_HANDLERS: Record = { } } +/** + * These two listings are pinned local by `shouldIgnoreRemoteSelection`, so a runtime selector is + * dropped for routing. It used to still reach the SSH half of `host list` through the routed + * client, producing a listing whose SSH rows came from the named server and whose paired-server + * rows came from this machine — one answer describing two hosts, stamped `runtimeId: local`. + * Failing is the only answer that is true of a single machine. + */ +function rejectLocalPairingStoreRetargeting( + flags: Map, + suffix: string, + crossHostNextStep: string +): void { + rejectRemoteSelectionFlags(flags, suffix, { + nextSteps: [crossHostNextStep, 'Drop the flag to answer for this machine.'] + }) +} + function getRequiredStringFlag(flags: Map, name: string): string { const value = flags.get(name) if (typeof value !== 'string' || value.length === 0) { diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 72e59b86655..3ff6142275a 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -32,6 +32,10 @@ import { getOptionalStringFlag, getRequiredStringFlag } from '../flags' +import { + annotateOmittedHostScope, + type WithAnnotatedHostScope +} from '../omitted-host-scope-selectors' import { RuntimeClientError } from '../runtime-client' import { getBrowserWorktreeSelector, @@ -90,12 +94,16 @@ const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json } export const TERMINAL_HANDLERS: Record = { 'terminal list': async ({ flags, client, cwd, json }) => { - const result = await client.call('terminal.list', { - worktree: await getOptionalWorktreeSelector(flags, 'worktree', cwd, client), - limit: getOptionalPositiveIntegerFlag(flags, 'limit'), - // Why: agent JSON calls dominate; topology stays available through an explicit opt-in. - includeVisualLayouts: !json || flags.has('include-visual-layouts') - }) + const result = await client.call>( + 'terminal.list', + { + worktree: await getOptionalWorktreeSelector(flags, 'worktree', cwd, client), + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + // Why: agent JSON calls dominate; topology stays available through an explicit opt-in. + includeVisualLayouts: !json || flags.has('include-visual-layouts') + } + ) + await annotateOmittedHostScope(client, result.result) printResult(result, json, formatTerminalList) }, 'terminal show': async ({ flags, client, cwd, json }) => { diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index 484bcf9ea6d..262599234f0 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -7,6 +7,10 @@ import type { } from '../../shared/runtime-types' import type { CommandHandler } from '../dispatch' import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format' +import { + annotateOmittedHostScope, + type WithAnnotatedHostScope +} from '../omitted-host-scope-selectors' import { RuntimeClientError } from '../runtime-client' import { getOptionalNullableNumberFlag, @@ -171,16 +175,22 @@ async function getCreateRepoSelector( export const WORKTREE_HANDLERS: Record = { 'worktree ps': async ({ flags, client, json }) => { - const result = await client.call('worktree.ps', { - limit: getOptionalPositiveIntegerFlag(flags, 'limit') - }) + const result = await client.call>( + 'worktree.ps', + { limit: getOptionalPositiveIntegerFlag(flags, 'limit') } + ) + await annotateOmittedHostScope(client, result.result) printResult(result, json, formatWorktreePs) }, 'worktree list': async ({ flags, client, json }) => { - const result = await client.call('worktree.list', { - repo: getOptionalStringFlag(flags, 'repo'), - limit: getOptionalPositiveIntegerFlag(flags, 'limit') - }) + const result = await client.call>( + 'worktree.list', + { + repo: getOptionalStringFlag(flags, 'repo'), + limit: getOptionalPositiveIntegerFlag(flags, 'limit') + } + ) + await annotateOmittedHostScope(client, result.result) printResult(result, json, formatWorktreeList) }, 'worktree show': async ({ flags, client, cwd, json }) => { diff --git a/src/cli/index-local-command-routing-flags.test.ts b/src/cli/index-local-command-routing-flags.test.ts new file mode 100644 index 00000000000..b8db44915d2 --- /dev/null +++ b/src/cli/index-local-command-routing-flags.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from 'vitest' + +const { + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + removeEnvironmentMock, + resolveEnvironmentMock, + spawnMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn(), + removeEnvironmentMock: vi.fn(), + resolveEnvironmentMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: removeEnvironmentMock, + resolveEnvironment: resolveEnvironmentMock +})) + +vi.mock('child_process', async () => { + const { createChildProcessModuleMock } = await import('./index-test-harness.js') + return createChildProcessModuleMock(spawnMock) +}) + +import { main } from './index' +import { okFixture, queueFixtures } from './test-fixtures' +import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' + +const SSH_TARGET = { id: 'ssh-1777360569033-yvz2mp', label: 'openclaw' } + +/** Every SSH-target lookup answers with the one target only this machine's runtime knows about. */ +function queueSshTargetLookups(count: number): void { + queueFixtures( + callMock, + ...Array.from({ length: count }, () => okFixture('req_ssh_targets', { targets: [SSH_TARGET] })) + ) +} + +describe('runtime-selector flags on locally pinned CLI commands', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + it('answers `host list` from this machine and stamps the runtime that actually answered', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + queueSshTargetLookups(1) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['host', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed._meta.runtimeId).toBe('local') + expect(printed.result.hosts.map((host: { id: string }) => host.id)).toEqual([ + 'local', + SSH_TARGET.id, + 'env-m4air' + ]) + // The tell: `runtimeId: local` is only honest if no routed client was ever built. + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null) + }) + + it('rejects `host list --environment` instead of answering with a half-routed listing', async () => { + // Why: pre-fix this routed the SSH lookup to m4air while reading paired servers from this + // machine, dropped the openclaw row, and still stamped `_meta.runtimeId: "local"` — one + // listing describing two hosts, which reads as "m4air has no SSH targets". + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['host', 'list', '--environment', 'm4air', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.ok).toBe(false) + expect(printed.error.code).toBe('invalid_argument') + expect(printed.error.message).toContain('`--environment` does not retarget `orca host list`') + expect(process.exitCode).toBe(1) + expect(callMock).not.toHaveBeenCalled() + expect(runtimeClientConstructorMock).not.toHaveBeenCalledWith(null, 'm4air') + process.exitCode = 0 + }) + + it('rejects `environment list --environment` rather than repeating the local answer', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['environment', 'list', '--environment', 'm4air', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.ok).toBe(false) + expect(printed.error.code).toBe('invalid_argument') + expect(printed.error.message).toContain( + '`--environment` does not retarget `orca environment list`' + ) + process.exitCode = 0 + }) + + it('rejects `--pairing-code` on both listings for the same reason', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['host', 'list', '--pairing-code', 'orca://pair?code=x', '--json'], '/tmp/repo') + await main( + ['environment', 'list', '--pairing-code', 'orca://pair?code=x', '--json'], + '/tmp/repo' + ) + + for (const call of logSpy.mock.calls) { + const printed = JSON.parse(String(call[0])) + expect(printed.ok).toBe(false) + expect(printed.error.message).toContain('`--pairing-code` does not retarget') + } + expect(callMock).not.toHaveBeenCalled() + process.exitCode = 0 + }) + + it('keeps `host list` local when ORCA_ENVIRONMENT is set ambiently', async () => { + // Why: the ambient variable produced the same two-machine listing as the explicit flag, with + // no flag to reject. Pinning the family is what makes `runtimeId: local` true in both cases. + process.env.ORCA_ENVIRONMENT = 'm4air' + pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air') + queueSshTargetLookups(1) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['host', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.ok).toBe(true) + expect(printed.result.hosts.some((host: { id: string }) => host.id === SSH_TARGET.id)).toBe( + true + ) + expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null) + expect(runtimeClientConstructorMock).not.toHaveBeenCalledWith(undefined, undefined) + }) + + it('still treats --environment as the selector argument on `environment show` and `rm`', async () => { + // Why: the guard must not fire where the flag names the row to act on rather than a route. + const environment = { + id: 'env-m4air', + name: 'm4air', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: null + } + resolveEnvironmentMock.mockReturnValue(environment) + removeEnvironmentMock.mockReturnValue(environment) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['environment', 'show', '--environment', 'm4air', '--json'], '/tmp/repo') + await main(['environment', 'rm', '--environment', 'm4air', '--json'], '/tmp/repo') + + for (const call of logSpy.mock.calls) { + expect(JSON.parse(String(call[0])).ok).toBe(true) + } + }) +}) diff --git a/src/cli/index-omitted-host-scope-selectors.test.ts b/src/cli/index-omitted-host-scope-selectors.test.ts new file mode 100644 index 00000000000..1fbd77289f5 --- /dev/null +++ b/src/cli/index-omitted-host-scope-selectors.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from 'vitest' + +const { + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) + +vi.mock('child_process', async () => { + const { createChildProcessModuleMock } = await import('./index-test-harness.js') + return createChildProcessModuleMock(spawnMock) +}) + +import { main } from './index' +import { okFixture, queueFixtures } from './test-fixtures' +import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' + +const TERMINAL_ROW = { + handle: 'term_1', + ptyId: 'pty-1', + worktreeId: 'repo::/wt', + worktreePath: '/wt', + branch: 'main', + tabId: 'tab-1', + leafId: 'leaf-1', + title: 'worker', + connected: true, + writable: true, + lastOutputAt: null, + preview: '', + executionHostId: 'local' +} + +describe('omittedHostIds selector annotation', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + it('marks a stale runtime host that no caller can select', async () => { + // Why: `omittedHostIds` is built from the runtime's own bookkeeping, so it names `runtime:` + // ids for servers that are no longer paired. An agent looping over the list to complete a + // partial listing hard-errors on those — 6 of 9 in the recorded QA run. + pairRuntimeEnvironment(listEnvironmentsMock, 'env-paired', 'm4air') + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [TERMINAL_ROW], + totalCount: 1, + truncated: false, + hostScope: { + hostIds: ['local'], + omittedHostIds: ['runtime:env-paired', 'runtime:env-retired'] + } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.result.hostScope.omittedHostIds).toEqual([ + 'runtime:env-paired', + 'runtime:env-retired' + ]) + expect(printed.result.hostScope.omittedHostSelectors).toEqual([ + { hostId: 'runtime:env-paired', selector: '--environment m4air' }, + { hostId: 'runtime:env-retired', selector: null } + ]) + }) + + it('says which omitted hosts are not selectable in the human listing', async () => { + pairRuntimeEnvironment(listEnvironmentsMock, 'env-paired', 'm4air') + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [TERMINAL_ROW], + totalCount: 1, + truncated: false, + hostScope: { + hostIds: ['local'], + omittedHostIds: ['runtime:env-paired', 'runtime:env-retired'] + } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('runtime:env-paired (--environment m4air)') + expect(printed).toContain('runtime:env-retired (not selectable from this machine)') + }) + + it('resolves an omitted SSH host against the targets the runtime actually knows', async () => { + listEnvironmentsMock.mockReturnValue([]) + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [TERMINAL_ROW], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1', 'ssh:box-gone'] } + }), + okFixture('req_ssh_targets', { targets: [{ id: 'box-1', label: 'openclaw' }] }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.result.hostScope.omittedHostSelectors).toEqual([ + { hostId: 'ssh:box-1', selector: '--host ssh:box-1' }, + { hostId: 'ssh:box-gone', selector: null } + ]) + }) + + it('never keeps a host id out of omittedHostIds', async () => { + // Why: filtering the unreachable ones would shrink what the listing admits it did not cover. + // The gap is real whether or not this machine can name the host that owns it. + listEnvironmentsMock.mockReturnValue([]) + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [], + totalCount: 0, + truncated: false, + hostScope: { hostIds: [], omittedHostIds: ['runtime:env-retired'] } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--json'], '/tmp/repo') + + const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) + expect(printed.result.hostScope.omittedHostIds).toEqual(['runtime:env-retired']) + }) + + it('costs no extra round trip when nothing was omitted', async () => { + queueFixtures( + callMock, + okFixture('req_terminal_list', { + terminals: [TERMINAL_ROW], + totalCount: 1, + truncated: false, + hostScope: { hostIds: ['local'], omittedHostIds: [] } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--json'], '/tmp/repo') + + expect(callMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('worktree listings report their host coverage', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + it('prints a host column and the scope line for `worktree list`', async () => { + listEnvironmentsMock.mockReturnValue([]) + queueFixtures( + callMock, + okFixture('req_worktree_list', { + worktrees: [ + { + id: 'repo-ssh::/remote/wt', + branch: 'main', + path: '/remote/wt', + hostId: 'ssh:box-1', + displayName: 'remote', + parentWorktreeId: null, + childWorktreeIds: [], + linkedIssue: null, + comment: '' + } + ], + totalCount: 521, + truncated: true, + hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['runtime:env-retired'] } + }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['worktree', 'list'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('host=ssh:box-1') + expect(printed).toContain('scope: ssh:box-1') + expect(printed).toContain('runtime:env-retired (not selectable from this machine)') + expect(printed).toContain('truncated: showing 1 of 521') + }) + + it('does not claim a scope for `worktree ps` when the host reported none', async () => { + queueFixtures( + callMock, + okFixture('req_worktree_ps', { worktrees: [], totalCount: 0, truncated: false }) + ) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['worktree', 'ps'], '/tmp/repo') + + const printed = String(logSpy.mock.calls[0]?.[0]) + expect(printed).toContain('scope: unverifiable') + }) +}) diff --git a/src/cli/index-terminal-list-host-scope.test.ts b/src/cli/index-terminal-list-host-scope.test.ts index b38cd71451f..04b486df671 100644 --- a/src/cli/index-terminal-list-host-scope.test.ts +++ b/src/cli/index-terminal-list-host-scope.test.ts @@ -88,7 +88,10 @@ describe('orca terminal list host scope', () => { expect(printed.result.terminals[0].executionHostId).toBe('ssh:box-1') expect(printed.result.hostScope).toEqual({ hostIds: ['ssh:box-1'], - omittedHostIds: ['local'] + omittedHostIds: ['local'], + // The CLI annotates each omitted host with the flag that reaches it; see + // index-omitted-host-scope-selectors.test.ts. + omittedHostSelectors: [{ hostId: 'local', selector: '--host local' }] }) }) diff --git a/src/cli/index.ts b/src/cli/index.ts index c29bfff7060..9389113b195 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -31,6 +31,10 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { commandPath[0] === 'account' || commandPath[0] === 'artifacts' || commandPath[0] === 'environment' || + // Why: `host list` answers "what can this machine target, and with what flag". Half of that + // answer (paired servers) is read from this machine's own pairing store and cannot be routed, + // so routing the other half produced one listing describing two machines at once. + commandPath[0] === 'host' || commandPath[0] === 'serve' || commandPath[0] === 'agent' || commandPath[0] === 'vm' || diff --git a/src/cli/omitted-host-scope-selectors.ts b/src/cli/omitted-host-scope-selectors.ts new file mode 100644 index 00000000000..2666b116375 --- /dev/null +++ b/src/cli/omitted-host-scope-selectors.ts @@ -0,0 +1,126 @@ +import { + parseExecutionHostId, + type ExecutionHostId, + type ParsedExecutionHost +} from '../shared/execution-host' +import type { RuntimeListingHostScope } from '../shared/runtime-listing-host-scope' +import { + findEnvironmentByName, + findSshTargetByName, + listSshTargets, + type SshTargetSummary +} from './host-selector-alternatives' +import type { RuntimeClient } from './runtime-client' + +export type OmittedHostScopeSelector = { + hostId: ExecutionHostId + /** The flag that routes a follow-up query to this host, or null when it names nothing here. */ + selector: string | null +} + +/** A host scope annotated on this machine. The runtime never sends `omittedHostSelectors`. */ +export type ListingHostScopeWithSelectors = RuntimeListingHostScope & { + omittedHostSelectors?: OmittedHostScopeSelector[] +} + +export type WithAnnotatedHostScope = Omit & { + hostScope?: ListingHostScopeWithSelectors +} + +/** + * Resolves how to reach each host a listing did not cover. + * + * `hostScope` is the documented way to complete a partial listing, but `omittedHostIds` is built + * from the runtime's own bookkeeping — repos, folder workspaces, and workspace sessions — so it + * names `runtime:` ids for servers that are no longer paired. An agent looping over the list to + * finish the job hard-errors on those. + * + * The ids are kept rather than filtered: dropping one would shrink what the listing admits it did + * not cover, and `docs/reference/ssh-execution-boundary.md` requires a listing to name its gaps. + * A `null` selector marks the ones this machine cannot name, which is the part a caller needs. + * Only the local pairing store and SSH-target registry are consulted, so this answers "can I + * select it", never "is it up" — no host is claimed live or exited on this path. + */ +export async function resolveOmittedHostScopeSelectors( + client: RuntimeClient, + omittedHostIds: readonly ExecutionHostId[] +): Promise { + const parsed = omittedHostIds.map((hostId) => ({ + hostId, + host: parseExecutionHostId(hostId) + })) + const environments = parsed.some((entry) => entry.host?.kind === 'runtime') + ? await listPairedEnvironments() + : [] + // Why: SSH targets need a round trip, so only pay for it when an ssh host was actually omitted. + const sshTargets = parsed.some((entry) => entry.host?.kind === 'ssh') + ? await listSshTargets(client) + : [] + return parsed.map(({ hostId, host }) => ({ + hostId, + selector: resolveSelector(host, environments, sshTargets) + })) +} + +async function listPairedEnvironments(): Promise<{ id: string; name: string }[]> { + const [{ listEnvironments }, { getDefaultUserDataPath }] = await Promise.all([ + import('./runtime/environments.js'), + import('./runtime-client.js') + ]) + return listEnvironments(getDefaultUserDataPath()).map((environment) => ({ + id: environment.id, + name: environment.name + })) +} + +function resolveSelector( + host: ParsedExecutionHost | null, + environments: readonly { id: string; name: string }[], + sshTargets: readonly SshTargetSummary[] +): string | null { + if (host?.kind === 'local') { + return '--host local' + } + if (host?.kind === 'ssh') { + return findSshTargetByName(sshTargets, host.targetId) ? `--host ssh:${host.targetId}` : null + } + if (host?.kind === 'runtime') { + const environment = findEnvironmentByName(environments, host.environmentId) + return environment ? `--environment ${environment.name}` : null + } + return null +} + +/** Renders a scope line; an absent scope means the host never reported one, not full coverage. */ +export function formatListingHostScope(scope: ListingHostScopeWithSelectors | undefined): string { + if (!scope) { + return 'scope: unverifiable — this host does not report which hosts it lists' + } + const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none' + if (scope.omittedHostIds.length === 0) { + return `scope: ${covered}` + } + const selectorByHostId = new Map( + (scope.omittedHostSelectors ?? []).map((entry) => [entry.hostId, entry.selector]) + ) + const omitted = scope.omittedHostIds.map((hostId) => { + if (!selectorByHostId.has(hostId)) { + return hostId + } + const selector = selectorByHostId.get(hostId) + return selector ? `${hostId} (${selector})` : `${hostId} (not selectable from this machine)` + }) + return `scope: ${covered} — not covered: ${omitted.join(', ')}` +} + +/** Attaches the resolved selectors in place; a listing with no omitted hosts pays nothing. */ +export async function annotateOmittedHostScope( + client: RuntimeClient, + result: { hostScope?: ListingHostScopeWithSelectors } +): Promise { + const scope = result.hostScope + if (!scope || scope.omittedHostIds.length === 0) { + return + } + scope.omittedHostSelectors = await resolveOmittedHostScopeSelectors(client, scope.omittedHostIds) +} diff --git a/src/cli/remote-selection-flag-rejection.ts b/src/cli/remote-selection-flag-rejection.ts new file mode 100644 index 00000000000..e2609fa604a --- /dev/null +++ b/src/cli/remote-selection-flag-rejection.ts @@ -0,0 +1,29 @@ +import { RuntimeClientError } from './runtime/types' + +/** + * The flags that pick which runtime answers a command. `shouldIgnoreRemoteSelection` + * in `src/cli/index.ts` pins some command families to the local runtime, which drops + * these silently — so every pinned family pairs the pin with this rejection instead. + */ +export const REMOTE_SELECTION_FLAGS = ['environment', 'pairing-code'] as const + +/** + * Fails a pinned command that was given a runtime selector, rather than answering + * for a machine the caller did not name. `suffix` completes "`--` does not + * retarget …" and should say what the command answers for and where to run it. + */ +export function rejectRemoteSelectionFlags( + flags: ReadonlyMap, + suffix: string, + data?: Record +): void { + for (const flag of REMOTE_SELECTION_FLAGS) { + if (flags.has(flag)) { + throw new RuntimeClientError( + 'invalid_argument', + `\`--${flag}\` does not retarget ${suffix}`, + data + ) + } + } +} diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 112d9528d2e..f2236ef86e9 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -1,5 +1,6 @@ import type { CommandSpec } from '../args' import { GLOBAL_FLAGS } from '../args' +import { WORKTREE_LISTING_SCOPE_NOTES } from './worktree-listing-scope-notes' import { SERVE_COMMAND_SPECS } from './serve' import { TERMINAL_CLOSE_COMMAND_SPEC } from './terminal-close' @@ -65,7 +66,8 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'list'], summary: 'List Orca-managed worktrees', usage: 'orca worktree list [--repo ] [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit'] + allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit'], + notes: [...WORKTREE_LISTING_SCOPE_NOTES] }, { path: ['worktree', 'show'], @@ -180,7 +182,8 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'ps'], summary: 'Show a compact orchestration summary across worktrees', usage: 'orca worktree ps [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit'] + allowedFlags: [...GLOBAL_FLAGS, 'limit'], + notes: [...WORKTREE_LISTING_SCOPE_NOTES] }, { path: ['terminal', 'list'], diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts index d6ceb795028..7bf90615270 100644 --- a/src/cli/specs/environment.ts +++ b/src/cli/specs/environment.ts @@ -10,7 +10,8 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'Answers "what can I target and what do I pass" in one place: this machine, the SSH targets registered on it, and the Orca servers paired with it.', 'The three kinds are reached differently. A paired Orca server is a connection, selected with --environment . An SSH target is a machine the connected Orca host reaches, selected with --host ssh:. Passing one where the other belongs is the most common way to get an empty or missing-host answer.', - "SSH targets are read from the Orca host you are currently connected to, so this lists that host's targets and not another server's." + "SSH targets are read from this machine's own Orca runtime, so this lists that machine's targets and not another server's. Run `orca host list` on the other machine to see the targets registered there.", + '--environment and --pairing-code are rejected rather than ignored: paired servers come from this machine\u2019s pairing store, so a routed answer would describe two machines at once.' ], examples: ['orca host list', 'orca host list --json'] }, @@ -25,7 +26,10 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ path: ['environment', 'list'], summary: 'List saved Orca runtime environments', usage: 'orca environment list [--json]', - allowedFlags: [...GLOBAL_FLAGS] + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'Answers from this machine\u2019s pairing store. --environment and --pairing-code are rejected rather than ignored, because there is no other host that could answer.' + ] }, { path: ['environment', 'show'], diff --git a/src/cli/specs/worktree-listing-scope-notes.ts b/src/cli/specs/worktree-listing-scope-notes.ts new file mode 100644 index 00000000000..91449cc6584 --- /dev/null +++ b/src/cli/specs/worktree-listing-scope-notes.ts @@ -0,0 +1,6 @@ +/** Shared by `worktree list` and `worktree ps`, which report host coverage the same way. */ +export const WORKTREE_LISTING_SCOPE_NOTES: readonly string[] = [ + 'Each row carries the execution host that owns it (`host=`), and the trailing `scope:` line names every host the page covers plus the ones it does not.', + 'A host named under `not covered` may still have workspaces; an empty answer for it is not evidence that it has none. Each is annotated with the flag that reaches it, or marked not selectable from this machine.', + 'The row cap is shared across hosts, so a host whose rows sort last is not starved out of the page.' +] diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index edf4cbaa22c..e61a2e48b76 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -1,10 +1,10 @@ import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict' import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy' +import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors' import type { RuntimeTerminalClose, RuntimeTerminalCreate, RuntimeTerminalFocus, - RuntimeTerminalListHostScope, RuntimeTerminalListResult, RuntimeTerminalVisualLayout, RuntimeTerminalVisualLayoutNode, @@ -18,8 +18,10 @@ import type { RuntimeTerminalWait } from '../shared/runtime-types' -export function formatTerminalList(result: RuntimeTerminalListResult): string { - const scope = formatTerminalListHostScope(result.hostScope) +export function formatTerminalList( + result: WithAnnotatedHostScope +): string { + const scope = formatListingHostScope(result.hostScope) if (result.terminals.length === 0) { return `No terminals listed.\n${scope}` } @@ -37,18 +39,6 @@ export function formatTerminalList(result: RuntimeTerminalListResult): string { : bodyWithScope } -// Why: a listing that does not say what it covers reads as absolute, and an -// absent scope means the host is too old to know — not that it covered everything. -function formatTerminalListHostScope(scope: RuntimeTerminalListHostScope | undefined): string { - if (!scope) { - return 'scope: unverifiable — this host does not report which hosts it lists' - } - const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none' - const omitted = - scope.omittedHostIds.length > 0 ? ` — not covered: ${scope.omittedHostIds.join(', ')}` : '' - return `scope: ${covered}${omitted}` -} - function formatTerminalVisualLayouts( layouts: readonly RuntimeTerminalVisualLayout[] | undefined ): string | null { diff --git a/src/cli/workspace-format.ts b/src/cli/workspace-format.ts index 8cdfce86b74..51a0369978b 100644 --- a/src/cli/workspace-format.ts +++ b/src/cli/workspace-format.ts @@ -7,6 +7,7 @@ import type { RuntimeWorktreeRecord } from '../shared/runtime-types' import type { MemorySnapshot, WorktreeMemory } from '../shared/process-stats-types' +import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors' export function formatMemorySnapshot(snapshot: MemorySnapshot): string { const topWorktrees = [...snapshot.worktrees].sort((a, b) => b.memory - a.memory).slice(0, 10) @@ -130,19 +131,21 @@ export function formatEnvironment(environment: PublicKnownRuntimeEnvironment): s ].join('\n') } -export function formatWorktreePs(result: RuntimeWorktreePsResult): string { +export function formatWorktreePs(result: WithAnnotatedHostScope): string { + const scope = formatListingHostScope(result.hostScope) if (result.worktrees.length === 0) { - return 'No worktrees found.' + return `No worktrees found.\n${scope}` } const body = result.worktrees .map( (worktree) => - `${worktree.repo} ${worktree.branch} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}` + `${worktree.repo} ${worktree.branch} host=${worktree.hostId ?? 'unverifiable'} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}` ) .join('\n\n') + const bodyWithScope = `${body}\n\n${scope}` return result.truncated - ? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` - : body + ? `${bodyWithScope}\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` + : bodyWithScope } export function formatRepoList(result: RuntimeRepoList): string { @@ -168,19 +171,23 @@ export function formatRepoRefs(result: RuntimeRepoSearchRefs): string { return result.truncated ? `${result.refs.join('\n')}\n\ntruncated: yes` : result.refs.join('\n') } -export function formatWorktreeList(result: RuntimeWorktreeListResult): string { +export function formatWorktreeList( + result: WithAnnotatedHostScope +): string { + const scope = formatListingHostScope(result.hostScope) if (result.worktrees.length === 0) { - return 'No worktrees found.' + return `No worktrees found.\n${scope}` } const body = result.worktrees .map((worktree) => { const childCount = worktree.childWorktreeIds?.length ?? 0 - return `${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nparentWorktreeId: ${String(worktree.parentWorktreeId ?? 'null')}\nchildWorktreeIds: ${childCount > 0 ? worktree.childWorktreeIds.join(',') : '[]'}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}` + return `${String(worktree.id)} ${String(worktree.branch)} host=${String(worktree.hostId ?? 'unverifiable')} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nparentWorktreeId: ${String(worktree.parentWorktreeId ?? 'null')}\nchildWorktreeIds: ${childCount > 0 ? worktree.childWorktreeIds.join(',') : '[]'}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}` }) .join('\n\n') + const bodyWithScope = `${body}\n\n${scope}` return result.truncated - ? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` - : body + ? `${bodyWithScope}\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}` + : bodyWithScope } export function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): string { diff --git a/src/main/asar-transparent-fs.test.ts b/src/main/asar-transparent-fs.test.ts new file mode 100644 index 00000000000..f416450599d --- /dev/null +++ b/src/main/asar-transparent-fs.test.ts @@ -0,0 +1,43 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { rm } from './asar-transparent-fs' + +// Why not an asar fixture here: plain Node has no asar shim to see through, so the archive case can +// only be settled by the real binary — `host-tree-removal-asar.electron.test.ts` does that. What +// this pins is the other half: outside Electron `original-fs` does not resolve, and the helper has +// to degrade to `node:fs/promises` rather than throw at first use. +const roots: string[] = [] + +afterAll(async () => { + for (const root of roots) { + await rm(root, { recursive: true, force: true }).catch(() => {}) + } +}) + +describe('asar-transparent rm', () => { + it('removes a tree recursively where `original-fs` is unresolvable', async () => { + expect(process.versions.electron).toBeUndefined() + const root = await mkdtemp(join(tmpdir(), 'orca-asar-transparent-')) + roots.push(root) + const target = join(root, 'wt-1700000000000-abcdef01') + await mkdir(join(target, 'nested'), { recursive: true }) + await writeFile(join(target, 'nested', 'file.txt'), 'x', 'utf8') + + await expect(rm(target, { recursive: true, force: true })).resolves.toBeUndefined() + + expect(existsSync(target)).toBe(false) + expect(existsSync(root)).toBe(true) + }) + + it('honours `force: false` rather than swallowing a missing path', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-asar-transparent-')) + roots.push(root) + + await expect(rm(join(root, 'absent'), { recursive: true })).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) +}) diff --git a/src/main/asar-transparent-fs.ts b/src/main/asar-transparent-fs.ts new file mode 100644 index 00000000000..cddab843434 --- /dev/null +++ b/src/main/asar-transparent-fs.ts @@ -0,0 +1,35 @@ +// Why: Electron patches `fs` so a `*.asar` file reports `isDirectory() === true`, so Node's +// recursive `rm` descends into the archive, tries to `rmdir` a real file, and fails the parent with +// ENOTEMPTY. Every worktree that has ever run `pnpm install` carries at least one +// (`node_modules/.pnpm/electron@…/…/Electron.app/Contents/Resources/default_app.asar`), so a +// worktree removal aborts there deterministically — the residue is not a concurrent-writer race and +// no amount of retrying clears it. `original-fs` is Electron's unpatched `fs`; unlike +// `process.noAsar` it is scoped to this call rather than to the whole process, which matters because +// a multi-GB removal runs for seconds while the main process may still be loading modules out of +// `app.asar`. See `cli/appimage-payload-removal.ts` for the same bug at a call site short enough to +// use the process-global flag. + +import { rm as nodeRm } from 'node:fs/promises' +import { createRequire } from 'node:module' + +type Rm = typeof nodeRm + +let resolvedRm: Rm | undefined + +function resolveRm(): Rm { + try { + // Why require and not an import: `original-fs` only exists inside Electron, so vitest, the + // `orca` CLI and the plain-node entrypoints must resolve `node:fs/promises` instead — and there + // the shim does not exist either, so plain `fs` is already asar-transparent. + const originalFs = createRequire(__filename)('original-fs') as { promises?: { rm?: Rm } } + return typeof originalFs.promises?.rm === 'function' ? originalFs.promises.rm : nodeRm + } catch { + return nodeRm + } +} + +/** `fs.promises.rm` that sees a `*.asar` as the file it is rather than as a directory. */ +export const rm: Rm = (path, options) => { + resolvedRm ??= resolveRm() + return resolvedRm(path, options) +} diff --git a/src/main/codex/codex-app-server-process-teardown.test.ts b/src/main/codex/codex-app-server-process-teardown.test.ts index 1ddb672a531..cec8f91d081 100644 --- a/src/main/codex/codex-app-server-process-teardown.test.ts +++ b/src/main/codex/codex-app-server-process-teardown.test.ts @@ -1,7 +1,14 @@ import type { ChildProcess } from 'node:child_process' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + findSelfInitiatedTreeKills, + resetSelfInitiatedTreeKillLogForTest +} from '../crash-reporting/self-initiated-tree-kill-log' import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown' +/** Above pid_max on every supported POSIX host, so the group signal is a real ESRCH. */ +const UNREACHABLE_PGID = 2_147_483_647 + function child() { return { pid: 1234, @@ -10,6 +17,10 @@ function child() { } describe('terminateCodexAppServerProcessTree', () => { + beforeEach(() => { + resetSelfInitiatedTreeKillLogForTest() + }) + it('waits for the Windows tree kill before releasing the wrapper', async () => { const target = child() const release = Promise.withResolvers() @@ -120,6 +131,55 @@ describe('terminateCodexAppServerProcessTree', () => { expect(target.kill).not.toHaveBeenCalled() }) + /** + * `selfInitiatedTreeKillCount` decides whether a `render-process-gone` was + * ours. A group that had already exited was killed by nobody, so crediting it + * puts a suspect in the five-second window that Orca never issued. Exercised + * through the real `process.kill(-pgid)` because the swallow being tested + * lives in the production default, not in an injectable seam. + */ + it('does not claim a snapshot group that was already gone', async () => { + const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) as ChildProcess['kill'] } + + await expect( + terminateCodexAppServerProcessTree(target, undefined, { + platform: 'darwin', + captureDescendants: async () => ({ + rootPgid: UNREACHABLE_PGID, + descendants: [], + capturedAtMs: 1 + }), + terminateDescendants: async () => true + }) + ).resolves.toBe(true) + + expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + expect(findSelfInitiatedTreeKills(Date.now())).toEqual([]) + }) + + it('claims a snapshot group the signal actually reached', async () => { + const target = child() + const signalProcessGroup = vi.fn() + + await expect( + terminateCodexAppServerProcessTree(target, undefined, { + platform: 'darwin', + captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), + terminateDescendants: async () => true, + signalProcessGroup + }) + ).resolves.toBe(true) + + expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') + expect(findSelfInitiatedTreeKills(Date.now())).toEqual([ + expect.objectContaining({ + pid: 1234, + site: 'codex-app-server-teardown', + scope: 'posix-process-group' + }) + ]) + }) + it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => { const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true)) const targets = killMocks.map((kill, index) => ({ diff --git a/src/main/codex/codex-app-server-process-teardown.ts b/src/main/codex/codex-app-server-process-teardown.ts index a35ad4d3164..5a9c6e3574b 100644 --- a/src/main/codex/codex-app-server-process-teardown.ts +++ b/src/main/codex/codex-app-server-process-teardown.ts @@ -128,19 +128,24 @@ async function terminatePosixTree( if (descendantsExited && snapshot.rootPgid === rootPid) { const signalGroup = deps.signalProcessGroup ?? - ((pgid: number, signal: NodeJS.Signals) => { - try { - process.kill(-pgid, signal) - } catch { - // Group already exited. - } + ((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal)) + let groupSignalled = false + try { + signalGroup(snapshot.rootPgid, 'SIGKILL') + groupSignalled = true + } catch { + // Already-gone is still the desired outcome, but nothing here killed it, + // and a crumb for a kill we never landed is a false render-process-gone suspect. + } + if (groupSignalled) { + // Outside the try, as in terminateDedicatedPosixGroup: that catch is the + // already-gone contract, not a breadcrumb handler. + recordSelfInitiatedTreeKill({ + pid: snapshot.rootPgid, + site: 'codex-app-server-teardown', + scope: 'posix-process-group' }) - signalGroup(snapshot.rootPgid, 'SIGKILL') - recordSelfInitiatedTreeKill({ - pid: snapshot.rootPgid, - site: 'codex-app-server-teardown', - scope: 'posix-process-group' - }) + } } if (!descendantsExited) { child.kill('SIGCONT') diff --git a/src/main/crash-reporting/gone-time-system-memory.ts b/src/main/crash-reporting/gone-time-system-memory.ts deleted file mode 100644 index 7cca89d2d4b..00000000000 --- a/src/main/crash-reporting/gone-time-system-memory.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { CrashReportDetailValue } from '../../shared/crash-reporting' - -// ─── System memory at gone time ───────────────────────────────────── -// Why: the system outlives the crashed process, so this IS sampleable at -// process-gone — it separates "renderer grew huge" from "machine out of -// memory/commit", which the per-process buckets alone cannot. -// Timing honesty: this reads AFTER the crashed process's memory returned to -// the OS, so free/swapFree can look healthier than they were at kill time. -// Platform honesty: swap* exist on Windows/Linux only. On Linux `free` is -// /proc/meminfo MemFree and is NOT the pressure signal — it excludes page cache -// and other reclaimable memory; `available` (MemAvailable, Linux-only) is. On -// macOS `free` is near-meaningless (file cache and compression keep it low on -// healthy machines); fileBacked/purgeable are the only reclaimability proxy this -// API gives there, and none of these fields answers "was the machine under -// pressure" on macOS — that needs a signal Electron does not expose. - -type CrashReportDetails = Record - -export function memoryKBFieldMB(value: unknown): number | undefined { - const kb = typeof value === 'number' && Number.isFinite(value) ? value : undefined - return kb === undefined ? undefined : Math.round(Math.max(0, kb) / 1024) -} - -type SystemMemoryInfoLike = { - total?: unknown - free?: unknown - available?: unknown - swapTotal?: unknown - swapFree?: unknown - fileBacked?: unknown - purgeable?: unknown -} - -type SystemMemoryInfoReader = () => SystemMemoryInfoLike | null - -function readElectronSystemMemoryInfo(): SystemMemoryInfoLike | null { - const read = (process as NodeJS.Process & { getSystemMemoryInfo?: () => SystemMemoryInfoLike }) - .getSystemMemoryInfo - if (typeof read !== 'function') { - return null - } - try { - return read.call(process) - } catch { - return null - } -} - -let systemMemoryInfoReader: SystemMemoryInfoReader = readElectronSystemMemoryInfo - -export function setSystemMemoryInfoReaderForTest(reader: SystemMemoryInfoReader | null): void { - systemMemoryInfoReader = reader ?? readElectronSystemMemoryInfo -} - -export function getSystemMemoryAtGoneDetails(): CrashReportDetails { - const info = systemMemoryInfoReader() - if (!info) { - return {} - } - const details: CrashReportDetails = {} - const fields: readonly [keyof SystemMemoryInfoLike, string][] = [ - ['total', 'systemMemoryTotalMB'], - ['free', 'systemMemoryFreeMB'], - ['available', 'systemMemoryAvailableMB'], - ['swapTotal', 'systemMemorySwapTotalMB'], - ['swapFree', 'systemMemorySwapFreeMB'], - ['fileBacked', 'systemMemoryFileBackedMB'], - ['purgeable', 'systemMemoryPurgeableMB'] - ] - for (const [field, key] of fields) { - const mb = memoryKBFieldMB(info[field]) - if (mb !== undefined) { - details[key] = mb - } - } - return details -} diff --git a/src/main/crash-reporting/gpu-crash-fallback-decision.ts b/src/main/crash-reporting/gpu-crash-fallback-decision.ts index e962952c695..ed2034a62be 100644 --- a/src/main/crash-reporting/gpu-crash-fallback-decision.ts +++ b/src/main/crash-reporting/gpu-crash-fallback-decision.ts @@ -50,6 +50,8 @@ export class GpuCrashFallbackTracker { crashesInWindow: number } { if (this.engaged || !Number.isFinite(msSinceLaunch) || msSinceLaunch < 0) { + // Crashes landing while engaged (e.g. during a verdict wait before `disengage`) are + // not recorded, so a reported crashesInWindow can understate the actual burst. return { shouldEngageFallback: false, crashesInWindow: this.recentCrashes.length } } // Why: out-of-order arrivals would corrupt the sorted window, and a clock @@ -73,6 +75,17 @@ export class GpuCrashFallbackTracker { return this.engaged } + /** + * Re-arm after an engagement the caller decided not to act on. `recordGpuCrash` + * latches `engaged` and reports the threshold crossing exactly once, so a caller + * that discards that one report would otherwise silence safe graphics for the + * rest of the process — including a later burst it would have acted on. + * Leaves the crash window intact; only the one-shot latch is released. + */ + disengage(): void { + this.engaged = false + } + /** Crash times currently inside the window. Exposed to assert the pruning invariant. */ windowSnapshot(): readonly number[] { return [...this.recentCrashes] diff --git a/src/main/crash-reporting/pre-gone-host-memory.test.ts b/src/main/crash-reporting/pre-gone-host-memory.test.ts new file mode 100644 index 00000000000..0df13d4fee5 --- /dev/null +++ b/src/main/crash-reporting/pre-gone-host-memory.test.ts @@ -0,0 +1,379 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getSystemMemoryDetails, + setSystemMemoryInfoReaderForTest, + withSwapVolumeFreeSpace +} from './system-memory-details' +import { + readSwapVolumeFreeSpace, + setSwapVolumeFreeSpaceReaderForTest, + type SwapVolumeFreeSpace +} from './swap-volume-free-space' +import { samplePreGoneSystemMemory } from './pre-gone-host-memory' +import { + buildProcessGoneCrashDetails, + resetPreGoneCrashSamplingForTest, + samplePreGoneProcessMetrics, + startPreGoneCrashSampling +} from './process-gone-diagnostics' + +type MetricFixture = { + pid: number + creationTime: number + type: string + memory: { workingSetSize: number; peakWorkingSetSize?: number; privateBytes?: number } +} + +const { appMetricsMock } = vi.hoisted(() => ({ + appMetricsMock: vi.fn<() => MetricFixture[]>(() => []) +})) + +vi.mock('electron', () => ({ app: { getAppMetrics: appMetricsMock } })) + +const BROWSER_AND_RENDERER: MetricFixture[] = [ + { pid: 10, creationTime: 1, type: 'Browser', memory: { workingSetSize: 1024 * 250 } }, + { + pid: 11, + creationTime: 2, + type: 'Tab', + memory: { workingSetSize: 1024 * 400, peakWorkingSetSize: 1024 * 420, privateBytes: 1024 * 260 } + } +] + +const BROWSER_ONLY: MetricFixture[] = [BROWSER_AND_RENDERER[0]] + +const UNDER_COMMIT_PRESSURE = { + total: 16_000 * 1024, + free: 400 * 1024, + swapTotal: 48_000 * 1024, + swapFree: 200 * 1024 +} + +const AFTER_THE_CORPSE_RELEASED = { + total: 16_000 * 1024, + free: 3_000 * 1024, + swapTotal: 48_000 * 1024, + swapFree: 2_900 * 1024 +} + +// Commit limit ~= RAM: a disabled or fixed pagefile, which no amount of empty +// disk can grow into. `swapTotal > total` is all this API can say about that. +const FIXED_PAGEFILE_UNDER_PRESSURE = { + total: 16_000 * 1024, + free: 300 * 1024, + swapTotal: 16_100 * 1024, + swapFree: 180 * 1024 +} + +const NO_PAGEFILE_UNDER_PRESSURE = { + ...FIXED_PAGEFILE_UNDER_PRESSURE, + swapTotal: 15_900 * 1024 +} + +const BEFORE_THE_STORM = { + total: 16_000 * 1024, + free: 9_000 * 1024, + swapTotal: 48_000 * 1024, + swapFree: 30_000 * 1024 +} + +describe('pre-gone host memory', () => { + beforeEach(() => { + resetPreGoneCrashSamplingForTest() + setSystemMemoryInfoReaderForTest(null) + setSwapVolumeFreeSpaceReaderForTest(null) + appMetricsMock.mockClear() + appMetricsMock.mockReturnValue(BROWSER_AND_RENDERER) + }) + + it('carries a pre-gone host reading, not only the post-mortem one', async () => { + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' })) + await samplePreGoneSystemMemory(Date.now() - 5_000) + + // The renderer dies; its ~400 MB returns to the OS, so the gone-time read + // now shows a much healthier machine than the one that refused the alloc. + setSystemMemoryInfoReaderForTest(() => AFTER_THE_CORPSE_RELEASED) + appMetricsMock.mockReturnValue(BROWSER_ONLY) + + const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer') + + expect(details.systemMemorySwapFreeMB).toBe(2_900) + expect(details.systemMemoryPreGoneSwapFreeMB).toBe(200) + expect(details.systemMemoryPreGoneFreeMB).toBe(400) + expect(details.systemMemoryPreGoneTotalMB).toBe(16_000) + // Why: host memory keeps its own key family, so a `systemMemory` prefix scan sees both reads. + expect( + Object.keys(details).filter((key) => key.startsWith('processMetricsPreGoneSystem')) + ).toEqual([]) + }) + + // Why this decides the cluster: 200 MB available commit is only a REFUSAL when + // the pagefile cannot grow, which is what the volume's free space says. + it('reports swap-volume free space so low commit can be told from refused commit', async () => { + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' })) + await samplePreGoneSystemMemory(Date.now() - 5_000) + + const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer') + + expect(details.systemMemoryPreGoneSwapVolumeFreeMB).toBe(120) + // Which volume was measured: Windows only names the DEFAULT pagefile drive. + expect(details.systemMemoryPreGoneSwapVolume).toBe('C:') + }) + + it('omits swap-volume free space on Linux, where swap cannot grow into free disk', async () => { + // Linux swap is a fixed partition, a fixed-size swapfile, or zram; reporting + // root-fs free space next to SwapFreeMB 0 would read as headroom that is not there. + setSwapVolumeFreeSpaceReaderForTest(null) + + await expect(readSwapVolumeFreeSpace('linux')).resolves.toBeUndefined() + }) + + it('labels the reading with the pressure verdict the platform can actually give', () => { + // Windows available commit is only a REFUSAL when the pagefile cannot grow, + // which nothing here proves, so no label may read as that verdict. + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + const windowsCommit = getSystemMemoryDetails('win32') + expect(windowsCommit.systemMemoryPressureSignal).toBe('available-commit-unqualified') + expect( + withSwapVolumeFreeSpace(windowsCommit, { freeMB: 120, volume: 'C:' }, 'win32') + .systemMemoryPressureSignal + ).toBe('available-commit-volume-cotimed') + // A volume number from a different moment describes a different machine. + expect( + withSwapVolumeFreeSpace(windowsCommit, { freeMB: 120, volume: 'C:' }, 'win32', false) + .systemMemoryPressureSignal + ).toBe('available-commit-unqualified') + + setSystemMemoryInfoReaderForTest(() => ({ total: 16_000 * 1024, free: 400 * 1024 })) + expect(getSystemMemoryDetails('linux').systemMemoryPressureSignal).toBe('none') + + setSystemMemoryInfoReaderForTest(() => ({ total: 16_000 * 1024, available: 900 * 1024 })) + expect(getSystemMemoryDetails('linux').systemMemoryPressureSignal).toBe('mem-available') + + // darwin free/fileBacked/purgeable answer reclaimability, never pressure. + setSystemMemoryInfoReaderForTest(() => ({ + total: 16_000 * 1024, + free: 272 * 1024, + fileBacked: 2_694 * 1024, + purgeable: 0 + })) + expect(getSystemMemoryDetails('darwin').systemMemoryPressureSignal).toBe('none') + }) + + // Why this and not the volume number: the branch's own repro needed a pagefile + // that CANNOT grow to kill anything, and neither the pagefile maximum nor its + // drive is readable here — `swapVolumeAnchor` measures SystemRoot's volume, + // which a relocated pagefile does not live on. + it('never reads free disk as proof the pagefile could have grown', () => { + setSystemMemoryInfoReaderForTest(() => FIXED_PAGEFILE_UNDER_PRESSURE) + const fixedPagefile = withSwapVolumeFreeSpace( + getSystemMemoryDetails('win32'), + { freeMB: 812_000, volume: 'C:' }, + 'win32' + ) + // 180 MB of commit beside 812 GB of free disk: co-timed, and still not a + // verdict — reading it as "the pagefile had room" is the opposite conclusion. + expect(fixedPagefile.systemMemoryPressureSignal).toBe('available-commit-volume-cotimed') + + // The one decisive win32 case: commit limit at or below RAM means there is + // no pagefile behind it, so the floor cannot heal however empty the disk is. + setSystemMemoryInfoReaderForTest(() => NO_PAGEFILE_UNDER_PRESSURE) + expect( + withSwapVolumeFreeSpace( + getSystemMemoryDetails('win32'), + { freeMB: 812_000, volume: 'C:' }, + 'win32' + ).systemMemoryPressureSignal + ).toBe('available-commit-hard-capped') + }) + + // Why the verdict and not just the field: a statfs issued on a healthy host at + // t=0 that resolves 20 s into a commit storm prints "200 MB commit, 40 GB of + // pagefile headroom" — which reads as NOT a commit refusal, the opposite + // conclusion, under the branch's most confident label. + it('will not let a statfs that outlived its tick qualify the win32 commit verdict', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.useFakeTimers() + let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {} + try { + setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM) + setSwapVolumeFreeSpaceReaderForTest( + () => + new Promise((resolve) => { + resolveVolume = resolve + }) + ) + void samplePreGoneSystemMemory(0) + + // The storm arrives; the in-flight latch makes every tick skip the merge, + // so the pending statfs is as old as the tick that STARTED it. + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + await samplePreGoneSystemMemory(10_000) + await samplePreGoneSystemMemory(20_000) + + resolveVolume({ freeMB: 40_000, volume: 'C:' }) + await vi.advanceTimersByTimeAsync(0) + + vi.setSystemTime(20_000) + const stale = buildProcessGoneCrashDetails({}, 'renderer') + expect(stale.systemMemoryPreGoneSwapFreeMB).toBe(200) + // The pre-storm volume number still ships — but carrying its own age, and + // without promoting the verdict the analyst reads. + expect(stale.systemMemoryPreGoneSwapVolumeFreeMB).toBe(40_000) + expect(stale.systemMemoryPreGoneSampleAgeMs).toBe(0) + expect(stale.systemMemoryPreGoneSwapVolumeAgeMs).toBe(20_000) + expect(stale.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified') + + // The next tick's statfs answers on its own tick, so it qualifies again. + setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 900, volume: 'C:' })) + await samplePreGoneSystemMemory(30_000) + vi.setSystemTime(30_000) + const fresh = buildProcessGoneCrashDetails({}, 'renderer') + expect(fresh.systemMemoryPreGoneSwapVolumeFreeMB).toBe(900) + expect(fresh.systemMemoryPreGoneSwapVolumeAgeMs).toBe(0) + expect(fresh.systemMemoryPreGonePressureSignal).toBe('available-commit-volume-cotimed') + } finally { + vi.useRealTimers() + Object.defineProperty(process, 'platform', platform) + } + }) + + // Round 5: sample identity alone could not see these ticks. A host read that + // returns nothing leaves the sample object in place, so `sample === issuedFor` + // still held 25 s and two ticks later and the statfs re-qualified the verdict. + it('will not let ticks with a failed host read pass a stale statfs off as co-timed', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.useFakeTimers() + let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {} + try { + setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM) + setSwapVolumeFreeSpaceReaderForTest( + () => + new Promise((resolve) => { + resolveVolume = resolve + }) + ) + void samplePreGoneSystemMemory(0) + + // GlobalMemoryStatusEx starts failing: the sample is neither replaced nor erased. + setSystemMemoryInfoReaderForTest(() => null) + await samplePreGoneSystemMemory(10_000) + await samplePreGoneSystemMemory(20_000) + + resolveVolume({ freeMB: 40_000, volume: 'C:' }) + await vi.advanceTimersByTimeAsync(0) + + vi.setSystemTime(25_000) + const details = buildProcessGoneCrashDetails({}, 'renderer') + // 25 s of lag: the label must not say co-timed beside that age. + expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(25_000) + expect(details.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified') + } finally { + vi.useRealTimers() + Object.defineProperty(process, 'platform', platform) + } + }) + + it("arms the host sampler on its own unref'd 10 s timer, not the metric sweep's", async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const readHostMemory = vi.fn(() => UNDER_COMMIT_PRESSURE) + setSystemMemoryInfoReaderForTest(readHostMemory) + setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' })) + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval') + try { + startPreGoneCrashSampling() + + // Literal millisecond values: asserting the constants against themselves + // would let a cadence regression through, and 37 s of staleness is the bug. + expect(setIntervalSpy.mock.calls.map(([, ms]) => ms)).toEqual([60_000, 10_000]) + for (const { value } of setIntervalSpy.mock.results) { + expect((value as NodeJS.Timeout).hasRef()).toBe(false) + } + expect(readHostMemory).toHaveBeenCalledTimes(1) + + readHostMemory.mockReturnValue(AFTER_THE_CORPSE_RELEASED) + await vi.advanceTimersByTimeAsync(10_000) + // One host tick, no extra metric sweep: the two samplers run independently. + expect(readHostMemory).toHaveBeenCalledTimes(2) + expect(appMetricsMock).toHaveBeenCalledTimes(1) + + const details = buildProcessGoneCrashDetails({}, 'renderer') + expect(details.systemMemoryPreGoneSampleAgeMs).toBe(0) + expect(details.systemMemoryPreGoneSwapFreeMB).toBe(2_900) + } finally { + setIntervalSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('commits the host reading without waiting on the swap-volume statfs', async () => { + // Why: statfs is slowest during the paging storm this sampler targets, and + // a hung volume must not stall or silently skip host sampling. + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + setSwapVolumeFreeSpaceReaderForTest(() => new Promise(() => {})) + + void samplePreGoneSystemMemory(Date.now() - 5_000) + expect(buildProcessGoneCrashDetails({}, 'renderer').systemMemoryPreGoneSwapFreeMB).toBe(200) + + // A second tick still refreshes the reading while that statfs hangs. + setSystemMemoryInfoReaderForTest(() => AFTER_THE_CORPSE_RELEASED) + void samplePreGoneSystemMemory(Date.now()) + expect(buildProcessGoneCrashDetails({}, 'renderer').systemMemoryPreGoneSwapFreeMB).toBe(2_900) + }) + + it('publishes no pre-gone host keys when every memory field failed to read', async () => { + // Why not "no keys at all": the reading always carries its signal label, so a + // committed empty one would ship an age and a volume number with no memory + // numbers beside them — a disk-free figure standing in for a host reading. + setSystemMemoryInfoReaderForTest(() => ({ total: Number.NaN, free: undefined })) + await samplePreGoneSystemMemory(Date.now()) + + const details = buildProcessGoneCrashDetails({}, 'renderer') + + expect(Object.keys(details).filter((key) => key.startsWith('systemMemoryPreGone'))).toEqual([]) + }) + + it('carries the last volume reading forward, aged, instead of dropping it', async () => { + vi.useFakeTimers() + try { + setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE) + setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 42, volume: 'C:' })) + await samplePreGoneSystemMemory(0) + + // The next tick's statfs hangs — during the paging storm this targets, that + // is the normal case — so the tick has no volume reading of its own, and + // the sample that replaces the last one would otherwise drop the field. + setSwapVolumeFreeSpaceReaderForTest(() => new Promise(() => {})) + void samplePreGoneSystemMemory(10_000) + vi.setSystemTime(10_000) + + const details = buildProcessGoneCrashDetails({}, 'renderer') + expect(details.systemMemoryPreGoneSwapVolumeFreeMB).toBe(42) + expect(details.systemMemoryPreGoneSwapVolume).toBe('C:') + expect(details.systemMemoryPreGoneSampleAgeMs).toBe(0) + // Carried, not re-read: it ships at its real age, never as a fresh number. + expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(10_000) + } finally { + vi.useRealTimers() + } + }) + + it('keeps a failed host read from erasing the process-metric sample', async () => { + samplePreGoneProcessMetrics(Date.now() - 5_000) + setSystemMemoryInfoReaderForTest(() => { + throw new Error('getSystemMemoryInfo unavailable') + }) + await samplePreGoneSystemMemory(Date.now() - 5_000) + setSystemMemoryInfoReaderForTest(null) + + const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer') + + expect(details.processMetricsPreGoneRendererWorkingSetMB).toBe(400) + expect(Object.keys(details).filter((key) => key.startsWith('systemMemoryPreGone'))).toEqual([]) + }) +}) diff --git a/src/main/crash-reporting/pre-gone-host-memory.ts b/src/main/crash-reporting/pre-gone-host-memory.ts new file mode 100644 index 00000000000..0db56796750 --- /dev/null +++ b/src/main/crash-reporting/pre-gone-host-memory.ts @@ -0,0 +1,164 @@ +import type { CrashReportDetailValue } from '../../shared/crash-reporting' +import { readSwapVolumeFreeSpace } from './swap-volume-free-space' +import { + getSystemMemoryDetails, + SYSTEM_MEMORY_KEY_PREFIX, + withSwapVolumeFreeSpace +} from './system-memory-details' + +// ─── Pre-gone host memory sampling ────────────────────────────────── +// Why sample at all: the gone-time host read lands after the corpse released +// its pages, so it reports a healthier machine than the one that refused the +// allocation. +// Why 10 s and not the 60 s process-metrics cadence: at 60 s, four of five +// field OOMs carried a ~37 s old host reading — far too stale to see a +// transient commit refusal. A refusal shorter than the interval stays +// invisible; no cadence fixes that. + +export const PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS = 10_000 + +type CrashReportDetails = Record + +type PreGoneSystemMemorySample = { + details: CrashReportDetails + sampledAtMs: number + /** Tick that ISSUED the statfs now merged in — never the tick it resolved on. */ + swapVolumeSampledAtMs?: number +} + +let preGoneSample: PreGoneSystemMemorySample | null = null +let preGoneTimer: ReturnType | null = null +let swapVolumeReadInFlight = false +let samplingGeneration = 0 +let sampleTick = 0 + +const PRESSURE_SIGNAL_KEY = `${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal` + +/** + * Carries the last volume reading onto the sample that replaces its own. + * + * Why: a statfs slower than one tick would otherwise make the field vanish from + * the reports it exists for — the next tick replaces the sample wholesale, and + * the in-flight latch keeps intervening ticks from merging anything. It ships + * with its own (now larger) age and, not being co-timed, never names the label. + */ +function withCarriedSwapVolume(sample: PreGoneSystemMemorySample): PreGoneSystemMemorySample { + const previous = preGoneSample + if (!previous || previous.swapVolumeSampledAtMs === undefined) { + return sample + } + const freeMB = previous.details[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB`] + const volume = previous.details[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolume`] + if (typeof freeMB !== 'number' || typeof volume !== 'string') { + return sample + } + return { + ...sample, + details: withSwapVolumeFreeSpace(sample.details, { freeMB, volume }, process.platform, false), + swapVolumeSampledAtMs: previous.swapVolumeSampledAtMs + } +} + +function commitHostMemorySample(nowMs: number): boolean { + try { + const details = getSystemMemoryDetails() + // Why not `length === 0`: the signal label is appended unconditionally, so a + // reading that resolved no memory field at all still arrives with one key. + if (!Object.keys(details).some((key) => key !== PRESSURE_SIGNAL_KEY)) { + return false + } + preGoneSample = withCarriedSwapVolume({ details, sampledAtMs: nowMs }) + return true + } catch { + // Why: a failed read must not erase the previous good sample. + return false + } +} + +async function mergeSwapVolumeFreeSpace(issuedOnTick: number): Promise { + if (swapVolumeReadInFlight) { + return + } + swapVolumeReadInFlight = true + const generation = samplingGeneration + const issuedFor = preGoneSample + try { + const volume = await readSwapVolumeFreeSpace() + if (volume && preGoneSample && generation === samplingGeneration) { + // Why only its own tick qualifies: a statfs that outlived its tick carries a + // pre-storm volume number, and the latch makes that lag unbounded. It still + // ships beside its age, but it may not decide the verdict. + // Why the tick counter and not sample identity: a tick whose host read fails + // leaves the sample object in place, so identity alone reads as co-timed. + const coTimed = issuedOnTick === sampleTick + preGoneSample = { + ...preGoneSample, + details: withSwapVolumeFreeSpace(preGoneSample.details, volume, process.platform, coTimed), + swapVolumeSampledAtMs: issuedFor?.sampledAtMs + } + } + } catch { + // Why: the memory reading is already committed and stands on its own. + } finally { + swapVolumeReadInFlight = false + } +} + +export async function samplePreGoneSystemMemory(nowMs: number = Date.now()): Promise { + // Why commit before awaiting: the volume read is a statfs, and under the very + // paging storm this targets it is slowest — it must never delay, or (via an + // in-flight latch) skip, the cheap synchronous host reading. + const tick = ++sampleTick + if (!commitHostMemorySample(nowMs)) { + return + } + await mergeSwapVolumeFreeSpace(tick) +} + +export function startPreGoneSystemMemorySampling( + intervalMs: number = PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS +): void { + if (preGoneTimer) { + return + } + void samplePreGoneSystemMemory() + preGoneTimer = setInterval(() => void samplePreGoneSystemMemory(), intervalMs) + preGoneTimer.unref?.() +} + +export function resetPreGoneSystemMemorySamplingForTest(): void { + if (preGoneTimer) { + clearInterval(preGoneTimer) + } + preGoneTimer = null + preGoneSample = null + swapVolumeReadInFlight = false + // Why bump: an already-awaited volume read must not repopulate a reset sample. + samplingGeneration += 1 +} + +/** Keyed as `systemMemoryPreGone*` so a scan over the `systemMemory` family sees both reads. */ +export function preGoneSystemMemoryDetails(nowMs: number): CrashReportDetails { + if (!preGoneSample) { + return {} + } + const details: CrashReportDetails = { + [`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSampleAgeMs`]: Math.max( + 0, + nowMs - preGoneSample.sampledAtMs + ) + } + // Why its own age: the volume read resolves out of band, so it can be older + // than the memory reading printed beside it, and that gap must be readable. + if (preGoneSample.swapVolumeSampledAtMs !== undefined) { + details[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSwapVolumeAgeMs`] = Math.max( + 0, + nowMs - preGoneSample.swapVolumeSampledAtMs + ) + } + for (const [key, value] of Object.entries(preGoneSample.details)) { + details[`${SYSTEM_MEMORY_KEY_PREFIX}PreGone${key.slice(SYSTEM_MEMORY_KEY_PREFIX.length)}`] = + value + } + return details +} diff --git a/src/main/crash-reporting/process-gone-diagnostics.test.ts b/src/main/crash-reporting/process-gone-diagnostics.test.ts index e31645865d8..6a6ec410733 100644 --- a/src/main/crash-reporting/process-gone-diagnostics.test.ts +++ b/src/main/crash-reporting/process-gone-diagnostics.test.ts @@ -2,11 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildProcessGoneCrashDetails, collectProcessGoneMetricDetails, - resetPreGoneProcessMetricsSamplingForTest, + resetPreGoneCrashSamplingForTest, samplePreGoneProcessMetrics, - startPreGoneProcessMetricsSampling + startPreGoneCrashSampling } from './process-gone-diagnostics' -import { setSystemMemoryInfoReaderForTest } from './gone-time-system-memory' +import { setSystemMemoryInfoReaderForTest } from './system-memory-details' type MetricFixture = { pid?: number @@ -27,7 +27,7 @@ vi.mock('electron', () => ({ describe('process gone diagnostics', () => { beforeEach(() => { - resetPreGoneProcessMetricsSamplingForTest() + resetPreGoneCrashSamplingForTest() setSystemMemoryInfoReaderForTest(null) }) @@ -141,8 +141,8 @@ describe('process gone diagnostics', () => { appMetricsMock.mockReturnValue([ { pid: 30, type: 'Tab', memory: { workingSetSize: 1024 * 100 } } ]) - startPreGoneProcessMetricsSampling(1_000) - startPreGoneProcessMetricsSampling(1_000) + startPreGoneCrashSampling(1_000) + startPreGoneCrashSampling(1_000) // A crash inside the first interval already has a sample to draw from. expect(buildProcessGoneCrashDetails({}, 'renderer')).toMatchObject({ @@ -582,12 +582,12 @@ describe('process gone diagnostics', () => { it("arms an unref'd interval so sampling never holds the event loop open", () => { const setIntervalSpy = vi.spyOn(globalThis, 'setInterval') try { - startPreGoneProcessMetricsSampling(60_000) + startPreGoneCrashSampling(60_000) const timer = setIntervalSpy.mock.results[0]?.value as NodeJS.Timeout expect(timer.hasRef()).toBe(false) } finally { setIntervalSpy.mockRestore() - resetPreGoneProcessMetricsSamplingForTest() + resetPreGoneCrashSamplingForTest() } }) @@ -641,7 +641,7 @@ describe('process gone diagnostics', () => { expect(details.systemMemoryTotalMB).toBe(16_384) }) - it('samples system memory at gone time but never into the pre-gone snapshot', () => { + it('samples system memory at gone time but never into the processMetrics family', () => { appMetricsMock.mockReturnValue([{ pid: 1, type: 'Browser', memory: { workingSetSize: 0 } }]) samplePreGoneProcessMetrics() setSystemMemoryInfoReaderForTest(() => ({ @@ -658,7 +658,9 @@ describe('process gone diagnostics', () => { systemMemorySwapTotalMB: 8_192, systemMemorySwapFreeMB: 40 }) - expect(details.processMetricsPreGoneSystemMemoryTotalMB).toBeUndefined() + expect( + Object.keys(details).filter((key) => key.startsWith('processMetricsPreGoneSystem')) + ).toEqual([]) }) it('leaves records unflagged when the crashed bucket is still populated', () => { diff --git a/src/main/crash-reporting/process-gone-diagnostics.ts b/src/main/crash-reporting/process-gone-diagnostics.ts index d0bb380a2b6..bf0d735a2c7 100644 --- a/src/main/crash-reporting/process-gone-diagnostics.ts +++ b/src/main/crash-reporting/process-gone-diagnostics.ts @@ -3,7 +3,13 @@ import { sanitizeCrashReportDetails, type CrashReportDetailValue } from '../../shared/crash-reporting' -import { getSystemMemoryAtGoneDetails, memoryKBFieldMB } from './gone-time-system-memory' +import { getSystemMemoryDetails, memoryKBFieldMB } from './system-memory-details' +import { + PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS, + preGoneSystemMemoryDetails, + resetPreGoneSystemMemorySamplingForTest, + startPreGoneSystemMemorySampling +} from './pre-gone-host-memory' type ProcessMetricLike = { pid?: unknown @@ -204,8 +210,9 @@ export function samplePreGoneProcessMetrics(nowMs: number = Date.now()): void { } } -export function startPreGoneProcessMetricsSampling( - intervalMs: number = PROCESS_METRICS_PRE_GONE_SAMPLE_INTERVAL_MS +export function startPreGoneCrashSampling( + intervalMs: number = PROCESS_METRICS_PRE_GONE_SAMPLE_INTERVAL_MS, + systemMemoryIntervalMs: number = PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS ): void { if (preGoneSampleTimer) { return @@ -213,14 +220,16 @@ export function startPreGoneProcessMetricsSampling( samplePreGoneProcessMetrics() preGoneSampleTimer = setInterval(() => samplePreGoneProcessMetrics(), intervalMs) preGoneSampleTimer.unref?.() + startPreGoneSystemMemorySampling(systemMemoryIntervalMs) } -export function resetPreGoneProcessMetricsSamplingForTest(): void { +export function resetPreGoneCrashSamplingForTest(): void { if (preGoneSampleTimer) { clearInterval(preGoneSampleTimer) } preGoneSampleTimer = null preGoneSample = null + resetPreGoneSystemMemorySamplingForTest() } const PROCESS_METRICS_KEY_PREFIX = 'processMetrics' @@ -271,7 +280,7 @@ export function buildProcessGoneCrashDetails( const crashDetails: CrashReportDetails = { ...sanitizedDetails, ...liveMetricDetails, - ...getSystemMemoryAtGoneDetails() + ...getSystemMemoryDetails() } // Why: with the crasher gone, Largest names a survivor — flag that so the // live buckets are read as "everyone else", not as the crashed process. @@ -290,8 +299,10 @@ export function buildProcessGoneCrashDetails( if (liveMetricDetails[crashedBucketCountKey] === 0 || sampledSameBucketProcessVanished) { crashDetails.processMetricsCrashedProcessAbsent = true } + const nowMs = Date.now() if (preGoneSample) { - Object.assign(crashDetails, preGoneSampleDetails(preGoneSample, Date.now())) + Object.assign(crashDetails, preGoneSampleDetails(preGoneSample, nowMs)) } + Object.assign(crashDetails, preGoneSystemMemoryDetails(nowMs)) return crashDetails } diff --git a/src/main/crash-reporting/swap-volume-free-space.ts b/src/main/crash-reporting/swap-volume-free-space.ts new file mode 100644 index 00000000000..3ad40b7629b --- /dev/null +++ b/src/main/crash-reporting/swap-volume-free-space.ts @@ -0,0 +1,67 @@ +import { statfs } from 'node:fs/promises' +import path from 'node:path' + +// Why: a system-managed Windows pagefile — and a macOS swapfile — only grows +// into free space on its own volume, so low available commit is a REFUSED +// allocation only when that volume is full too. Linux is excluded on purpose: +// its swap is a fixed partition, a fixed-size swapfile, or zram, none of which +// grow into root-fs free space, so the number would read as headroom that +// cannot exist. The measured volume ships alongside because Windows only names +// the DEFAULT pagefile drive; a relocated pagefile lives elsewhere. + +const BYTES_PER_MB = 1024 * 1024 + +export type SwapVolumeFreeSpace = { + freeMB: number + /** Which volume was measured, separator-trimmed so redaction sees no path. */ + volume: string +} + +type SwapVolumeFreeSpaceReader = ( + platform: NodeJS.Platform +) => Promise + +function swapVolumeAnchor(platform: NodeJS.Platform): string | undefined { + if (platform === 'win32') { + const anchor = process.env.SystemRoot || process.env.SystemDrive + return anchor ? path.parse(anchor).root || anchor : undefined + } + return platform === 'darwin' ? path.sep : undefined +} + +function volumeLabel(root: string): string { + const trimmed = root.replace(/[\\/]+$/, '') + return trimmed.length > 0 ? trimmed : root +} + +async function statfsSwapVolumeFreeSpace( + platform: NodeJS.Platform +): Promise { + const root = swapVolumeAnchor(platform) + if (!root) { + return undefined + } + try { + const stats = await statfs(root) + const bytes = Number(stats.bsize) * Number(stats.bavail) + return Number.isFinite(bytes) + ? { freeMB: Math.round(Math.max(0, bytes) / BYTES_PER_MB), volume: volumeLabel(root) } + : undefined + } catch { + return undefined + } +} + +let swapVolumeFreeSpaceReader: SwapVolumeFreeSpaceReader = statfsSwapVolumeFreeSpace + +export function setSwapVolumeFreeSpaceReaderForTest( + reader: SwapVolumeFreeSpaceReader | null +): void { + swapVolumeFreeSpaceReader = reader ?? statfsSwapVolumeFreeSpace +} + +export function readSwapVolumeFreeSpace( + platform: NodeJS.Platform = process.platform +): Promise { + return swapVolumeFreeSpaceReader(platform) +} diff --git a/src/main/crash-reporting/system-memory-details.ts b/src/main/crash-reporting/system-memory-details.ts new file mode 100644 index 00000000000..1f2cf556faa --- /dev/null +++ b/src/main/crash-reporting/system-memory-details.ts @@ -0,0 +1,161 @@ +import type { CrashReportDetailValue } from '../../shared/crash-reporting' +import type { SwapVolumeFreeSpace } from './swap-volume-free-space' + +// ─── Host system memory for crash reports ─────────────────────────── +// Why: the system outlives the crashed process, so this IS sampleable at +// process-gone — it separates "renderer grew huge" from "machine out of +// memory/commit", which the per-process buckets alone cannot. The gone-time +// caller reads AFTER the corpse returned its pages, so free/swapFree read +// healthier than at kill time; the pre-gone sampler carries a live reading past +// that. +// Every reading is labelled `systemMemoryPressureSignal` so no report can be +// read as a pressure verdict the platform never gave: +// win32 — swapFree is MEMORYSTATUSEX.ullAvailPageFile, i.e. available +// COMMIT, which pagefile growth can heal (a 127 MB commit floor healed to +// 2029 MB mid-hold on the win-lowspec repro, killing nothing). Free space on +// the swap volume does NOT establish that it could: a fixed-size or disabled +// pagefile grows into no amount of empty disk, its maximum is unreadable +// here (needs a registry read), and the measured volume is only the DEFAULT +// pagefile drive. So a co-timed volume reading is context beside the commit +// number — `available-commit-volume-cotimed` — never a verdict. The one +// decisive win32 case is a commit limit at or below RAM: no pagefile exists +// to grow, so the floor cannot heal (`available-commit-hard-capped`). +// linux — MemAvailable is the real signal; MemFree is not (it excludes page +// cache and other reclaimable memory). +// darwin — none. `free` stays low on healthy machines and +// fileBacked/purgeable are only a reclaimability proxy. The real signal +// needs `memory_pressure -Q`; Orca's reader for it +// (src/main/memory/host-memory.ts) is on-demand, and spawning a subprocess +// on a 10 s app-lifetime timer costs more than the gap it closes. + +type CrashReportDetails = Record + +export const SYSTEM_MEMORY_KEY_PREFIX = 'systemMemory' + +export function memoryKBFieldMB(value: unknown): number | undefined { + const kb = typeof value === 'number' && Number.isFinite(value) ? value : undefined + return kb === undefined ? undefined : Math.round(Math.max(0, kb) / 1024) +} + +type SystemMemoryInfoLike = { + total?: unknown + free?: unknown + available?: unknown + swapTotal?: unknown + swapFree?: unknown + fileBacked?: unknown + purgeable?: unknown +} + +type SystemMemoryInfoReader = () => SystemMemoryInfoLike | null + +/** How far this reading may be read as a "was the host under pressure" verdict. */ +export type SystemMemoryPressureSignal = + | 'available-commit-hard-capped' + | 'available-commit-volume-cotimed' + | 'available-commit-unqualified' + | 'mem-available' + | 'none' + +function readElectronSystemMemoryInfo(): SystemMemoryInfoLike | null { + const read = (process as NodeJS.Process & { getSystemMemoryInfo?: () => SystemMemoryInfoLike }) + .getSystemMemoryInfo + if (typeof read !== 'function') { + return null + } + try { + return read.call(process) + } catch { + return null + } +} + +let systemMemoryInfoReader: SystemMemoryInfoReader = readElectronSystemMemoryInfo + +export function setSystemMemoryInfoReaderForTest(reader: SystemMemoryInfoReader | null): void { + systemMemoryInfoReader = reader ?? readElectronSystemMemoryInfo +} + +function numericDetail(details: CrashReportDetails, suffix: string): number | undefined { + const value = details[`${SYSTEM_MEMORY_KEY_PREFIX}${suffix}`] + return typeof value === 'number' ? value : undefined +} + +/** Windows commit limit = RAM + pagefile, so a limit at or below RAM has no pagefile behind it. */ +function pagefileBacksCommit(details: CrashReportDetails): boolean | undefined { + const total = numericDetail(details, 'TotalMB') + const swapTotal = numericDetail(details, 'SwapTotalMB') + return total === undefined || swapTotal === undefined ? undefined : swapTotal > total +} + +function pressureSignal( + platform: NodeJS.Platform, + details: CrashReportDetails, + volumeCoTimed = true +): SystemMemoryPressureSignal { + if (platform === 'win32' && `${SYSTEM_MEMORY_KEY_PREFIX}SwapFreeMB` in details) { + if (pagefileBacksCommit(details) === false) { + return 'available-commit-hard-capped' + } + return volumeCoTimed && `${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB` in details + ? 'available-commit-volume-cotimed' + : 'available-commit-unqualified' + } + if (platform === 'linux' && `${SYSTEM_MEMORY_KEY_PREFIX}AvailableMB` in details) { + return 'mem-available' + } + return 'none' +} + +export function getSystemMemoryDetails( + platform: NodeJS.Platform = process.platform +): CrashReportDetails { + const info = systemMemoryInfoReader() + if (!info) { + return {} + } + const details: CrashReportDetails = {} + const fields: readonly [keyof SystemMemoryInfoLike, string][] = [ + ['total', 'TotalMB'], + ['free', 'FreeMB'], + ['available', 'AvailableMB'], + ['swapTotal', 'SwapTotalMB'], + ['swapFree', 'SwapFreeMB'], + ['fileBacked', 'FileBackedMB'], + ['purgeable', 'PurgeableMB'] + ] + for (const [field, suffix] of fields) { + const mb = memoryKBFieldMB(info[field]) + if (mb !== undefined) { + details[`${SYSTEM_MEMORY_KEY_PREFIX}${suffix}`] = mb + } + } + details[`${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`] = pressureSignal(platform, details) + return details +} + +/** + * Merges the statfs-derived volume datum, which needs an await and so is only + * reachable from the periodic sampler, and relabels the reading it sits beside. + * + * `coTimed` false means the statfs outlived the tick that issued it, so this + * volume number and the commit number beside it describe different moments — + * during a pagefile-growth storm that is exactly when they diverge, and a + * pre-storm 40 GB printed next to 200 MB of commit reads as "the pagefile had + * room", the opposite conclusion. The datum still ships (with its own age), but + * only a co-timed one is named in the label. + */ +export function withSwapVolumeFreeSpace( + details: CrashReportDetails, + volume: SwapVolumeFreeSpace, + platform: NodeJS.Platform = process.platform, + coTimed = true +): CrashReportDetails { + const merged: CrashReportDetails = { + ...details, + [`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB`]: volume.freeMB, + [`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolume`]: volume.volume + } + merged[`${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`] = pressureSignal(platform, merged, coTimed) + return merged +} diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index 20cf8415d04..aa7932687ca 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -3,8 +3,7 @@ import { runPullWithDivergenceFallback } from '../../shared/git-remote-error' import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream' -import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name' -import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index' +import { resolveConfiguredGitPushTarget } from '../../shared/git-push-target-resolution' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' @@ -20,157 +19,6 @@ import { runWithGitWorktreeOperationLock } from '../../shared/git-worktree-opera export { gitPullRebaseFromBase } from './remote-rebase' -async function getConfiguredPushTarget( - worktreePath: string, - options: GitRuntimeOptions = {} -): Promise<{ remote: string; refspec: string } | null> { - try { - const { stdout: branchStdout } = await gitExecFileAsync( - ['symbolic-ref', '--quiet', '--short', 'HEAD'], - gitOptionsForWorktree(worktreePath, options) - ) - const branch = branchStdout.trim() - if (!branch) { - return null - } - - const [pushRemote, { stdout: mergeStdout }] = await Promise.all([ - getConfiguredPushRemote(worktreePath, branch, options), - gitExecFileAsync( - ['config', '--get', `branch.${branch}.merge`], - gitOptionsForWorktree(worktreePath, options) - ) - ]) - const remote = pushRemote?.remote - const mergeRef = mergeStdout.trim() - const branchRef = mergeRef.replace(/^refs\/heads\//, '') - if (!remote || !branchRef || remote === '.' || branchRef === mergeRef) { - return null - } - if (await branchMergeTargetsConfiguredBase(worktreePath, branch, remote, branchRef, options)) { - return null - } - if (!canPushConfiguredMergeBranch(pushRemote, branch, branchRef)) { - return null - } - return { remote, refspec: `HEAD:${branchRef}` } - } catch { - return null - } -} - -async function getConfigValue( - worktreePath: string, - key: string, - options: GitRuntimeOptions = {} -): Promise { - try { - const { stdout } = await gitExecFileAsync( - ['config', '--get', key], - gitOptionsForWorktree(worktreePath, options) - ) - const value = stdout.trim() - return value || null - } catch { - return null - } -} - -function isUrlValuedRemote(remote: string): boolean { - return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(remote) || /^[^@/:]+@[^:]+:.+/.test(remote) -} - -type ConfiguredPushRemote = { - remote: string - branchRemote: string | null -} - -// One `git remote -v` instead of `git remote` plus a serial `git remote get-url` -// per remote; both print the same insteadOf-expanded fetch URL. -async function findRemoteNameForUrl( - worktreePath: string, - remoteUrl: string, - options: GitRuntimeOptions = {} -): Promise { - try { - const { stdout } = await gitExecFileAsync( - ['remote', '-v'], - gitOptionsForWorktree(worktreePath, options) - ) - return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) - } catch { - return null - } -} - -async function normalizePushRemote( - worktreePath: string, - remote: string, - options: GitRuntimeOptions = {} -): Promise { - if (!isUrlValuedRemote(remote)) { - return remote - } - return (await findRemoteNameForUrl(worktreePath, remote, options)) ?? remote -} - -async function getConfiguredPushRemote( - worktreePath: string, - branch: string, - options: GitRuntimeOptions = {} -): Promise { - const branchRemote = await getConfigValue(worktreePath, `branch.${branch}.remote`, options) - const remote = - (await getConfigValue(worktreePath, `branch.${branch}.pushRemote`, options)) ?? - (await getConfigValue(worktreePath, 'remote.pushDefault', options)) ?? - branchRemote - if (!remote) { - return null - } - const normalizedRemote = await normalizePushRemote(worktreePath, remote, options) - // The two usually name the same URL; resolving it twice reads the remote table twice. - if (!branchRemote) { - return { remote: normalizedRemote, branchRemote: null } - } - return { - remote: normalizedRemote, - branchRemote: - branchRemote === remote - ? normalizedRemote - : await normalizePushRemote(worktreePath, branchRemote, options) - } -} - -async function branchMergeTargetsConfiguredBase( - worktreePath: string, - branch: string, - remote: string, - branchRef: string, - options: GitRuntimeOptions = {} -): Promise { - return gitRefTargetsBranchOnRemote( - await getConfigValue(worktreePath, `branch.${branch}.base`, options), - remote, - branchRef - ) -} - -function canPushConfiguredMergeBranch( - pushRemote: ConfiguredPushRemote | null, - branch: string, - branchRef: string -): boolean { - if (!pushRemote) { - return false - } - if (branchRef === branch) { - return true - } - // Why: branch.merge belongs to branch.remote. A pushDefault fork must not - // inherit origin/main as its destination branch. - return pushRemote.remote !== 'origin' && pushRemote.branchRemote === pushRemote.remote -} - function explicitPushTarget(target: GitPushTarget): { remote: string; refspec: string } { return { remote: target.remoteName, refspec: `HEAD:${target.branchName}` } } @@ -197,7 +45,9 @@ export async function gitPush( // from worktree config, not the upstream relationship. const target = pushTarget ? explicitPushTarget(pushTarget) - : await getConfiguredPushTarget(worktreePath, options) + : await resolveConfiguredGitPushTarget((args) => + gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)) + ) const args = [ 'push', ...(options.forceWithLease ? ['--force-with-lease'] : []), diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 4675e3a57a2..7b73739cd3f 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -77,6 +77,13 @@ describe('getStatus', () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) }) + /** `access` targets outside the git dir — i.e. working-tree probes, not conflict-marker reads. */ + function conflictFileProbes(): string[] { + return accessMock.mock.calls + .map(([target]) => String(target).replaceAll('\\', '/')) + .filter((target) => !target.includes('/.git/')) + } + it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') accessMock.mockImplementation(async (target: string) => { @@ -104,11 +111,12 @@ describe('getStatus', () => { ]) }) - it('maps deleted conflicts to deleted when the working tree file is absent', async () => { + // The 7th field of a `u` record is the working-tree mode; `000000` is how Git reports an absent path. + it('maps deleted conflicts to deleted from the porcelain working-tree mode', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: - 'u UD N... 100644 100644 000000 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n' + 'u UD N... 100644 100644 000000 000000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n' }) const result = await getStatus('/repo') @@ -120,10 +128,12 @@ describe('getStatus', () => { conflictKind: 'deleted_by_them', conflictStatus: 'unresolved' }) + expect(conflictFileProbes()).toEqual([]) }) - it('falls back to modified when the working-tree probe fails for a non-absence reason', async () => { + it('never re-probes the working tree for a conflict row, whatever the filesystem would say', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + // Every probe fails ENOENT (beforeEach) or EIO — neither may reach the row's status. accessMock.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' })) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: @@ -134,19 +144,14 @@ describe('getStatus', () => { expect(result.entries[0]?.status).toBe('modified') expect(result.entries[0]?.conflictKind).toBe('added_by_us') + expect(conflictFileProbes()).toEqual([]) }) // Why both cases normalize separators: git reports the worktree in the WSL guest namespace, and // the assertion is about which path is probed, not which separator this host's `path` emits. - it('probes the conflict working tree through the distro spelling on Windows', async () => { + it('resolves a WSL conflict row without crossing the 9p share', async () => { const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n') - accessMock.mockImplementation(async (target: string) => { - if (String(target).endsWith('new.ts')) { - return undefined - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'u DU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n' @@ -156,10 +161,12 @@ describe('getStatus', () => { const result = await getStatus('/home/me/repo/feature', { wslDistro: 'Ubuntu' }) const probed = accessMock.mock.calls.map(([target]) => String(target).replaceAll('\\', '/')) - expect(probed).toContain('//wsl.localhost/Ubuntu/home/me/repo/feature/src/new.ts') + // No `\\wsl.localhost` round trip per conflict row: the porcelain `mW` field already answered. + expect(probed).not.toContain('//wsl.localhost/Ubuntu/home/me/repo/feature/src/new.ts') + expect(conflictFileProbes()).toEqual([]) expect(result.entries[0]?.status).toBe('modified') expect(result.entries[0]?.conflictKind).toBe('deleted_by_us') - // The conflict-marker probes travel the same way. + // The conflict-marker probes still travel through the distro spelling. expect( probed.filter((target) => target.startsWith('//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/feature/') diff --git a/src/main/git/worktree-branch-removal.ts b/src/main/git/worktree-branch-removal.ts index 35385254bca..dc09311596c 100644 --- a/src/main/git/worktree-branch-removal.ts +++ b/src/main/git/worktree-branch-removal.ts @@ -7,12 +7,9 @@ import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' +import { isBranchCheckedOutInWorktreeError } from '../../shared/git-branch-delete-refusal' import type { GitWorktreeExecOptions, RemoveWorktreeOptions } from './worktree-operation-options' -import { - gitExecOptions, - isBranchCheckedOutInWorktreeError, - normalizeLocalBranchRef -} from './worktree-operation-options' +import { gitExecOptions, normalizeLocalBranchRef } from './worktree-operation-options' export async function deleteBranchAfterWorktreeRemoval( repoPath: string, diff --git a/src/main/git/worktree-operation-options.ts b/src/main/git/worktree-operation-options.ts index 9376fe63f85..6f956c6c422 100644 --- a/src/main/git/worktree-operation-options.ts +++ b/src/main/git/worktree-operation-options.ts @@ -2,6 +2,7 @@ import type { LocalBaseRefRefreshResult, LocalBaseRefUpdateSuggestion } from '../../shared/worktree/base-ref-drift-types' +import { readGitCommandFailureText } from '../../shared/git-command-failure-text' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' @@ -95,28 +96,8 @@ export function getErrorCode(error: unknown): string | undefined { : undefined } -function getErrorText(error: unknown): string { - if (typeof error === 'object' && error !== null) { - const parts: string[] = [] - if ('message' in error && typeof error.message === 'string') { - parts.push(error.message) - } - if ('stderr' in error && typeof error.stderr === 'string') { - parts.push(error.stderr) - } - return parts.join('\n') - } - return String(error) -} - export function isNotGitRepositoryError(error: unknown): boolean { - return /not a git repository/i.test(getErrorText(error)) -} - -export function isBranchCheckedOutInWorktreeError(error: unknown): boolean { - return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test( - getErrorText(error) - ) + return /not a git repository/i.test(readGitCommandFailureText(error)) } export function normalizeLocalBranchRef(branch: string): string { diff --git a/src/main/host-tree-removal-asar.electron.test.ts b/src/main/host-tree-removal-asar.electron.test.ts new file mode 100644 index 00000000000..a41a55035ac --- /dev/null +++ b/src/main/host-tree-removal-asar.electron.test.ts @@ -0,0 +1,132 @@ +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync +} from 'node:fs' +import { createRequire, isBuiltin } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { removeTreeSync } from '../shared/windows-transient-lock-removal' + +/** + * Why the real binary: Electron patches `fs` so a `*.asar` file reports `isDirectory() === true`, so + * a recursive `rm` descends into the archive, `rmdir`s a real file, and fails the parent with + * ENOTEMPTY. Plain Node has no such shim, so no in-process unit test can reproduce it — and every + * worktree that has run `pnpm install` carries a `default_app.asar`, which is what stranded 267 + * trash entries on the reporting machine. This runs the shipped `removeHostTree` against a real + * archive under the real binary. + */ +const requireFromTest = createRequire(import.meta.url) +const electronBinary = requireFromTest('electron') as string +const electronDist = join(dirname(requireFromTest.resolve('electron/package.json')), 'dist') +const FIXTURE_ASAR = [ + join(electronDist, 'Electron.app/Contents/Resources/default_app.asar'), + join(electronDist, 'resources/default_app.asar') +].find((candidate) => existsSync(candidate)) + +// Mirrors the residue reported on the failing machine, down to the depth of the blocking leaf. +const ENTRY_NAME = 'wt-1700000000000-abcdef01' +const ASAR_PARENT = 'node_modules/.pnpm/electron/node_modules/electron/dist/App/Contents/Resources' + +const roots: string[] = [] + +afterAll(() => { + for (const root of roots) { + try { + removeTreeSync(root) + } catch { + // A fixture the shim strands is exactly what this file is about; never fail teardown on it. + } + } +}) + +type ProbeResult = { failure: string | null; residue: string[] } + +function buildDriver(bundlePath: string, target: string, resultPath: string): string { + return [ + `const fs = require('node:fs')`, + `const { removeHostTree } = require(${JSON.stringify(bundlePath)})`, + // Why noAsar for the read-back: the shim would report the stranded archive as a directory here + // too, so the residue listing has to be taken with real filesystem semantics. + `const withoutAsar = (fn) => { const prev = process.noAsar; process.noAsar = true; try { return fn() } finally { process.noAsar = prev } }`, + `;(async () => {`, + ` let failure = null`, + ` try { await removeHostTree(${JSON.stringify(target)}) } catch (error) { failure = error.code ?? String(error) }`, + ` const residue = withoutAsar(() => fs.existsSync(${JSON.stringify(target)})`, + ` ? fs.readdirSync(${JSON.stringify(target)}, { recursive: true }).map(String)`, + ` : [])`, + ` fs.writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({ failure, residue }))`, + `})()` + ].join('\n') +} + +async function bundleHostTreeRemoval(outFile: string): Promise { + const { build } = await import('vite') + const result = await build({ + root: process.cwd(), + configFile: false, + logLevel: 'error', + build: { + write: false, + minify: false, + ssr: true, + rollupOptions: { + input: 'src/main/host-tree-removal.ts', + // Why mirror `isExternalMainModule` from electron.vite.config.ts exactly — CJS, and + // `original-fs` deliberately *not* externalized: the shipped bundle does not list it either, + // so if the archive-aware `rm` ever became a static import (or the bundler learned to fold + // `createRequire(...)('original-fs')`) production would silently degrade to the shimmed `fs` + // while a test that pre-externalized it kept passing. + output: { format: 'cjs' }, + external: (id: string) => isBuiltin(id) || id === 'electron' || id.startsWith('electron/') + } + } + }) + const output = (Array.isArray(result) ? result[0] : result) as { output: { code?: string }[] } + const code = output.output[0]?.code + expect(typeof code).toBe('string') + writeFileSync(outFile, code as string, 'utf8') +} + +function buildStrandedTree(root: string): string { + const target = join(root, ENTRY_NAME) + const asarParent = join(target, ...ASAR_PARENT.split('/')) + mkdirSync(asarParent, { recursive: true }) + copyFileSync(FIXTURE_ASAR as string, join(asarParent, 'default_app.asar')) + writeFileSync(join(asarParent, 'plain.txt'), 'x', 'utf8') + return target +} + +describe('removeHostTree against a tree holding an asar archive', () => { + it.runIf(FIXTURE_ASAR)( + 'removes the whole tree under the real Electron binary', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-host-tree-asar-')) + roots.push(root) + const bundlePath = join(root, 'host-tree-removal.cjs') + await bundleHostTreeRemoval(bundlePath) + const target = buildStrandedTree(root) + const resultPath = join(root, 'result.json') + const driverPath = join(root, 'driver.cjs') + writeFileSync(driverPath, buildDriver(bundlePath, target, resultPath), 'utf8') + + const run = spawnSync(electronBinary, [driverPath], { + encoding: 'utf8', + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + timeout: 60_000 + }) + expect(run.status, run.stderr?.slice(-2000)).toBe(0) + + const probe = JSON.parse(readFileSync(resultPath, 'utf8')) as ProbeResult + // Without an asar-transparent `rm` this is `ENOTEMPTY` and the residue stops at the archive, + // on every attempt, forever — it is not a race a retry can win. + expect(probe).toEqual({ failure: null, residue: [] }) + }, + 120_000 + ) +}) diff --git a/src/main/host-tree-removal.ts b/src/main/host-tree-removal.ts index a5d5d447956..f789a9861d0 100644 --- a/src/main/host-tree-removal.ts +++ b/src/main/host-tree-removal.ts @@ -1,10 +1,12 @@ // Why: every recursive host delete Orca performs (worktrees, terminal history, quarantined recovery -// generations) hits the same Windows stickiness — AV/indexers/late handle releases surface transient -// EBUSY/ENOTEMPTY/EPERM on a tree Node just emptied. One helper so no call site forgets the retries. +// generations) hits the same two hazards, so one helper exists so no call site forgets either. +// Windows stickiness — AV/indexers/late handle releases surface transient EBUSY/ENOTEMPTY/EPERM on a +// tree Node just emptied — and Electron's asar shim, which strands any tree holding a `*.asar` +// (see `asar-transparent-fs`). -import { rm } from 'node:fs/promises' import { win32 } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' +import { rm } from './asar-transparent-fs' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' import { isWslUncPath } from '../shared/wsl-paths' import { transientLockRemovalOptions } from '../shared/windows-transient-lock-removal' diff --git a/src/main/host/deferred-secret-protection-report.ts b/src/main/host/deferred-secret-protection-report.ts index 8f5be1fb047..7b4ea14367c 100644 --- a/src/main/host/deferred-secret-protection-report.ts +++ b/src/main/host/deferred-secret-protection-report.ts @@ -1,4 +1,4 @@ -import { app, type BrowserWindow } from 'electron' +import { runAfterFirstWindowShown } from '../startup/first-window-deferral' import { reportSecretProtectionGap } from './secret-protection-report' /** @@ -72,21 +72,5 @@ export function scheduleSecretProtectionGapReport({ } } - let ran = false - const run = (): void => { - if (ran) { - return - } - ran = true - clearTimeout(fallback) - // Why setImmediate: keep the blocking keyring probe off the event handler that - // reveals the window, so the reveal paints first. - setImmediate(report) - } - - const fallback = setTimeout(run, REPORT_FALLBACK_MS) - fallback.unref?.() - app.once('browser-window-created', (_event: Electron.Event, window: BrowserWindow) => { - window.once('ready-to-show', run) - }) + runAfterFirstWindowShown(report, REPORT_FALLBACK_MS) } diff --git a/src/main/i18n/main-i18n.ts b/src/main/i18n/main-i18n.ts index 8ebe21e0545..4fb548986b4 100644 --- a/src/main/i18n/main-i18n.ts +++ b/src/main/i18n/main-i18n.ts @@ -25,6 +25,7 @@ const LAZY_LOCALE_LOADERS: Record< () => Promise<{ default: Record }> > = { es: () => import('../../renderer/src/i18n/locales/es.json'), + fr: () => import('../../renderer/src/i18n/locales/fr.json'), ja: () => import('../../renderer/src/i18n/locales/ja.json'), ko: () => import('../../renderer/src/i18n/locales/ko.json'), zh: () => import('../../renderer/src/i18n/locales/zh.json') diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts index 97e0a8f9d65..8d126f557b5 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts @@ -49,6 +49,7 @@ function recordRendererBreadcrumbTrace( const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees' const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn' const REACT_COMMIT_CASCADE_BREADCRUMB = 'react_commit_cascade' +const REPLAY_GUARD_WEDGED_BREADCRUMB = 'terminal_replay_guard_wedged_release' const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ 'renderer_error', 'renderer_unhandled_rejection', @@ -56,6 +57,7 @@ const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ DUPLICATE_TAB_OWNER_BREADCRUMB, PARK_VERDICT_CHURN_BREADCRUMB, REACT_COMMIT_CASCADE_BREADCRUMB, + REPLAY_GUARD_WEDGED_BREADCRUMB, TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB ]) const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 @@ -69,6 +71,11 @@ const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 // 30-entry ring to two such bursts. `suppressedSinceLast` keeps the pane count // — the only signal these carry — in one slot. const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set(['terminal_safe_fit_retry_exhausted']) +// Why: the 30-slot ring is the scarce sink; the durable span stream is not. For +// bounded-rate pane telemetry whose multiplicity is the whole signal, spans are the +// only place a burst survives the restart that clears the ring, so coalesce the ring +// but keep every event's span. +const PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES = new Set([REPLAY_GUARD_WEDGED_BREADCRUMB]) function rendererBreadcrumbCoalesceKey( name: string, @@ -77,6 +84,13 @@ function rendererBreadcrumbCoalesceKey( if (NAME_ONLY_COALESCED_BREADCRUMB_NAMES.has(name)) { return name } + // Why presence and not value: `ptyId`/`tabIdHash` are absent on the restore call + // site (layout-serialization restoreScrollbackBuffers) and present on reattach, so + // their presence is the call-site identity a mixed burst would otherwise lose. Four + // slots per storm at most, regardless of pane count. + if (name === REPLAY_GUARD_WEDGED_BREADCRUMB) { + return `${name}:${data?.ptyId ? 'pty' : ''}:${data?.tabIdHash ? 'tab' : ''}` + } // Why trigger and not name alone: `burst` means damping engaged a commit // short of React #185, `window` means slow benign churn. Collapsing them // would drop the near-crash signal into a slow-churn slot. Still bounded — @@ -191,9 +205,13 @@ export function recordRendererBreadcrumbFromRenderer( minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS, ...(origin ? { origin } : {}) }) - // Why: tracing every suppressed duplicate would preserve the same - // serialization and disk churn that breadcrumb coalescing removes. - if (coalesceResult) { + if (PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES.has(args.name)) { + // Why the raw data: every event already gets its own span, so folding the ring's + // running count in here would double-count in any span-stream total. + recordRendererBreadcrumbTrace(args.name, data) + } else if (coalesceResult) { + // Why gated: tracing every suppressed duplicate would preserve the same + // serialization and disk churn that breadcrumb coalescing removes. recordRendererBreadcrumbTrace( args.name, coalesceResult.suppressedSinceLast > 0 diff --git a/src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts b/src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts new file mode 100644 index 00000000000..e3823f0b313 --- /dev/null +++ b/src/main/ipc/crash-reporting-replay-guard-wedge-burst.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + clearCrashBreadcrumbsForTest, + getCrashBreadcrumbSnapshot, + recordCrashBreadcrumb +} from '../crash-reporting/crash-breadcrumb-store' +import { recordRendererBreadcrumbFromRenderer } from './crash-reporting-renderer-breadcrumbs' + +type SpanOptions = { attributes: Record } +const startSpanMock = vi.fn((_name: string, _options: SpanOptions) => ({ end: () => {} })) +vi.mock('../observability/tracer', () => ({ + startSpan: (name: string, options: SpanOptions) => startSpanMock(name, options) +})) + +const WEDGE_BREADCRUMB = 'terminal_replay_guard_wedged_release' + +/** Reattach-path shape: identity-bearing (`tabIdHash`, optionally `ptyId`). */ +function emitReattachWedge(pane: number, withPtyId = false): void { + recordRendererBreadcrumbFromRenderer({ + name: WEDGE_BREADCRUMB, + data: { + paneId: pane, + leafIdHash: `leaf${String(pane).padStart(5, '0')}`, + tabIdHash: `tab${String(pane).padStart(6, '0')}`, + worktreeIdHash: 'caa15fa9', + ...(withPtyId ? { ptyId: `…@@pty-${pane}` } : {}) + } + }) +} + +/** Restore-path shape (restoreScrollbackBuffers): no tabIdHash, no ptyId. */ +function emitRestoreWedge(pane: number): void { + recordRendererBreadcrumbFromRenderer({ + name: WEDGE_BREADCRUMB, + data: { paneId: pane, leafIdHash: `leaf${String(pane).padStart(5, '0')}` } + }) +} + +function wedgeCrumbs(): ReturnType { + return getCrashBreadcrumbSnapshot().filter((entry) => entry.name === WEDGE_BREADCRUMB) +} + +function wedgeSpanCount(): number { + return startSpanMock.mock.calls.filter( + (call) => call[1].attributes['breadcrumb.name'] === WEDGE_BREADCRUMB + ).length +} + +beforeEach(() => { + startSpanMock.mockClear() +}) + +afterEach(() => { + clearCrashBreadcrumbsForTest() +}) + +// One mount/reveal/wake transition expires every in-flight replay write at once, so +// the burst reaches the 30-slot ring as N distinct entries. Field span streams measure +// bursts of 26 in 0.96s and 62 over 85s. No captured report in the 09-02 corpus shows +// a ring that actually drained — all nine bursts predate their report's ring window — +// so this bounds a demonstrated hazard, not an observed loss, and must not cost the +// durable span evidence that did carry those bursts. +describe('replay-guard wedge burst against the fixed-size breadcrumb ring', () => { + it('costs one ring slot per call site and preserves the pre-crash trail', () => { + for (let index = 0; index < 10; index += 1) { + recordCrashBreadcrumb(`pre_crash_evidence_${index}`, { index }) + } + + for (let pane = 0; pane < 26; pane += 1) { + emitReattachWedge(pane) + } + + const snapshot = getCrashBreadcrumbSnapshot() + expect(snapshot.filter((entry) => entry.name.startsWith('pre_crash_evidence_'))).toHaveLength( + 10 + ) + expect(wedgeCrumbs()).toHaveLength(1) + }) + + it('carries the burst multiplicity into the ring as suppressedSinceLast', () => { + for (let pane = 0; pane < 26; pane += 1) { + emitReattachWedge(pane) + } + + // 26 emissions: one owns the slot, 25 fold into it. + expect(wedgeCrumbs()[0]?.data?.suppressedSinceLast).toBe(25) + }) + + // The 121-event field corpus lives entirely in the renderer.breadcrumb span stream, + // and the ring is cleared by the restart that usually precedes the crash report, so + // ring coalescing must not suppress the per-event spans. + it('still emits one durable span per wedge event', () => { + for (let pane = 0; pane < 26; pane += 1) { + emitReattachWedge(pane) + } + + expect(wedgeSpanCount()).toBe(26) + // Why no count on the span: one span per event already carries the multiplicity. + expect( + startSpanMock.mock.calls.some((call) => + JSON.stringify(call[1]).includes('suppressedSinceLast') + ) + ).toBe(false) + }) + + // Bundle 8907a508 mixes restore-path (identity-less) and reattach-path crumbs in one + // window; name-only keying would report only the last one's shape. + it('keeps restore-path and reattach-path call sites in separate slots', () => { + emitRestoreWedge(1) + emitRestoreWedge(2) + emitReattachWedge(3) + emitReattachWedge(4, true) + + const crumbs = wedgeCrumbs() + expect(crumbs).toHaveLength(3) + expect(crumbs.map((crumb) => Boolean(crumb.data?.tabIdHash))).toEqual([false, true, true]) + expect(crumbs.map((crumb) => Boolean(crumb.data?.ptyId))).toEqual([false, false, true]) + }) + + it('bounds a many-pane burst to one slot within a call site', () => { + for (let pane = 0; pane < 40; pane += 1) { + emitReattachWedge(pane, pane % 2 === 0) + } + + expect(wedgeCrumbs()).toHaveLength(2) + }) +}) diff --git a/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts b/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts new file mode 100644 index 00000000000..a4d510650d6 --- /dev/null +++ b/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' +import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' +import { requireEmojiShortcodeDataset } from './deferred-emoji-shortcode-dataset' + +// Lives under src/main (not next to the shared catalog) so the shared tsconfig projects stay +// free of a src/main import — the boundary emoji-shortcode-catalog.lazy.test.ts asserts on. +describe('deferred emoji shortcode dataset', () => { + it('loads the main-side dataset synchronously into an identical catalog', async () => { + vi.resetModules() + const eager = await import('../../shared/emoji-shortcode-catalog.js') + eager.setEmojiShortcodeDatasetLoader(() => emojiShortcodes) + const eagerEntries = eager.getStandardEmojiShortcodeEntries() + const eagerTransform = eager.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}') + + vi.resetModules() + const deferred = await import('../../shared/emoji-shortcode-catalog.js') + deferred.setEmojiShortcodeDatasetLoader(requireEmojiShortcodeDataset) + + // No await between registration and first use: the require path keeps the sync contract. + expect(deferred.getStandardEmojiShortcodeEntries()).toEqual(eagerEntries) + expect(deferred.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}')).toBe( + eagerTransform + ) + }) +}) diff --git a/src/main/ipc/deferred-emoji-shortcode-dataset.ts b/src/main/ipc/deferred-emoji-shortcode-dataset.ts new file mode 100644 index 00000000000..0d499d25750 --- /dev/null +++ b/src/main/ipc/deferred-emoji-shortcode-dataset.ts @@ -0,0 +1,13 @@ +import { createRequire } from 'node:module' +import type { EmojiShortcodeDataset } from '../../shared/emoji-shortcode-catalog' + +// Why createRequire (same reason as linear-sdk.ts): a static import inlines the 166 KB +// shortcode dataset into out/main/index.js and JSON.parses it on every launch, while only +// worktree-name sanitization ever reads it. app.asar ships no node_modules, so this bare require +// resolves out of Resources/node_modules — electron-builder.config.cjs copies exactly this file +// there (the package root is 49 MB of locale data). +const requireFromMain = createRequire(__filename) + +export function requireEmojiShortcodeDataset(): EmojiShortcodeDataset { + return requireFromMain('emojibase-data/en/shortcodes/emojibase.json') as EmojiShortcodeDataset +} diff --git a/src/main/ipc/filesystem-allowed-roots.test.ts b/src/main/ipc/filesystem-allowed-roots.test.ts new file mode 100644 index 00000000000..f94c99c5fdb --- /dev/null +++ b/src/main/ipc/filesystem-allowed-roots.test.ts @@ -0,0 +1,372 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as RepoWorktrees from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' +import type * as ProjectGroupsModule from '../../shared/project-groups' +import { buildProjectGroupChildIndex, getProjectGroupSubtreeIds } from '../../shared/project-groups' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { getWorktreeMirrorDistro } from '../project-runtime-git-options' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../shared/project-group-types' +import type { Project } from '../../shared/project-types' +import type { Repo } from '../../shared/repo-types' +import { getAllowedRoots } from './filesystem-allowed-roots' +import { authorizeExternalPath, resolveAuthorizedPath } from './filesystem-auth' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' +import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' + +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } +}) + +vi.mock('../../shared/project-groups', async () => { + const actual = await vi.importActual('../../shared/project-groups') + return { + ...actual, + buildProjectGroupChildIndex: vi.fn(actual.buildProjectGroupChildIndex), + getProjectGroupSubtreeIds: vi.fn(actual.getProjectGroupSubtreeIds) + } +}) + +type StoreFixture = { + repos: Repo[] + projects: Project[] + projectGroups: ProjectGroup[] + folderWorkspaces: FolderWorkspace[] + workspaceDir?: string +} + +type StoreCallCounts = { + getRepos: number + getProjects: number + getProjectGroups: number + getFolderWorkspaces: number +} + +function makeCountingStore(fixture: StoreFixture): { store: Store; counts: StoreCallCounts } { + const counts: StoreCallCounts = { + getRepos: 0, + getProjects: 0, + getProjectGroups: 0, + getFolderWorkspaces: 0 + } + const store = { + getRepos: () => { + counts.getRepos += 1 + // Match the real store, which rehydrates fresh repo objects on every read. + return fixture.repos.map((repo) => ({ ...repo })) + }, + getProjects: () => { + counts.getProjects += 1 + return fixture.projects.map((project) => ({ ...project })) + }, + getProjectGroups: () => { + counts.getProjectGroups += 1 + return fixture.projectGroups.map((group) => ({ ...group })) + }, + getFolderWorkspaces: () => { + counts.getFolderWorkspaces += 1 + return fixture.folderWorkspaces.map((workspace) => ({ ...workspace })) + }, + getSettings: () => ({ nestWorkspaces: false, workspaceDir: fixture.workspaceDir ?? '' }) + } as unknown as Store + return { store, counts } +} + +/** + * The pre-change `getAllowedRoots` algorithm, kept verbatim so the equivalence test compares the + * new root list against the old one rather than against a hand-written expectation. + */ +function referenceAllowedRoots(store: Store): string[] { + const scopeStore = store as unknown as { + getRepos: () => Repo[] + getProjectGroups?: () => ProjectGroup[] + getFolderWorkspaces?: () => FolderWorkspace[] + getSettings: () => { workspaceDir?: string; nestWorkspaces?: boolean } + } + const localRepos = scopeStore.getRepos().filter((repo) => !repo.connectionId) + const settings = scopeStore.getSettings() + + const scopeRepos = scopeStore.getRepos() + const projectGroups = scopeStore.getProjectGroups?.() ?? [] + const isRemoteOnly = ( + folderPath: string, + projectGroupId: string, + connectionId: string | null | undefined + ): boolean => { + if (connectionId) { + return true + } + const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId) + const candidates = scopeRepos.filter( + (repo) => + (typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || + isPathInsideOrEqual(folderPath, repo.path) + ) + return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId)) + } + const folderScopeRoots: string[] = [] + for (const group of projectGroups) { + if (group.parentPath && !isRemoteOnly(group.parentPath, group.id, group.connectionId)) { + folderScopeRoots.push(resolve(group.parentPath)) + } + } + for (const workspace of scopeStore.getFolderWorkspaces?.() ?? []) { + const connectionId = + workspace.connectionId ?? + projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ?? + null + if (!isRemoteOnly(workspace.folderPath, workspace.projectGroupId, connectionId)) { + folderScopeRoots.push(resolve(workspace.folderPath)) + } + } + + const roots = [...localRepos.map((repo) => resolve(repo.path)), ...folderScopeRoots] + if (settings.workspaceDir) { + if (localRepos.length === 0) { + roots.push(resolve(settings.workspaceDir)) + } else { + for (const repo of localRepos) { + roots.push( + resolve( + computeWorkspaceRoot( + repo.path, + getWorktreePathSettings(repo, settings as never, getWorktreeMirrorDistro(store, repo)) + ) + ) + ) + } + } + } + return roots +} + +function makeRepo(overrides: Partial & Pick): Repo { + return { + displayName: overrides.id, + badgeColor: '#000000', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +function makeGroup(overrides: Partial & Pick): ProjectGroup { + return { + name: overrides.id, + parentPath: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeWorkspace( + overrides: Partial & Pick +): FolderWorkspace { + return { + projectGroupId: 'group-root', + name: overrides.id, + comment: '', + linkedTask: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +/** Repos, nested groups, folder workspaces (one not a git worktree), and an SSH repo. */ +function makeMixedFixture(): StoreFixture { + const repos = [ + makeRepo({ id: 'repo-local', path: '/repos/app', projectGroupId: 'group-root' }), + makeRepo({ id: 'repo-nested', path: '/repos/nested', projectGroupId: 'group-child' }), + makeRepo({ id: 'repo-folder', path: '/folders/plain', kind: 'folder' }), + makeRepo({ + id: 'repo-ssh', + path: '/remote/app', + connectionId: 'ssh-1', + projectGroupId: 'group-remote' + }) + ] + const projectGroups = [ + makeGroup({ id: 'group-root', parentPath: '/folders/root' }), + makeGroup({ id: 'group-child', parentGroupId: 'group-root', parentPath: '/folders/child' }), + makeGroup({ id: 'group-grandchild', parentGroupId: 'group-child' }), + makeGroup({ id: 'group-remote', parentPath: '/remote/scope' }), + makeGroup({ id: 'group-connection', parentPath: '/remote/via-group', connectionId: 'ssh-1' }) + ] + const folderWorkspaces = [ + makeWorkspace({ id: 'ws-git', folderPath: '/folders/root/feature' }), + // Not a git worktree: a plain folder workspace under a folder-kind repo. + makeWorkspace({ + id: 'ws-plain', + folderPath: '/folders/plain/scratch', + projectGroupId: 'group-child' + }), + makeWorkspace({ id: 'ws-remote', folderPath: '/remote/ws', projectGroupId: 'group-remote' }), + makeWorkspace({ + id: 'ws-connection', + folderPath: '/remote/direct', + projectGroupId: 'group-connection' + }), + makeWorkspace({ + id: 'ws-unlinked', + folderPath: '/folders/unlinked', + projectGroupId: 'group-orphan' + }) + ] + const projects: Project[] = [ + { + id: 'project-1', + displayName: 'App', + badgeColor: '#000000', + sourceRepoIds: ['repo-local', 'repo-nested'], + createdAt: 1, + updatedAt: 1 + }, + { + id: 'project-2', + displayName: 'Folder', + badgeColor: '#000000', + sourceRepoIds: ['repo-folder'], + createdAt: 1, + updatedAt: 1 + } + ] + return { repos, projects, projectGroups, folderWorkspaces, workspaceDir: '/workspaces' } +} + +beforeEach(() => { + invalidateAuthorizedRootsCache() + vi.mocked(buildProjectGroupChildIndex).mockClear() + vi.mocked(getProjectGroupSubtreeIds).mockClear() +}) + +describe('getAllowedRoots', () => { + it('produces the same roots as the pre-change implementation', () => { + const { store } = makeCountingStore(makeMixedFixture()) + + expect(getAllowedRoots(store)).toEqual(referenceAllowedRoots(store)) + }) + + it('reads the store once and indexes project groups once per build', () => { + const fixture = makeMixedFixture() + const { store, counts } = makeCountingStore(fixture) + + getAllowedRoots(store) + + expect.soft(counts.getRepos).toBe(1) + expect.soft(counts.getProjectGroups).toBe(1) + expect.soft(counts.getFolderWorkspaces).toBe(1) + // Batched runtime resolution scans the project list once, not once per local repo. + expect.soft(counts.getProjects).toBe(1) + // The per-scope subtree walk no longer rebuilds the parent->children index. + expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(1) + expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled() + }) +}) + +describe('resolveAuthorizedPath allowed-root reuse', () => { + let repoRoot: string + let outsideRoot: string + let store: Store + let counts: StoreCallCounts + + beforeEach(async () => { + repoRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-allowed-roots-')) + outsideRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-outside-')) + const fixture = makeMixedFixture() + fixture.repos = [makeRepo({ id: 'repo-local', path: repoRoot }), ...fixture.repos] + fixture.projects[0]!.sourceRepoIds = ['repo-local'] + ;({ store, counts } = makeCountingStore(fixture)) + }) + + afterEach(async () => { + await rm(repoRoot, { recursive: true, force: true }) + await rm(outsideRoot, { recursive: true, force: true }) + }) + + it('builds the allowed-root list once per call across repeated reads', async () => { + const dirPath = join(repoRoot, 'src') + await mkdir(dirPath) + await writeFile(join(dirPath, 'index.ts'), 'export {}\n') + const callCount = 5 + + for (let index = 0; index < callCount; index += 1) { + await resolveAuthorizedPath(dirPath, store) + await resolveAuthorizedPath(join(dirPath, 'index.ts'), store) + } + + const buildCount = callCount * 2 + // One build per authorization, not one per raw-path check plus one per realpath check. + expect.soft(counts.getFolderWorkspaces).toBe(buildCount) + expect.soft(counts.getRepos).toBe(buildCount) + expect.soft(counts.getProjects).toBe(buildCount) + expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(buildCount) + expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled() + }) + + // Why (both symlink cases): creating a symlink on Windows needs elevation or + // Developer Mode, so these would fail EPERM in setup rather than exercise the + // escape check. Every non-symlink case still runs there. + it.skipIf(process.platform === 'win32')( + 'still refuses a symlink that escapes every allowed root', + async () => { + const secret = join(outsideRoot, 'secret.txt') + await writeFile(secret, 'secret\n') + const escape = join(repoRoot, 'escape.txt') + await symlink(secret, escape) + + await expect(resolveAuthorizedPath(escape, store)).rejects.toThrow('Access denied') + expect(vi.mocked(listRepoWorktreeGraph)).toHaveBeenCalled() + } + ) + + it('builds no allowed-root list at all for a granted external path', async () => { + const external = join(outsideRoot, 'external.md') + await writeFile(external, 'notes\n') + authorizeExternalPath(external) + counts.getRepos = 0 + counts.getProjects = 0 + counts.getFolderWorkspaces = 0 + + for (let index = 0; index < 5; index += 1) { + await expect(resolveAuthorizedPath(external, store)).resolves.toBe(external) + } + + // The grant answers on its own; hoisting the snapshot must not turn zero builds into one per read. + expect.soft(counts.getRepos).toBe(0) + expect.soft(counts.getProjects).toBe(0) + expect.soft(counts.getFolderWorkspaces).toBe(0) + expect.soft(vi.mocked(buildProjectGroupChildIndex)).not.toHaveBeenCalled() + }) + + it.skipIf(process.platform === 'win32')( + 'still refuses a directory symlink that escapes every allowed root', + async () => { + const outsideDir = join(outsideRoot, 'nested') + await mkdir(outsideDir) + await writeFile(join(outsideDir, 'file.txt'), 'secret\n') + const escape = join(repoRoot, 'escape-dir') + await symlink(outsideDir, escape) + + await expect(resolveAuthorizedPath(join(escape, 'file.txt'), store)).rejects.toThrow( + 'Access denied' + ) + } + ) +}) diff --git a/src/main/ipc/filesystem-allowed-roots.ts b/src/main/ipc/filesystem-allowed-roots.ts index 3cb7fe4fa55..cef249430c6 100644 --- a/src/main/ipc/filesystem-allowed-roots.ts +++ b/src/main/ipc/filesystem-allowed-roots.ts @@ -1,9 +1,16 @@ import { resolve } from 'node:path' import type { Store } from '../persistence' import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' -import { getWorktreeMirrorDistro } from '../project-runtime-git-options' +import { + getWorktreeMirrorDistroForRuntime, + resolveLocalProjectRuntimesForRepos +} from '../project-runtime-git-options' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import { getProjectGroupSubtreeIds } from '../../shared/project-groups' +import { + buildProjectGroupChildIndex, + collectProjectGroupSubtreeIds, + type ProjectGroupChildIndex +} from '../../shared/project-groups' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' @@ -11,18 +18,22 @@ import type { Repo } from '../../shared/repo-types' type FolderScopeStore = Pick & Partial> +// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths. +function filterLocalRepos(repos: readonly Repo[]): Repo[] { + return repos.filter((repo) => !repo.connectionId) +} + export function getLocalRepos(store: Store) { - // Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths. - return store.getRepos().filter((repo) => !repo.connectionId) + return filterLocalRepos(store.getRepos()) } function getFolderScopeCandidateRepos( folderPath: string, projectGroupId: string, - projectGroups: readonly ProjectGroup[], + childGroupIndex: ProjectGroupChildIndex, repos: readonly Repo[] ): Repo[] { - const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId) + const groupIds = collectProjectGroupSubtreeIds(childGroupIndex, projectGroupId) return repos.filter( (repo) => (typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) || @@ -34,13 +45,18 @@ function isRemoteOnlyFolderScope( folderPath: string, projectGroupId: string, connectionId: string | null | undefined, - projectGroups: readonly ProjectGroup[], + childGroupIndex: ProjectGroupChildIndex, repos: readonly Repo[] ): boolean { if (connectionId) { return true } - const candidates = getFolderScopeCandidateRepos(folderPath, projectGroupId, projectGroups, repos) + const candidates = getFolderScopeCandidateRepos( + folderPath, + projectGroupId, + childGroupIndex, + repos + ) return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId)) } @@ -55,16 +71,22 @@ function getFolderWorkspaceConnectionId( ) } -function getLocalFolderScopeRoots(store: Store): string[] { +function getLocalFolderScopeRoots(store: Store, repos: readonly Repo[]): string[] { const scopeStore = store as FolderScopeStore - const repos = scopeStore.getRepos() // Why: many filesystem tests use narrow Store doubles; folder scopes are additive. const projectGroups = scopeStore.getProjectGroups?.() ?? [] + const childGroupIndex = buildProjectGroupChildIndex(projectGroups) const roots: string[] = [] for (const group of projectGroups) { if ( group.parentPath && - !isRemoteOnlyFolderScope(group.parentPath, group.id, group.connectionId, projectGroups, repos) + !isRemoteOnlyFolderScope( + group.parentPath, + group.id, + group.connectionId, + childGroupIndex, + repos + ) ) { roots.push(resolve(group.parentPath)) } @@ -75,7 +97,7 @@ function getLocalFolderScopeRoots(store: Store): string[] { workspace.folderPath, workspace.projectGroupId, getFolderWorkspaceConnectionId(workspace, projectGroups), - projectGroups, + childGroupIndex, repos ) ) { @@ -86,16 +108,19 @@ function getLocalFolderScopeRoots(store: Store): string[] { } export function getAllowedRoots(store: Store): string[] { - const localRepos = getLocalRepos(store) + // Why one read: `getRepos` rehydrates every repo, and this runs twice per filesystem IPC. + const repos = store.getRepos() + const localRepos = filterLocalRepos(repos) const settings = store.getSettings() const roots = [ ...localRepos.map((repo) => resolve(repo.path)), - ...getLocalFolderScopeRoots(store) + ...getLocalFolderScopeRoots(store, repos) ] if (settings.workspaceDir) { if (localRepos.length === 0) { roots.push(resolve(settings.workspaceDir)) } else { + const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(store, localRepos) for (const repo of localRepos) { roots.push( resolve( @@ -104,7 +129,11 @@ export function getAllowedRoots(store: Store): string[] { // Why enriched here too: placement has to agree with the create // flow, or renderer file access is denied for a worktree Orca // just put on the WSL side. - getWorktreePathSettings(repo, settings, getWorktreeMirrorDistro(store, repo)) + getWorktreePathSettings( + repo, + settings, + getWorktreeMirrorDistroForRuntime(projectRuntimeByRepoId.get(repo.id)) + ) ) ) ) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 122617845ed..894e39945c1 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -43,7 +43,24 @@ export function authorizeExternalPath(targetPath: string): void { } catch {} } -export function isPathAllowed(targetPath: string, store: Store): boolean { +/** + * One allowed-root list shared by every check in a single authorization. + * + * Lazy so a path already covered by an external grant still builds nothing at all, the way it did + * before the list was hoisted out of the individual checks. + */ +type AllowedRootsSnapshot = { get: () => readonly string[] } + +function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot { + let roots: readonly string[] | undefined + return { get: () => (roots ??= getAllowedRoots(store)) } +} + +export function isPathAllowed( + targetPath: string, + store: Store, + allowedRoots?: AllowedRootsSnapshot +): boolean { const resolvedTarget = resolve(targetPath) if (authorizedExternalPaths.has(resolvedTarget)) { return true @@ -53,7 +70,9 @@ export function isPathAllowed(targetPath: string, store: Store): boolean { return true } } - return getAllowedRoots(store).some((root) => isDescendantOrEqual(resolvedTarget, root)) + return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) => + isDescendantOrEqual(resolvedTarget, root) + ) } export type ResolveAuthorizedPathOptions = { @@ -69,7 +88,10 @@ export async function resolveAuthorizedPath( options: ResolveAuthorizedPathOptions = {} ): Promise { const resolvedTarget = resolve(targetPath) - if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store))) { + // Why: the roots depend only on store state, not on the candidate path, so one snapshot serves + // every authorization below; each candidate is still checked against it in full. + const allowedRoots = createAllowedRootsSnapshot(store) + if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } @@ -80,14 +102,15 @@ export async function resolveAuthorizedPath( realParent = await realpath(dirname(resolvedTarget)) } catch (error) { if (isENOENT(error)) { - return resolveAuthorizedMissingPath(resolvedTarget, store) + return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots) } throw error } const candidateTarget = resolve(realParent, basename(resolvedTarget)) if ( !(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, { - canonicalSourcePath: resolvedTarget + canonicalSourcePath: resolvedTarget, + allowedRoots })) ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -100,7 +123,8 @@ export async function resolveAuthorizedPath( const realTarget = resolve(await realpath(resolvedTarget)) if ( !(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, { - canonicalSourcePath: resolvedTarget + canonicalSourcePath: resolvedTarget, + allowedRoots })) ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -110,11 +134,15 @@ export async function resolveAuthorizedPath( if (!isENOENT(error)) { throw error } - return resolveAuthorizedMissingPath(resolvedTarget, store) + return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots) } } -async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store): Promise { +async function resolveAuthorizedMissingPath( + resolvedTarget: string, + store: Store, + allowedRoots: AllowedRootsSnapshot +): Promise { let existingAncestor = resolvedTarget const missingSegments: string[] = [] @@ -124,7 +152,8 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store const candidateTarget = resolve(realAncestor, ...missingSegments) if ( !(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, { - canonicalSourcePath: resolvedTarget + canonicalSourcePath: resolvedTarget, + allowedRoots })) ) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) @@ -148,9 +177,9 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store async function isPathAllowedIncludingRegisteredWorktrees( targetPath: string, store: Store, - options: { canonicalSourcePath?: string } = {} + options: { canonicalSourcePath?: string; allowedRoots?: AllowedRootsSnapshot } = {} ): Promise { - if (isPathAllowed(targetPath, store)) { + if (isPathAllowed(targetPath, store, options.allowedRoots)) { return true } @@ -158,7 +187,14 @@ async function isPathAllowedIncludingRegisteredWorktrees( return true } - if (await isPathAllowedByCanonicalAllowedRoot(targetPath, options.canonicalSourcePath, store)) { + if ( + await isPathAllowedByCanonicalAllowedRoot( + targetPath, + options.canonicalSourcePath, + store, + options.allowedRoots + ) + ) { return true } @@ -178,12 +214,13 @@ async function isPathAllowedIncludingRegisteredWorktrees( async function isPathAllowedByCanonicalAllowedRoot( targetPath: string, sourcePath: string | undefined, - store: Store + store: Store, + allowedRoots?: AllowedRootsSnapshot ): Promise { if (!sourcePath) { return false } - for (const root of getAllowedRoots(store)) { + for (const root of allowedRoots?.get() ?? getAllowedRoots(store)) { const resolvedRoot = resolve(root) if (!isDescendantOrEqual(sourcePath, resolvedRoot)) { continue diff --git a/src/main/ipc/local-worktree-runtime-options.test.ts b/src/main/ipc/local-worktree-runtime-options.test.ts new file mode 100644 index 00000000000..f7a1b0430e1 --- /dev/null +++ b/src/main/ipc/local-worktree-runtime-options.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WORKTREE_ID_SEPARATOR, type ParsedWorktreeId } from '../../shared/worktree/id' +import type * as WorktreeIdModule from '../../shared/worktree/id' + +const counter = vi.hoisted(() => ({ splitCalls: 0 })) + +// Why: `splitWorktreeId` (and the `Object.keys` snapshot around it) is the per-row work the repo +// loop used to repeat once per repo. Counting it makes the O(repos x rows) regression observable. +vi.mock('../../shared/worktree/id', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + splitWorktreeId: (worktreeId: string): ParsedWorktreeId | null => { + counter.splitCalls += 1 + return actual.splitWorktreeId(worktreeId) + } + } +}) + +const { getLocalRepoForRegisteredWorktree } = await import('./local-worktree-runtime-options') + +type TestRepo = { id: string; path: string; connectionId?: string } + +const makeStore = ( + repos: readonly TestRepo[], + worktreeIds: readonly string[] +): { store: never; metaScans: () => number } => { + let metaScans = 0 + const meta = Object.fromEntries(worktreeIds.map((id) => [id, {}])) + const store = { + getRepos: () => repos, + getAllWorktreeMeta: () => { + metaScans += 1 + return meta + } + } + return { store: store as never, metaScans: () => metaScans } +} + +const worktreeId = (repoId: string, path: string): string => + `${repoId}${WORKTREE_ID_SEPARATOR}${path}` + +beforeEach(() => { + counter.splitCalls = 0 +}) + +describe('getLocalRepoForRegisteredWorktree', () => { + it('walks the worktree meta table once, not once per repo', () => { + // Worst case: the owning repo is last, so every earlier repo used to force a full rescan. + const repoCount = 10 + const rowCount = 200 + const repos = Array.from({ length: repoCount }, (_, i) => ({ + id: `repo-${i}`, + path: `/repos/repo-${i}` + })) + const target = '/repos/repo-9/wt-last' + const worktreeIds = Array.from({ length: rowCount }, (_, i) => + worktreeId(`repo-${i % repoCount}`, `/repos/wt-${i}`) + ) + worktreeIds[rowCount - 1] = worktreeId(`repo-${repoCount - 1}`, target) + const { store, metaScans } = makeStore(repos, worktreeIds) + + expect(getLocalRepoForRegisteredWorktree(store, target, target)?.id).toBe('repo-9') + expect(metaScans()).toBe(1) + expect(counter.splitCalls).toBe(rowCount) + }) + + it('never touches the meta table when a repo path matches directly', () => { + const { store, metaScans } = makeStore([{ id: 'repo-a', path: '/repos/a' }], []) + expect(getLocalRepoForRegisteredWorktree(store, '/repos/a', '/repos/a')?.id).toBe('repo-a') + expect(metaScans()).toBe(0) + }) + + describe('equivalence with the per-repo scan', () => { + const repos: TestRepo[] = [ + { id: 'first', path: '/repos/first' }, + { id: 'middle', path: '/repos/middle' }, + { id: 'last', path: '/repos/last' } + ] + + it('finds a worktree owned by the first repo', () => { + const { store } = makeStore(repos, [worktreeId('first', '/wt/one')]) + expect(getLocalRepoForRegisteredWorktree(store, '/wt/one', '/wt/one')?.id).toBe('first') + }) + + it('finds a worktree owned by the last repo', () => { + const { store } = makeStore(repos, [worktreeId('last', '/wt/one')]) + expect(getLocalRepoForRegisteredWorktree(store, '/wt/one', '/wt/one')?.id).toBe('last') + }) + + it('returns undefined when no repo owns the worktree', () => { + const { store } = makeStore(repos, [worktreeId('other', '/wt/elsewhere')]) + expect(getLocalRepoForRegisteredWorktree(store, '/wt/one', '/wt/one')).toBeUndefined() + }) + + it('keeps getRepos precedence when two repos both own the path', () => { + const { store } = makeStore(repos, [ + worktreeId('last', '/wt/shared'), + worktreeId('middle', '/wt/shared') + ]) + // getRepos order decides, not the meta table's insertion order. + expect(getLocalRepoForRegisteredWorktree(store, '/wt/shared', '/wt/shared')?.id).toBe( + 'middle' + ) + }) + + it('excludes an SSH repo even when it owns the registered worktree', () => { + const { store } = makeStore( + [{ id: 'remote', path: '/repos/remote', connectionId: 'm4air' }, ...repos], + [worktreeId('remote', '/wt/one'), worktreeId('middle', '/wt/one')] + ) + expect(getLocalRepoForRegisteredWorktree(store, '/wt/one', '/wt/one')?.id).toBe('middle') + + const onlyRemote = makeStore( + [{ id: 'remote', path: '/repos/remote', connectionId: 'm4air' }], + [worktreeId('remote', '/wt/one')] + ) + expect( + getLocalRepoForRegisteredWorktree(onlyRemote.store, '/wt/one', '/wt/one') + ).toBeUndefined() + }) + + it('matches the resolved path spelling as well as the raw one', () => { + const { store } = makeStore(repos, [worktreeId('middle', '/wt/one')]) + expect(getLocalRepoForRegisteredWorktree(store, '/wt/other', '/wt/one')?.id).toBe('middle') + }) + + it('returns undefined for a folder workspace that is not a registered worktree', () => { + const { store } = makeStore(repos, [worktreeId('middle', '/wt/one')]) + expect( + getLocalRepoForRegisteredWorktree(store, '/folders/notes', '/folders/notes') + ).toBeUndefined() + }) + + it('tolerates a store without getRepos or getAllWorktreeMeta', () => { + expect(getLocalRepoForRegisteredWorktree({} as never, '/wt/one', '/wt/one')).toBeUndefined() + expect( + getLocalRepoForRegisteredWorktree({ getRepos: () => repos } as never, '/wt/one', '/wt/one') + ).toBeUndefined() + }) + }) +}) diff --git a/src/main/ipc/local-worktree-runtime-options.ts b/src/main/ipc/local-worktree-runtime-options.ts index d0d1a67e3cd..07bb9b41c83 100644 --- a/src/main/ipc/local-worktree-runtime-options.ts +++ b/src/main/ipc/local-worktree-runtime-options.ts @@ -19,20 +19,21 @@ function getCandidateLocalWorktreePaths( return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) } -function hasRegisteredWorktreeMetaForRepo( +/** Repos owning a registered worktree at one of `candidatePaths`, in one pass over the meta table. */ +function collectRepoIdsWithRegisteredWorktreeMeta( store: Store, - repoId: string, candidatePaths: Set -): boolean { +): Set { const worktreeMeta = typeof store.getAllWorktreeMeta === 'function' ? store.getAllWorktreeMeta() : {} + const repoIds = new Set() for (const worktreeId of Object.keys(worktreeMeta)) { const parsed = splitWorktreeId(worktreeId) - if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { - return true + if (parsed && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { + repoIds.add(parsed.repoId) } } - return false + return repoIds } export function getLocalRepoForRegisteredWorktree( @@ -45,13 +46,18 @@ export function getLocalRepoForRegisteredWorktree( } const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) + // Built at most once, and only when a repo actually needs it, so the meta table is never + // rescanned per repo — 59 IPC call sites hit this, some per keystroke. + let repoIdsWithMeta: Set | undefined return store .getRepos() .find( (repo) => !repo.connectionId && (candidatePaths.has(comparableLocalPath(repo.path)) || - hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) + (repoIdsWithMeta ??= collectRepoIdsWithRegisteredWorktreeMeta(store, candidatePaths)).has( + repo.id + )) ) } diff --git a/src/main/ipc/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty-controller-ownership-routing.test.ts index 0d236e63cb2..2101d17f26b 100644 --- a/src/main/ipc/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty-controller-ownership-routing.test.ts @@ -324,6 +324,7 @@ describe('registerPtyHandlers', () => { } const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), diff --git a/src/main/ipc/pty-daemon-spawn-session-identity.test.ts b/src/main/ipc/pty-daemon-spawn-session-identity.test.ts index 90ea56049fa..b1ead181530 100644 --- a/src/main/ipc/pty-daemon-spawn-session-identity.test.ts +++ b/src/main/ipc/pty-daemon-spawn-session-identity.test.ts @@ -345,6 +345,7 @@ describe('registerPtyHandlers', () => { ) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn() } registerSshPtyProvider('ssh-1', { diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index 8f47fa6fcdf..4669d7e5528 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, setLocalPtyProvider } from './pty' @@ -59,7 +60,7 @@ describe('registerPtyHandlers', () => { const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-proven-absent-owner') + throw new SessionNotFoundError('pty-proven-absent-owner') } return { id: 'pty-fresh-proven', incarnationId: 'inc-fresh-proven' } } @@ -161,10 +162,13 @@ describe('registerPtyHandlers', () => { expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume proven-absent-session' }) + // The registry that owns the PTY answered, so this exit is confirmed — but the code stays the + // -1 sentinel; a synthesized zero would be indistinguishable from a clean shell exit. expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-proven-absent-owner', - 0, - 'inc-proven-absent-owner' + -1, + 'inc-proven-absent-owner', + { hostExitConfirmed: true } ) expect(store.setWorkspaceSession).toHaveBeenCalledOnce() expect(store.flushOrThrow).toHaveBeenCalledOnce() @@ -178,7 +182,7 @@ describe('registerPtyHandlers', () => { const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-probe-blip-owner') + throw new SessionNotFoundError('pty-probe-blip-owner') } return { id: 'pty-fresh-probe-blip', incarnationId: 'inc-fresh-probe-blip' } } @@ -282,8 +286,9 @@ describe('registerPtyHandlers', () => { expect(providerSpawn).toHaveBeenCalledTimes(2) expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-probe-blip-owner', - 0, - 'inc-probe-blip-owner' + -1, + 'inc-probe-blip-owner', + { hostExitConfirmed: true } ) }) // Why: a parked pane (stopped with keepHistory) leaves the runtime holding the binding while @@ -299,7 +304,7 @@ describe('registerPtyHandlers', () => { const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-already-retired-owner') + throw new SessionNotFoundError('pty-already-retired-owner') } return { id: 'pty-fresh-already-retired', incarnationId: 'inc-fresh-already-retired' } } @@ -420,6 +425,8 @@ describe('registerPtyHandlers', () => { expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume already-retired-session' }) - expect(runtime.onPtyExit).toHaveBeenCalledWith('pty-already-retired-owner', 0, undefined) + expect(runtime.onPtyExit).toHaveBeenCalledWith('pty-already-retired-owner', -1, undefined, { + hostExitConfirmed: true + }) }) }) diff --git a/src/main/ipc/pty-pane-reservation-settlement.test.ts b/src/main/ipc/pty-pane-reservation-settlement.test.ts index c894eeaeddd..06e8679ace4 100644 --- a/src/main/ipc/pty-pane-reservation-settlement.test.ts +++ b/src/main/ipc/pty-pane-reservation-settlement.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it, vi } from 'vitest' import { spawnMock, registerPtyMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { makePaneKey } from '../../shared/stable-pane-id' +import { + SSH_SESSION_EXPIRED_ERROR, + SshPtyProvenExitedOnRelayError +} from '../providers/ssh-pty-errors' import { registerPtyHandlers, registerSshPtyProvider, @@ -67,7 +71,9 @@ describe('registerPtyHandlers', () => { const freshPtyId = `ssh:${connectionId}@@fresh-relay-pty` const remoteSpawn = vi.fn(async (options: { attachOnly?: boolean; command?: string }) => { if (options.attachOnly) { - throw new Error('PTY "dead-relay-pty" not found') + // The relay's raw wire text never reaches a pane untyped; the SSH reattach path mints the + // proven-exit class for the one refusal the relay backed with a pid probe. + throw new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: dead-relay-pty`) } return { id: freshPtyId, incarnationId: 'inc-fresh-ssh-owner' } }) @@ -118,6 +124,7 @@ describe('registerPtyHandlers', () => { flushOrThrow: vi.fn(), persistPtyBinding: vi.fn(), upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() @@ -476,6 +483,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index 8f565f1c08e..743963d0189 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { statSyncMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' -import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, clearProviderPtyState, setLocalPtyProvider } from './pty' @@ -230,7 +230,7 @@ describe('registerPtyHandlers', () => { const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-dead-persisted-owner') + throw new SessionNotFoundError('pty-dead-persisted-owner') } return { id: 'pty-fresh-recovery', incarnationId: 'inc-fresh-recovery' } } @@ -352,8 +352,9 @@ describe('registerPtyHandlers', () => { expect(store.setWorkspaceSession).toHaveBeenCalledOnce() expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', - 0, - 'inc-dead-persisted-owner' + -1, + 'inc-dead-persisted-owner', + { hostExitConfirmed: true } ) return } @@ -376,8 +377,9 @@ describe('registerPtyHandlers', () => { expect(store.flushOrThrow).toHaveBeenCalledOnce() expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', - 0, - 'inc-dead-persisted-owner' + -1, + 'inc-dead-persisted-owner', + { hostExitConfirmed: true } ) } ) diff --git a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts index 3fd1f89b11d..d1078477d12 100644 --- a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts +++ b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts @@ -154,6 +154,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), @@ -260,6 +261,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn() } let controller: RuntimeSpawnController | null = null @@ -370,6 +372,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(() => { throw new Error('disk full') }), @@ -471,6 +474,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), @@ -577,6 +581,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index 4af42f1dbd5..53755214238 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -108,6 +108,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), removeSshRemotePtyLease: vi.fn(), markSshRemotePtyLease: vi.fn(), @@ -212,6 +213,7 @@ describe('registerPtyHandlers', () => { } as never) const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(() => { throw new Error('disk full') }), diff --git a/src/main/ipc/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty-session-liveness-and-ownership.test.ts index fb88fc4b6cb..574e83c1220 100644 --- a/src/main/ipc/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty-session-liveness-and-ownership.test.ts @@ -386,6 +386,7 @@ describe('registerPtyHandlers', () => { it('ignores fire-and-forget IPC for detached SSH PTYs without a provider', async () => { const store = { upsertSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn(), persistPtyBinding: vi.fn(), markSshRemotePtyLease: vi.fn(), clearSshRemotePtyKillIntent: vi.fn() diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index be9cb31394a..540ada9397d 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -134,6 +134,13 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ }) } } + // Why here and not at the upsert: this path leases before it binds, so supersession fenced on the + // pane's binding still named the predecessor and bailed on every reconnect — one more reattachable + // lease, and one more `pty.attach`, per reconnect forever. Runs after whichever binding write this + // commit made, so the lease/binding order no longer decides. + if (ctx.deps.store && args.connectionId && ctx.validatedLeafId !== null) { + ctx.deps.store.supersedeSshRemotePtyLeasesForBoundPane(args.connectionId, ctx.validatedLeafId) + } // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. const rendererPreSignaled = ctx.validatedPaneKey ? pendingByPaneKey.has(ctx.validatedPaneKey) diff --git a/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts new file mode 100644 index 00000000000..6266faf0c02 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { testState, createStore } from '../../../persistence-test-harness' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../../persistence-session-fixtures' +import { sshRemotePtyLeaseAllowsReattach } from '../../../../shared/ssh-types' +import { toAppSshPtyId } from '../../../providers/ssh-pty-id' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' +import { createPtyIpcSpawnState } from './spawn-state' +import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +const TARGET = 'ssh-1' +const WORKTREE = 'repo1::/worktree' +const TAB = 'tab-1' + +/** + * Drives the shipped IPC spawn commit rather than the store primitives it calls. + * + * The store-level suite could not catch this: it exercised bind-then-upsert, and this path does the + * opposite — it writes the lease row first so a force-quit in the renderer's debounce window cannot + * strand a running remote shell without one, then binds the pane. Supersession is fenced on the + * pane's binding, so under this real order it bailed on the predecessor every time and never re-ran, + * and each reconnect left one more reattachable lease for `reattachKnownPtys` to `pty.attach`. + */ +async function commitSshSpawn( + store: ReturnType, + args: { relayPtyId: string; leafId: string } +): Promise { + const deps = { store } as unknown as PtySpawnIpcDeps + const spawnArgs = { + cols: 80, + rows: 24, + worktreeId: WORKTREE, + tabId: TAB, + leafId: args.leafId, + connectionId: TARGET + } as unknown as PtySpawnIpcArgs + const ctx = createPtyIpcSpawnState(deps, spawnArgs) + ctx.result = { id: toAppSshPtyId(TARGET, args.relayPtyId) } + ctx.validatedLeafId = args.leafId + await persistPtyIpcSpawnCommit(ctx) +} + +/** One pane's layout, so the two host partitions can be given different bindings for one leaf. */ +function sessionBinding(ptyId: string) { + return { + activeRepoId: 'repo1', + activeWorktreeId: WORKTREE, + activeTabId: TAB, + tabsByWorktree: {}, + terminalLayoutsByTabId: { + [TAB]: { + root: { type: 'leaf' as const, leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: ptyId } + } + } + } +} + +function bulkReattachPtyIds(store: ReturnType): string[] { + return store + .getSshRemotePtyLeases(TARGET) + .filter(sshRemotePtyLeaseAllowsReattach) + .map((lease) => lease.ptyId) + .sort() +} + +describe('the IPC spawn commit keeps one reattachable lease per SSH pane', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + // QA's measurement, driven through the real path: five relay restarts, one pane, N+1 leases. + it('holds the reattach set flat across five reconnects of one pane', async () => { + const store = await createStore() + + for (let reconnect = 0; reconnect < 5; reconnect++) { + // A relay renumbers from `pty-1` on every start; a reconnect therefore re-leases the same + // pane under an id it has never used before. + await commitSshSpawn(store, { relayPtyId: `pty-${reconnect}`, leafId: TEST_LEAF_1 }) + } + + expect(bulkReattachPtyIds(store)).toEqual(['pty-4']) + }) + + it('retires each predecessor as `expired` with the winner recorded, never `terminated`', async () => { + const store = await createStore() + + await commitSshSpawn(store, { relayPtyId: 'pty-0', leafId: TEST_LEAF_1 }) + await commitSshSpawn(store, { relayPtyId: 'pty-1', leafId: TEST_LEAF_1 }) + + const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-0') + // `expired`, not `terminated`: losing the lease is not evidence the remote shell died, and the + // process is deliberately left running (docs/reference/ssh-execution-boundary.md). + expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-1' }) + }) + + // The failure that would be worse than the fan-out: over-superseding strands a live remote + // process behind a pane that can no longer find it. + it('leaves a genuine orphan reattachable while superseding the pane that re-leased', async () => { + const store = await createStore() + + await commitSshSpawn(store, { relayPtyId: 'orphan-pty', leafId: TEST_LEAF_2 }) + // The orphan's client lost its route; nothing observed the shell, so it stays askable. + store.markSshRemotePtyLease(TARGET, 'orphan-pty', 'expired') + + await commitSshSpawn(store, { relayPtyId: 'pty-0', leafId: TEST_LEAF_1 }) + await commitSshSpawn(store, { relayPtyId: 'pty-1', leafId: TEST_LEAF_1 }) + + const orphan = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'orphan-pty') + expect(orphan?.supersededBy).toBeUndefined() + expect(bulkReattachPtyIds(store)).toEqual(['orphan-pty', 'pty-1']) + }) + + /** + * The shape the Docker lane exposed, and the reason a spawn-time trigger is not enough on its + * own. When the spawn commit writes no binding, the renderer's debounced layout publish does it + * later — so at commit time the pane still names the predecessor and supersession correctly + * declines. Nothing revisited it afterwards, and the predecessor stayed reattachable forever. + * + * Measured rows agreed on target, worktree, tab and leaf and still carried no `supersededBy`. + */ + it('retires a predecessor whose successor bound the pane after the spawn commit', async () => { + const store = await createStore() + + await commitSshSpawn(store, { relayPtyId: 'pty2:aaa:1', leafId: TEST_LEAF_1 }) + // What `handlePtyReattachFailure` writes when a restarted relay disowns the id. + store.markSshRemotePtyLease(TARGET, 'pty2:aaa:1', 'expired') + + // The successor leases without binding the pane; the binding catches up afterwards, exactly as + // the renderer's debounced publish does. + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'pty2:bbb:1', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'attached' + }) + expect(bulkReattachPtyIds(store)).toEqual(['pty2:aaa:1', 'pty2:bbb:1']) + store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + ptyId: toAppSshPtyId(TARGET, 'pty2:bbb:1') + }) + + // What the connect path does before reading the set it feeds to `pty.attach`. + store.reconcileSshRemotePtyLeasesForTarget(TARGET) + + expect(bulkReattachPtyIds(store)).toEqual(['pty2:bbb:1']) + }) + + // Reconciliation must not invent evidence: with no binding naming the pane, nothing says which + // shell owns it, so every lease stays askable. + it('leaves leases reattachable when no binding names the pane', async () => { + const store = await createStore() + + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'unbound-a', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'expired' + }) + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'unbound-b', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'expired' + }) + + store.reconcileSshRemotePtyLeasesForTarget(TARGET) + + expect(bulkReattachPtyIds(store)).toEqual(['unbound-a', 'unbound-b']) + }) + + /** + * The measured defect, reduced to its cause. + * + * Main writes an SSH pane's binding to the `ssh:` partition, but a stale copy of the same + * leaf survives in `local`. Reading `local` first named the PREDECESSOR as the pane's current + * PTY, so supersession took an already-expired lease as its winner and returned having marked + * nothing — once per relay restart, forever. Both partitions name the same PTY again once the + * renderer republishes, which is why the finished store looks consistent and hides this. + */ + it('supersedes when the local partition still names the predecessor', async () => { + const store = await createStore() + const hostId = toSshExecutionHostId(TARGET) + const predecessor = toAppSshPtyId(TARGET, 'pty2:old:1') + const successor = toAppSshPtyId(TARGET, 'pty2:new:1') + + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'pty2:old:1', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'expired' + }) + // Both partitions start on the predecessor, as they do before a relay restart. + store.setWorkspaceSession(sessionBinding(predecessor)) + store.setWorkspaceSession(sessionBinding(predecessor), hostId) + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'pty2:new:1', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'attached' + }) + // Production's writer for an SSH pane binding, and the whole point: it updates ONLY the host + // partition, so `local` is left naming the predecessor until the renderer republishes. + store.persistPtyBinding( + { worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1, ptyId: successor }, + hostId + ) + expect( + store.getWorkspaceSession().terminalLayoutsByTabId?.[TAB]?.ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBe(predecessor) + + store.supersedeSshRemotePtyLeasesForBoundPane(TARGET, TEST_LEAF_1) + + const retired = store.getSshRemotePtyLeases(TARGET).find((l) => l.ptyId === 'pty2:old:1') + expect(retired).toMatchObject({ state: 'expired', supersededBy: 'pty2:new:1' }) + expect(bulkReattachPtyIds(store)).toEqual(['pty2:new:1']) + }) + + // The mirror: a live shell the pane is still bound to must never be retired, whichever partition + // names it. Over-superseding strands a running remote process. + it('never retires a live lease the pane is still bound to', async () => { + const store = await createStore() + const live = toAppSshPtyId(TARGET, 'pty2:live:1') + + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'pty2:live:1', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'attached' + }) + // Bound BEFORE the stray lease arrives, which is the order that makes the binding meaningful: + // with no binding at all, an arriving lease is the only evidence there is and does win. + store.setWorkspaceSession(sessionBinding(live)) + store.setWorkspaceSession(sessionBinding(live), toSshExecutionHostId(TARGET)) + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: 'pty2:other:1', + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + state: 'attached' + }) + + store.supersedeSshRemotePtyLeasesForBoundPane(TARGET, TEST_LEAF_1) + + const stillLive = store.getSshRemotePtyLeases(TARGET).find((l) => l.ptyId === 'pty2:live:1') + expect(stillLive).toMatchObject({ state: 'attached' }) + expect(stillLive?.supersededBy).toBeUndefined() + }) + + // Panes are independent, and supersession keys on the leaf: a second live pane on the same + // target must survive its neighbour reconnecting. + it('does not touch a sibling pane on the same target', async () => { + const store = await createStore() + + await commitSshSpawn(store, { relayPtyId: 'sibling-pty', leafId: TEST_LEAF_2 }) + await commitSshSpawn(store, { relayPtyId: 'pty-0', leafId: TEST_LEAF_1 }) + await commitSshSpawn(store, { relayPtyId: 'pty-1', leafId: TEST_LEAF_1 }) + + expect(bulkReattachPtyIds(store)).toEqual(['pty-1', 'sibling-pty']) + }) +}) diff --git a/src/main/ipc/pty/pane/ssh-pane-lease-claim.ts b/src/main/ipc/pty/pane/ssh-pane-lease-claim.ts new file mode 100644 index 00000000000..5746814a5f7 --- /dev/null +++ b/src/main/ipc/pty/pane/ssh-pane-lease-claim.ts @@ -0,0 +1,44 @@ +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { getRelayPtyId } from '../provider/registry' +import type { Store } from '../../../persistence' + +/** + * Claim a remote PTY for a pane: record the lease, then retire the pane's predecessors. + * + * The lease keeps the RELAY id, because reconnect calls `pty.attach` with target-local ids, while + * the pane binding keeps the app-facing id used for hydration. + * + * Supersession is a second step rather than something `upsertSshRemotePtyLease` finishes on its own + * because it is fenced on the pane's durable binding — it refuses to retire a predecessor the pane + * is still bound to, which would detach a live pane. A caller that leases BEFORE it binds therefore + * trips that fence on every reconnect and, with the upsert as the only trigger, never re-runs: one + * more reattachable lease, and one more `pty.attach` round trip on every later connect, forever. + * Re-running it here from the binding side is what makes the two writes commute. + */ +export function claimSshPaneLease(args: { + store: Store | undefined + connectionId: string | null | undefined + ptyId: string + worktreeId: string | undefined + tabId: string | undefined + leafId: string | undefined +}): void { + const { store, connectionId } = args + if (!store || !connectionId) { + return + } + const leafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + store.upsertSshRemotePtyLease({ + targetId: connectionId, + ptyId: getRelayPtyId(connectionId, args.ptyId), + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), + ...(leafId ? { leafId } : {}), + state: 'attached', + lastAttachedAt: Date.now() + }) + if (leafId) { + store.supersedeSshRemotePtyLeasesForBoundPane(connectionId, leafId) + } +} diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index 9442e914e1f..731065e59ab 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -1,5 +1,6 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host' import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause' import type { Store } from '../../../persistence' import { retireTerminalSurfaceFromPersistence } from '../../../runtime/mobile-session-terminal-persistence-retirement' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' @@ -11,7 +12,7 @@ import { TerminalSessionOwnerUnverifiedError } from '../../../daemon/daemon-errors' import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' -import { isPtyAlreadyGoneError } from '../provider/liveness' +import { isHostReportedPtyAbsenceError, isObservedPtyExitEvidence } from '../provider/liveness' import { clearProviderPtyState } from '../provider/state-cleanup' export type StablePaneOwner = { @@ -239,7 +240,7 @@ export async function attachStablePaneOwner( if (isDaemonEndpointGoneError(error)) { throw new TerminalHostGoneError() } - if (!isPtyAlreadyGoneError(error)) { + if (!isHostReportedPtyAbsenceError(error)) { throw error } const ownerBeforeRetire = args.resolveOwner?.() @@ -252,7 +253,17 @@ export async function attachStablePaneOwner( ) { throw new Error('terminal_pane_owner_changed') } - runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) + // `pty.attach` answers absent both for a pid the relay probed and found gone and for an id its + // session map never had — every id minted before a relay restart, checked against nothing. Only + // the marked half observed the process, so only it may certify a death; the rest publishes the + // stop sentinel its sibling handlePtyReattachFailure publishes, which every reader resolves to + // `stop_unverified` (docs/reference/ssh-execution-boundary.md). + runtime?.onPtyExit( + owner.ptyId, + UNVERIFIED_PROCESS_EXIT_CODE, + owner.incarnationId, + isObservedPtyExitEvidence(error) ? { hostExitConfirmed: true } : {} + ) clearProviderPtyState(owner.ptyId) ptyOwnership.delete(owner.ptyId) if ( diff --git a/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts new file mode 100644 index 00000000000..972f479b492 --- /dev/null +++ b/src/main/ipc/pty/pane/stable-pane-absence-death-certificate.test.ts @@ -0,0 +1,185 @@ +// `attachStablePaneOwner` is the last reader that synthesised a runtime exit from a reattach +// refusal, and it published code 0 — which `orca-runtime-on-pty-exit` records as a death +// certificate. The refusal it acts on is a union: `pty.attach` answers absent both for a pid the +// relay probed and found gone, and for an id its session map never had, which is every id minted +// before a relay restart. Certifying the union orphans a live remote shell and cold-starts a second +// agent onto its transcript (docs/reference/ssh-execution-boundary.md). +// +// The sibling handlePtyReattachFailure has always refused to certify from that union. These pin the +// same rule here, and pin that the marked half — the one refusal the relay backed with a pid probe +// — still earns the certificate, so a genuinely dead PTY is not left `unverifiable` forever. +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { SSH_EXIT_UNCONFIRMED_REASON } from '../../../../shared/pty-liveness-verdict' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' +import type { Store } from '../../../persistence' +import { + SSH_SESSION_EXPIRED_ERROR, + SshPtyAbsentFromRelayError, + SshPtyProvenExitedOnRelayError +} from '../../../providers/ssh-pty-errors' +import type { IPtyProvider } from '../../../providers/types' +import { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { resolvePersistedStablePaneOwner, spawnForStablePane } from './stable-owner' + +const CONNECTION = 'conn-1' +const WORKTREE = 'repo-1::/tmp/pane-absence' +const TAB = 'tab-1' +const LEAF = '1b3f2c4d-5e6a-4b7c-8d9e-0f1a2b3c4d5e' +const SIBLING_LEAF = '2c4d3e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f' +// Ids carry the relay's per-start mint epoch, so this one names a PTY the CURRENT relay never minted. +const PTY_ID = 'ssh:conn-1@@pty2:epoch-a:1' +const OWNER = { tabId: TAB, leafId: LEAF, ptyId: PTY_ID, hasPersistedBinding: true as const } + +function paneStore(): { store: Store; read: () => WorkspaceSessionState } { + let session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE]: [{ id: TAB, type: 'terminal', worktreeId: WORKTREE, ptyId: PTY_ID }] + }, + terminalLayoutsByTabId: { + [TAB]: { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF }, + second: { type: 'leaf', leafId: SIBLING_LEAF } + }, + activeLeafId: LEAF, + ptyIdsByLeafId: { [LEAF]: PTY_ID, [SIBLING_LEAF]: 'ssh:conn-1@@pty2:epoch-a:2' } + } + } + } as unknown as WorkspaceSessionState + return { + read: () => session, + store: { + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + flushOrThrow: () => {}, + getRepos: () => [ + { + id: 'repo-1', + path: '/tmp/pane-absence', + displayName: 'pane-absence', + badgeColor: '#000000', + addedAt: 0 + } + ], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => {}, + removeWorktreeMeta: () => {}, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [] + } as unknown as Store + } +} + +function runtimeOwning(store: Store): OrcaRuntimeService { + const runtime = new OrcaRuntimeService(store as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + hasPty: () => null, + listProcesses: async () => [], + getForegroundProcess: async () => null + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.registerPty(PTY_ID, WORKTREE, CONNECTION) + return runtime +} + +async function adoptAfterAttachRefusal(error: unknown): Promise<{ + runtime: OrcaRuntimeService + store: Store + read: () => WorkspaceSessionState + spawn: ReturnType +}> { + const { store, read } = paneStore() + const runtime = runtimeOwning(store) + const spawn = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ id: 'ssh:conn-1@@pty2:epoch-b:1', isReattach: false }) + await spawnForStablePane({ + runtime, + store, + provider: { spawn } as unknown as IPtyProvider, + spawnOptions: { cols: 80, rows: 24 }, + owner: OWNER, + worktreeId: WORKTREE, + connectionId: CONNECTION, + resolveOwner: () => null + }) + return { runtime, store, read, spawn } +} + +describe('a stable pane whose reattach was refused', () => { + it('records no death certificate when the relay merely does not know the id', async () => { + const { runtime, spawn } = await adoptAfterAttachRefusal( + new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty2:epoch-a:1`) + ) + + expect(spawn).toHaveBeenCalledTimes(2) + // The shell may well still be running under the previous daemon's orphaned process tree, so the + // register must keep saying "we could not observe it" — not "it ended". + expect(runtime.getPtyLivenessVerdict(PTY_ID)).toEqual({ + status: 'unverifiable', + reason: SSH_EXIT_UNCONFIRMED_REASON + }) + }) + + it('still certifies the death the relay proved with a pid probe', async () => { + const { runtime, store, spawn } = await adoptAfterAttachRefusal( + new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty2:epoch-a:1`) + ) + + expect(spawn).toHaveBeenCalledTimes(2) + expect(runtime.getPtyLivenessVerdict(PTY_ID)).toEqual({ status: 'exited' }) + // If nothing ever retired, a proven-dead pane would reattach to a corpse on every adoption. + expect( + resolvePersistedStablePaneOwner(store, makePaneKey(TAB, LEAF), WORKTREE, CONNECTION) + ).toBeNull() + }) + + it('certifies an absence reported by the process registry that owns the PTY', async () => { + // The daemon (or the in-process map) answering here is the owner of the process, and an + // endpoint that had gone raises TerminalHostGoneError above, so this absence is an observation + // rather than a lost route. + const { runtime } = await adoptAfterAttachRefusal(new SessionNotFoundError(PTY_ID)) + + expect(runtime.getPtyLivenessVerdict(PTY_ID)).toEqual({ status: 'exited' }) + }) + + it('refuses to abandon the binding on an untyped "not found" string', async () => { + // The relay's raw wire wording. The SSH reattach path types it before any pane sees it, so an + // untyped one reached this gate having lost every distinction the type carries — including + // whether the answer came from the host that owns the process at all. + const { store, read } = paneStore() + const before = JSON.stringify(read()) + const runtime = runtimeOwning(store) + const spawn = vi.fn().mockRejectedValue(new Error(`PTY "pty2:epoch-a:1" not found`)) + + await expect( + spawnForStablePane({ + runtime, + store, + provider: { spawn } as unknown as IPtyProvider, + spawnOptions: { cols: 80, rows: 24 }, + owner: OWNER, + worktreeId: WORKTREE, + connectionId: CONNECTION, + resolveOwner: () => null + }) + ).rejects.toThrow('not found') + + expect(spawn).toHaveBeenCalledTimes(1) + expect(runtime.getPtyLivenessVerdict(PTY_ID)).toBeNull() + expect(JSON.stringify(read())).toBe(before) + }) +}) diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index cc764143384..8a362b74aaf 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -2,10 +2,12 @@ import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay' import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority' import { agentSessionOwnerBindingsEqual } from '../../../../shared/claimed-agent-pty-owner' import { addNodePtyRecoveryHint } from '../../../daemon/node-pty-error-hints' +import { SessionNotFoundError } from '../../../daemon/daemon-errors' import type { Store } from '../../../persistence' import { isSshPtyAbsentFromRelayError, - isSshPtyNotFoundError + isSshPtyNotFoundError, + isSshPtyProvenExitedOnRelayError } from '../../../providers/ssh-pty-errors' import type { IPtyProvider } from '../../../providers/types' import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' @@ -66,6 +68,33 @@ export function isPtyAlreadyGoneError(err: unknown): boolean { ) } +/** + * Narrower than {@link isPtyAlreadyGoneError}, for the one caller that retires a durable pane + * binding rather than just releasing in-memory state: only a typed answer from the host that owns + * the process may authorise that. The bare `PTY ".+" not found` text is the relay's raw wire + * wording, which the SSH reattach path always types before it reaches a pane; matching the text + * instead would let any untyped string carrying that phrase unbind a live pane + * (docs/reference/ssh-execution-boundary.md). + */ +export function isHostReportedPtyAbsenceError(err: unknown): boolean { + return isSshPtyAbsentFromRelayError(err) || err instanceof SessionNotFoundError +} + +/** + * The half of {@link isHostReportedPtyAbsenceError} that actually observed the process, and so the + * only half that may certify an exit. + * + * The relay's plain absence answer is excluded because `pty.attach` gives it for an id its session + * map never had as readily as for a pid it probed — after a relay restart, every id the previous + * one minted. `SessionNotFoundError` is included because the process answering is the one that owns + * the PTY: the in-process registry itself, or a daemon whose endpoint is live (a gone endpoint + * raises `isDaemonEndpointGoneError` instead), so its absence is an observation rather than a lost + * route (docs/reference/ssh-execution-boundary.md). + */ +export function isObservedPtyExitEvidence(err: unknown): boolean { + return isSshPtyProvenExitedOnRelayError(err) || err instanceof SessionNotFoundError +} + export function delay(ms: number): Promise { return new Promise((resolve) => { const timer = setTimeout(resolve, ms) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 0b4e8804ac0..23592604bea 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -1,8 +1,6 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' -import { getRelayPtyId } from '../provider/registry' import { shouldSkipCodexHomeEnvForWindowsShell, recordCodexPaneAccountForSpawn, @@ -25,6 +23,7 @@ import { requestKindSchema } from '../../../../shared/telemetry-events' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { isNativeWindowsLocalPtySpawn, markNativeWindowsConptyPty @@ -114,23 +113,15 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ) { markNativeWindowsConptyPty(ctx.result.id) } - const persistSshLease = (): void => { - if (!ctx.deps.store || !args.connectionId) { - return - } - // Why: SSH leases keep relay ids for remote reconciliation, while session bindings keep app-facing ids for hydration. - ctx.deps.store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: getRelayPtyId(args.connectionId, ctx.result.id), - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) - ? { leafId: args.leafId } - : {}), - state: 'attached', - lastAttachedAt: Date.now() + const persistSshLease = (): void => + claimSshPaneLease({ + store: ctx.deps.store, + connectionId: args.connectionId, + ptyId: ctx.result.id, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId }) - } if (!ctx.hostSessionBinding) { persistSshLease() } diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 56eb4804a82..3b900e175bc 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -7,18 +7,35 @@ import type { RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' import type { SshTarget } from '../../shared/ssh-types' +import type * as WorktreeExecutionHostResolution from '../../shared/worktree-execution-host-resolution' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' const { getActiveMultiplexerMock, getSshConnectionStoreMock, - registerRemoteWorkspaceNotificationHandlerMock + registerRemoteWorkspaceNotificationHandlerMock, + resolveWorktreeExecutionHostCalls } = vi.hoisted(() => ({ getActiveMultiplexerMock: vi.fn(), getSshConnectionStoreMock: vi.fn(), - registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()) + registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()), + resolveWorktreeExecutionHostCalls: { count: 0 } })) +// Counts ownership resolutions without changing any of them. +vi.mock('../../shared/worktree-execution-host-resolution', async (importOriginal) => { + const actual = (await importOriginal()) as typeof WorktreeExecutionHostResolution + return { + ...actual, + resolveWorktreeExecutionHost: ( + ...args: Parameters + ) => { + resolveWorktreeExecutionHostCalls.count += 1 + return actual.resolveWorktreeExecutionHost(...args) + } + } +}) + vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), @@ -154,9 +171,10 @@ describe('remoteWorkspace:setForConnectedTargets', () => { const getRepoMock = vi.fn() const getWorkspaceSessionMock = vi.fn() // Ownership resolution reads the catalog, not one id-keyed row, so the fake has to project one. + const getReposMock = vi.fn(() => [getRepoMock('repo-target-1')].filter(Boolean)) const store = { getRepo: getRepoMock, - getRepos: () => [getRepoMock('repo-target-1')].filter(Boolean), + getRepos: getReposMock, getWorkspaceSession: getWorkspaceSessionMock } as unknown as Store @@ -176,6 +194,7 @@ describe('remoteWorkspace:setForConnectedTargets', () => { getTarget: (targetId: string) => targets.find((target) => target.id === targetId) }) getRepoMock.mockReset() + getReposMock.mockClear() getWorkspaceSessionMock.mockReset() getWorkspaceSessionMock.mockReturnValue(baseSession) getRepoMock.mockImplementation((repoId: string) => @@ -251,6 +270,79 @@ describe('remoteWorkspace:setForConnectedTargets', () => { return observed as RemoteWorkspaceObservedSnapshot } + it('reads the repo catalog once per publish, not once per worktree', async () => { + // `store.getRepos()` re-hydrates every repo row. The export asks "is this worktree mine?" once + // per worktree, so reading the catalog inside that callback multiplied hydration by the + // worktree count — 413 on the session that surfaced this. + const worktrees = Object.fromEntries( + Array.from({ length: 12 }, (_, index) => [`repo-target-1::/remote/repo-${index}`, []]) + ) + getWorkspaceSessionMock.mockReturnValue({ + ...baseSession, + tabsByWorktree: worktrees + } as WorkspaceSessionState) + const observed = await observeTarget('target-1') + getReposMock.mockClear() + + await callSetForConnectedTargets({ + hydratedTargetIds: ['target-1'], + expectedRevisionsByTargetId: { 'target-1': observed.revision }, + expectedHostObservationTokensByTargetId: { + 'target-1': observed.hostObservationToken + } + }) + + expect(getReposMock).toHaveBeenCalledTimes(1) + }) + + it('resolves each worktree ownership once for the whole publish, not once per target', async () => { + // Ownership is a function of the repo catalog alone; only the final `=== targetId` differs, so + // exporting to N targets used to repeat the identical resolution N times per worktree key. + const worktrees = Object.fromEntries( + Array.from({ length: 6 }, (_, index) => [`repo-target-1::/remote/repo-${index}`, []]) + ) + getWorkspaceSessionMock.mockReturnValue({ + ...baseSession, + tabsByWorktree: worktrees + } as WorkspaceSessionState) + const observed = await Promise.all(targets.map((target) => observeTarget(target.id))) + getReposMock.mockClear() + resolveWorktreeExecutionHostCalls.count = 0 + + await callSetForConnectedTargets({ + hydratedTargetIds: targets.map((target) => target.id), + expectedRevisionsByTargetId: Object.fromEntries( + targets.map((target, index) => [target.id, observed[index].revision]) + ), + expectedHostObservationTokensByTargetId: Object.fromEntries( + targets.map((target, index) => [target.id, observed[index].hostObservationToken]) + ) + }) + + expect(getReposMock).toHaveBeenCalledTimes(1) + // 6 worktree keys resolved once each, regardless of how many targets are published to. + expect(resolveWorktreeExecutionHostCalls.count).toBe(6) + }) + + it('skips the session and repo-catalog reads when no hydrated target is connected', async () => { + // A hydrated but disconnected target leaves nothing to project onto, so hoisting the catalog + // read must not make the idle path pay for a full repo hydration it never used before. + getActiveMultiplexerMock.mockReturnValue(undefined) + getReposMock.mockClear() + getWorkspaceSessionMock.mockClear() + + await expect( + callSetForConnectedTargets({ + hydratedTargetIds: ['target-1'], + expectedRevisionsByTargetId: { 'target-1': 7 }, + expectedHostObservationTokensByTargetId: { 'target-1': 'token' } + }) + ).resolves.toEqual([]) + + expect(getReposMock).not.toHaveBeenCalled() + expect(getWorkspaceSessionMock).not.toHaveBeenCalled() + }) + it('does not write without an explicit non-empty hydrated target set', async () => { await expect(callSetForConnectedTargets({ session: baseSession })).resolves.toEqual([]) await expect( diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index f9479f15ce9..935fd1c9f72 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -1,5 +1,6 @@ import { ipcMain, type BrowserWindow } from 'electron' import type { Store } from '../persistence' +import type { Repo } from '../../shared/repo-types' import { getActiveMultiplexer, getSshConnectionStore } from './ssh' import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection' import { @@ -107,7 +108,7 @@ function getExpectedHostObservationTokens( } function targetForWorktree( - store: Store, + repoLookup: ReturnType>, worktreeId: string, executionHostId?: string ): string | null { @@ -115,21 +116,49 @@ function targetForWorktree( // `getRepo(id)?.connectionId`, which is host-blind — the same repo id can name rows on several // hosts, so a session could be published to a machine that never owned the worktree (#11163). // Unresolvable ownership exports to nobody rather than guessing. - const resolution = resolveWorktreeExecutionHost( - createRepoRowExecutionHostLookup(store.getRepos()), - { repoId: getRepoIdFromWorktreeId(worktreeId), hostId: executionHostId ?? null } - ) + const resolution = resolveWorktreeExecutionHost(repoLookup, { + repoId: getRepoIdFromWorktreeId(worktreeId), + hostId: executionHostId ?? null + }) return resolution.kind === 'resolved' ? resolution.connectionId : null } +/** + * Resolve each worktree's owning connection at most once for a whole publish. + * + * Why this is shared and not per target: `targetForWorktree` computes a connection id from the + * repo catalog alone — only the final `=== targetId` differs — so exporting to N targets used to + * repeat the identical resolution N times over every worktree key. `store.getRepos()` also + * re-hydrates every repo row on each call, and the projection asks this question once per key of + * `tabsByWorktree`, `activeTabIdByWorktree`, `lastVisitedAtByWorktreeId` and + * `defaultTerminalTabsAppliedByWorktreeId`. + */ +function createWorktreeTargetResolver( + repoLookup: ReturnType> +): (worktreeId: string, executionHostId?: string) => string | null { + const resolved = new Map() + return (worktreeId, executionHostId) => { + // Host id participates in resolution, so it has to participate in the key. NUL cannot appear + // in either id, so it is a collision-free separator. + const key = `${worktreeId}\u0000${executionHostId ?? ''}` + const cached = resolved.get(key) + if (cached !== undefined) { + return cached + } + const connectionId = targetForWorktree(repoLookup, worktreeId, executionHostId) + resolved.set(key, connectionId) + return connectionId + } +} + function exportSessionForTarget( - store: Store, + resolveWorktreeTarget: (worktreeId: string, executionHostId?: string) => string | null, targetId: string, session: WorkspaceSessionState ): RemoteWorkspaceSession { return exportRemoteWorkspaceSession(session, { isTargetWorktree: (worktreeId, executionHostId) => - targetForWorktree(store, worktreeId, executionHostId) === targetId + resolveWorktreeTarget(worktreeId, executionHostId) === targetId }) } @@ -245,12 +274,21 @@ export function registerRemoteWorkspaceHandlers( (target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id) ) ?? [] + if (targets.length === 0) { + // Nothing to project onto, so skip the session and repo-catalog reads entirely. + return [] + } + const workspaceSession = args.session ?? store.getWorkspaceSession() + // One repo read, and ownership resolutions shared across targets: neither depends on the target. + const resolveWorktreeTarget = createWorktreeTargetResolver( + createRepoRowExecutionHostLookup(store.getRepos()) + ) const results = await Promise.all( targets.map(async (target) => { // Why: each target has its own revision stream. Keep same-target // writes queued, but do not let one slow relay block others. - const session = exportSessionForTarget(store, target.id, workspaceSession) + const session = exportSessionForTarget(resolveWorktreeTarget, target.id, workspaceSession) const result = await queueRemoteWorkspacePatch(target.id, async () => { const current = getCachedRemoteWorkspaceSnapshot(target.id) ?? (await getRemoteSnapshot(target)) diff --git a/src/main/ipc/ssh-ipc-test-harness.ts b/src/main/ipc/ssh-ipc-test-harness.ts index 84802fe83a0..581fcc916ca 100644 --- a/src/main/ipc/ssh-ipc-test-harness.ts +++ b/src/main/ipc/ssh-ipc-test-harness.ts @@ -25,6 +25,7 @@ export type SshLeaseStoreMock = { upsertSshPtyConsumerRecovery: Mock removeSshPtyConsumerRecovery: Mock getSshRemotePtyLeases: Mock + reconcileSshRemotePtyLeasesForTarget: Mock markSshRemotePtyLease: Mock markSshRemotePtyLeases: Mock markSshRemotePtyLeasesAsync: Mock @@ -102,6 +103,7 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { upsertSshPtyConsumerRecovery: vi.fn(), removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + reconcileSshRemotePtyLeasesForTarget: vi.fn(), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), markSshRemotePtyLeasesAsync: vi.fn(), diff --git a/src/main/ipc/worktree-base-directory-marker-poller.ts b/src/main/ipc/worktree-base-directory-marker-poller.ts index dba1c4df03c..5f0acee4ee0 100644 --- a/src/main/ipc/worktree-base-directory-marker-poller.ts +++ b/src/main/ipc/worktree-base-directory-marker-poller.ts @@ -28,7 +28,7 @@ const PENDING_MARKER_MAX_TICKS = 300 // Why: matches the git-common poller's fan-out bound (#17828) — bounded // concurrency turns hundreds of serial round trips into a handful of batches // without dumping every candidate onto libuv's 4-thread pool at once. -const MARKER_PROBE_CONCURRENCY = 8 +export const MARKER_PROBE_CONCURRENCY = 8 function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` @@ -186,7 +186,7 @@ export async function startBasePoller( } const checkPendingMarkers = async (): Promise => { - const events: WorktreeBasePollEvent[] = [] + const dueDirs: string[] = [] for (const [dir, firstSeenTick] of markerProbeStartedAt) { if (firstSeenTick === null) { continue @@ -195,13 +195,19 @@ export async function startBasePoller( markerProbeStartedAt.set(dir, null) continue } + dueDirs.push(dir) + } + const events: WorktreeBasePollEvent[] = [] + // Same bound as the full scan's fan-out: serial probes cost D x latency per tick, + // which a WSL- or network-backed base directory pays for up to `pendingMarkerMaxTicks`. + await forEachWithConcurrency(dueDirs, MARKER_PROBE_CONCURRENCY, async (dir) => { options.onPendingMarkerProbe?.(join(dir, '.git')) if (await hasGitMarker(dir)) { markerProbeStartedAt.delete(dir) snapshot.markers.set(dir, true) events.push({ type: 'create', path: join(dir, '.git') }) } - } + }) if (!disposed && events.length > 0) { onEvents(events) } diff --git a/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts b/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts index 023a8390ccd..508a7bf8c41 100644 --- a/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts +++ b/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import type * as NodeFsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { MARKER_PROBE_CONCURRENCY } from './worktree-base-directory-marker-poller' import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' import type { WorktreeBaseRepoWatchConfig, @@ -12,7 +13,11 @@ import type { // Why: the backstop full scan stats a `.git` marker per candidate dir; an // unbounded fan-out at hundreds of worktrees would queue thousands of `stat` // calls on libuv's 4-thread pool (#17828). -const { concurrency } = vi.hoisted(() => ({ concurrency: { current: 0, peak: 0 } })) +const { concurrency, markerStatGate } = vi.hoisted(() => ({ + concurrency: { current: 0, peak: 0 }, + // Parks `.git` stats so a batch's launched-at-once width is observable without wall clocks. + markerStatGate: { hold: false, parked: [] as (() => void)[] } +})) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -22,6 +27,9 @@ vi.mock('node:fs/promises', async (importOriginal) => { concurrency.current += 1 concurrency.peak = Math.max(concurrency.peak, concurrency.current) try { + if (markerStatGate.hold && String(args[0]).endsWith('.git')) { + await new Promise((resolve) => markerStatGate.parked.push(resolve)) + } return await actual.stat(...args) } finally { concurrency.current -= 1 @@ -50,12 +58,27 @@ describe('worktree base directory poller marker fan-out (#17828)', () => { beforeEach(() => { concurrency.current = 0 concurrency.peak = 0 + markerStatGate.hold = false + markerStatGate.parked.length = 0 }) afterEach(async () => { + markerStatGate.hold = false + for (const resume of markerStatGate.parked.splice(0)) { + resume() + } await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) }) + async function waitUntil(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 2_000 && !predicate(); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + if (!predicate()) { + throw new Error('timed out waiting for the poller') + } + } + it('bounds concurrent `.git`-marker stats regardless of candidate count', async () => { const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-fanout-'))) cleanups.push(() => rm(root, { recursive: true, force: true })) @@ -81,4 +104,47 @@ describe('worktree base directory poller marker fan-out (#17828)', () => { expect(concurrency.peak).toBeGreaterThan(1) expect(concurrency.peak).toBeLessThan(20) }) + + it('probes pending `.git` markers in bounded batches instead of one at a time', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-pending-'))) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const pendingCount = MARKER_PROBE_CONCURRENCY * 4 + for (let i = 0; i < pendingCount; i++) { + // No `.git`: every dir stays a pending-marker candidate for the whole test. + await mkdir(join(root, `pending-${i}`)) + } + + const probed: string[] = [] + let parkFirstBatch = true + const target = makeTarget(root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { + pollIntervalMs: 1, + onPendingMarkerProbe: (path) => { + probed.push(path) + // Park from the first probe onward, so the count below is the batch width. + markerStatGate.hold = parkFirstBatch + } + } + ) + cleanups.push(() => poller.unsubscribe()) + + await waitUntil(() => markerStatGate.parked.length > 0) + + // Serial probing parks after one; the batch launches exactly the bound at once. + expect(probed.length).toBe(MARKER_PROBE_CONCURRENCY) + + parkFirstBatch = false + markerStatGate.hold = false + for (const resume of markerStatGate.parked.splice(0)) { + resume() + } + await waitUntil(() => probed.length >= pendingCount) + + // The first tick still probes every due dir exactly once. + expect(new Set(probed.slice(0, pendingCount)).size).toBe(pendingCount) + }) }) diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 17ad0c49e46..7a8fe175c89 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -4,9 +4,15 @@ import type { Repo } from '../../shared/repo-types' import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath, resolveWslRepoWorktreeBasePath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree/id' -import { replaceKnownEmojiWithShortcodes } from '../../shared/emoji-shortcode-catalog' +import { + replaceKnownEmojiWithShortcodes, + setEmojiShortcodeDatasetLoader +} from '../../shared/emoji-shortcode-catalog' +import { requireEmojiShortcodeDataset } from './deferred-emoji-shortcode-dataset' import { getWslHome, getWslHomeAsync, parseWslPath } from '../wsl' +setEmojiShortcodeDatasetLoader(requireEmojiShortcodeDataset) + type WorktreePathSettings = Pick & { /** Distro to mirror the workspace root into when the repo itself sits on a * Windows drive but this project's git runs in WSL. Omitted = today's diff --git a/src/main/ipc/worktrees-create-execution-host-routing.test.ts b/src/main/ipc/worktrees-create-execution-host-routing.test.ts new file mode 100644 index 00000000000..36f1e816ea6 --- /dev/null +++ b/src/main/ipc/worktrees-create-execution-host-routing.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + addWorktreeMock, + getActiveMultiplexerMock, + getSshGitProviderMock, + listWorktreesMock +} from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('../providers/ssh-git-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() +) +vi.mock('../providers/ssh-filesystem-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) + +const REMOTE_REPO_PATH = '/remote/repo' + +function makeRepo(fields: Record) { + return { + id: 'repo-1', + path: REMOTE_REPO_PATH, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/main', + ...fields + } +} + +function makeProvider(worktreePath: string) { + return { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + // A hit here reads as "branch already exists"; the create loop would then rename. + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: worktreePath, + head: 'abc123', + branch: 'refs/heads/wt', + isBare: false, + isMainWorktree: false + } + ]) + } +} + +function useRepo(repo: ReturnType): void { + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) +} + +describe('worktrees:create execution host routing', () => { + beforeEach(() => { + setupWorktreeHandlers() + }) + + it('creates on the SSH host for a row that names it only as executionHostId', async () => { + // No `connectionId`: the raw read answered "local" and ran `git worktree add` on the client + // against `/remote/repo`. The runtime sibling already resolved this row remotely. + useRepo(makeRepo({ executionHostId: 'ssh:target-a' })) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(provider.addWorktree).toHaveBeenCalledTimes(1) + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('keeps two simultaneously registered SSH hosts apart', async () => { + useRepo(makeRepo({ executionHostId: 'ssh:target-b' })) + const providerA = makeProvider('/remote/repo-wt-a') + const providerB = makeProvider('/remote/repo-wt-b') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? providerA : connectionId === 'target-b' ? providerB : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(providerB.addWorktree).toHaveBeenCalledTimes(1) + expect(providerA.addWorktree).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a runtime row with no nested SSH target instead of creating locally', async () => { + useRepo(makeRepo({ executionHostId: 'runtime:env-1' })) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('refuses a runtime row whose nested SSH target is dialable in this namespace', async () => { + // `target-a` names a target inside env-1. A same-named one registered here is another machine, + // so creating through it lands the checkout on the wrong host. + useRepo(makeRepo({ executionHostId: 'runtime:env-1', connectionId: 'target-a' })) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(provider.addWorktree).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it('answers local for a row that declares itself local while carrying a connection', async () => { + // A contradictory row: `getRepoSshConnectionId` lets `local` win, and the runtime sibling has + // always read it that way. The raw field sent it remote, so the two entry points disagreed. + useRepo( + makeRepo({ path: '/workspace/repo', executionHostId: 'local', connectionId: 'target-a' }) + ) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/wt', + head: 'abc123', + branch: 'wt', + isBare: false, + isMainWorktree: false + } + ]) + const provider = makeProvider('/remote/repo-wt') + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + expect(addWorktreeMock).toHaveBeenCalledTimes(1) + expect(provider.addWorktree).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts index f371529963c..1f94598208b 100644 --- a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -29,6 +29,7 @@ import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' import { createFolderWorkspace } from './folder-workspace-creation' import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-host-ownership' +import { requireWorktreeCreateRoute } from '../../../worktree-create-execution-host-route' import type { WorktreeIpcContext } from '../worktree-ipc-context' export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void { @@ -62,11 +63,19 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi let result: CreateWorktreeResult try { // Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks. - result = isFolderRepo(repo) - ? createFolderWorkspace(createArgs, repo, store) - : repo.connectionId - ? await createRemoteWorktree(createArgs, repo, store, mainWindow) - : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) + if (isFolderRepo(repo)) { + // A folder workspace is a registration, not a filesystem create, so it is host-agnostic. + result = createFolderWorkspace(createArgs, repo, store) + } else { + // Resolve the host rather than reading the raw field: an `executionHostId: 'ssh:*'`-only + // row read as local here and ran `git worktree add` on the client against a remote path, + // while the runtime sibling on the same repo already resolved. + const createRoute = requireWorktreeCreateRoute(repo) + result = + createRoute.kind === 'ssh' + ? await createRemoteWorktree(createArgs, createRoute.repo, store, mainWindow) + : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) + } } catch (error) { releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) track('workspace_create_failed', { diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing-meta-index.test.ts b/src/main/ipc/worktrees/listing/detected-provider-listing-meta-index.test.ts new file mode 100644 index 00000000000..4bf55e97492 --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-provider-listing-meta-index.test.ts @@ -0,0 +1,131 @@ +/** + * The SSH worktree-meta index is only ever read via `metaIndex.get(repo.id)` on the disconnected + * fallbacks, so a connected listing must not pay `parseWorktreeId` over the whole host snapshot. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { Store } from '../../../persistence/loading-store/store' +import type * as SshWorktreeFallbackModule from './ssh-worktree-fallback' + +const { getSshGitProviderMock, indexBuildSpy } = vi.hoisted(() => ({ + getSshGitProviderMock: vi.fn(), + indexBuildSpy: vi.fn() +})) + +vi.mock('../../../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + requireSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: () => 1 +})) + +vi.mock('./ssh-worktree-fallback', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Both builders are counted: the point is that NO index is built on the connected path. + createSshWorktreeMetaIndex: (...args: Parameters) => { + indexBuildSpy('all-hosts', ...args) + return actual.createSshWorktreeMetaIndex(...args) + }, + createSshWorktreeMetaIndexForRepo: ( + ...args: Parameters + ) => { + indexBuildSpy('repo-scoped', ...args) + return actual.createSshWorktreeMetaIndexForRepo(...args) + } + } +}) + +const { listDetectedWorktreesForCapturedRepo } = await import('./detected-provider-listing') + +const repo = { + id: 'repo-1', + path: '/home/user/repo', + displayName: 'repo', + connectionId: 'conn-1' +} as Repo + +const worktreeId = `${repo.id}::/home/user/feature` + +function createStore(): Store { + const rows: Record = { + [worktreeId]: { instanceId: 'instance-1' }, + // Other repos' rows share the host snapshot; only this repo's bucket is ever read back. + 'repo-2::/home/user/other': { instanceId: 'instance-2' } + } + return { + getRepos: () => [repo], + getRepo: () => repo, + getSettings: () => ({}), + getProjectHostSetups: () => [], + getAllWorktreeLineage: () => ({}), + getAllWorktreeMeta: () => rows, + getWorktreeMeta: (id: string) => rows[id], + setWorktreeMeta: vi.fn() + } as unknown as Store +} + +describe('SSH worktree meta index construction', () => { + beforeEach(() => { + indexBuildSpy.mockClear() + getSshGitProviderMock.mockReset() + }) + + it('does not build the index when the provider answers', async () => { + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { path: repo.path, head: 'a', branch: 'main', isBare: false, isMainWorktree: true }, + { + path: '/home/user/feature', + head: 'b', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + } + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider as never + ) + + expect(result).toMatchObject({ authoritative: true, source: 'git' }) + expect(indexBuildSpy).not.toHaveBeenCalled() + }) + + it('builds the index once when no provider is available', async () => { + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + undefined + ) + + expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' }) + expect(indexBuildSpy).toHaveBeenCalledTimes(1) + expect(indexBuildSpy).toHaveBeenCalledWith('all-hosts', expect.anything()) + expect( + (result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id) + ).toEqual([worktreeId]) + }) + + it('builds the index once when the provider listing fails', async () => { + const provider = { listWorktrees: vi.fn().mockRejectedValue(new Error('relay down')) } + + const result = await listDetectedWorktreesForCapturedRepo( + createStore(), + repo, + () => true, + provider as never + ) + + expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' }) + expect(indexBuildSpy).toHaveBeenCalledTimes(1) + expect( + (result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id) + ).toEqual([worktreeId]) + }) +}) diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 51a0618ba66..25c08d262fb 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -10,7 +10,8 @@ import type { ListDesktopLineageForHostArgs } from '../../../../shared/host-line import { buildDetectedGitWorktrees, createSshWorktreeMetaIndex, - listDisconnectedSshWorktrees + listDisconnectedSshWorktrees, + type SshWorktreeMetaIndex } from './ssh-worktree-fallback' import { buildDisconnectedDetectedWorktrees, @@ -42,9 +43,11 @@ export async function listDetectedWorktreesForCapturedRepo( const allMeta = isFolderRepo(repo) ? undefined : readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo)) - const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {})) - : new Map() + // Why: only the disconnected fallbacks read this, so keep parseWorktreeId over the whole host snapshot + // off the connected path entirely. + let cachedSshWorktreeMetaIndex: SshWorktreeMetaIndex | undefined + const sshWorktreeMetaIndex = (): SshWorktreeMetaIndex => + (cachedSshWorktreeMetaIndex ??= createSshWorktreeMetaIndex(Object.entries(allMeta ?? {}))) try { let gitWorktrees: GitWorktreeInfo[] @@ -86,7 +89,7 @@ export async function listDetectedWorktreesForCapturedRepo( if (!isCurrent()) { return null } - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex()) return { repoId: repo.id, authoritative: false, @@ -158,7 +161,7 @@ export async function listDetectedWorktreesForCapturedRepo( err ) if (repo.connectionId) { - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex()) return { repoId: repo.id, authoritative: false, diff --git a/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts b/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts new file mode 100644 index 00000000000..6a25d696c9b --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts @@ -0,0 +1,234 @@ +/** + * Guards the single-classification contract of `buildDetectedGitWorktrees`: every visible worktree + * used to be run through `mergeWorktree` + `toDetectedWorktree` twice per catalog pass. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { Store } from '../../../persistence/loading-store/store' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { GitWorktreeInfo } from '../../../../shared/worktree/types' +import type * as NodeCryptoModule from 'node:crypto' +import type * as OwnershipModule from '../../../../shared/worktree/ownership' + +const { toDetectedWorktreeSpy } = vi.hoisted(() => ({ toDetectedWorktreeSpy: vi.fn() })) + +vi.mock('../../../../shared/worktree/ownership', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toDetectedWorktree: (args: Parameters[0]) => { + toDetectedWorktreeSpy(args) + return actual.toDetectedWorktree(args) + } + } +}) + +vi.mock('node:crypto', async (importOriginal) => ({ + ...(await importOriginal()), + randomUUID: () => 'fixed-instance-id' +})) + +const { buildDetectedGitWorktrees } = await import('./ssh-worktree-fallback') +const { getProjectHostSetupWorktreeMeta } = + await import('../../../../shared/project-host-setup-lookup') +const { mergeWorktree } = await import('../../worktree-logic') +const { resolveWorktreeMetaWithDiscoveryBackfill } = await import('./worktree-discovery-metadata') +const ownership = await import('../../../../shared/worktree/ownership') +const { projectResolvedWorktreeLineage } = + await import('../../../../shared/resolved-worktree-lineage') +const { createWorktreeVisibilitySourceMatcher, resolveCustomWorktreeVisibilitySources } = + await import('../../../../shared/worktree/visibility-sources') +const { resolveConfiguredWorktreeBasePaths } = + await import('../../../../shared/worktree/configured-worktree-base-path') +const { dedupeWorktreesByPath } = await import('../../worktree-path-comparison') +const { readWorktreeMetaForHost } = + await import('../../../persistence/host-qualified-worktree-meta') +const { getRepoOwnedWorktreeMeta } = await import('../../../worktree-metadata-ownership') +const { getRepoExecutionHostId } = await import('../../../../shared/execution-host') + +const repo: Repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 +} as Repo + +const ownershipMeta = getProjectHostSetupWorktreeMeta([], repo) + +function gitWorktree(path: string): GitWorktreeInfo { + return { + path, + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } +} + +/** Fully settled metadata: discovery backfill has nothing to write, so it hands the same object back. */ +function settledMeta(overrides: Partial = {}): WorktreeMeta { + return { + ...ownershipMeta, + instanceId: 'instance-settled', + orcaCreatedAt: 1, + lastActivityAt: 5, + ...overrides + } as WorktreeMeta +} + +function createStore(meta: Record, repos: Repo[] = [repo]) { + const rows = { ...meta } + return { + getRepos: () => repos, + getSettings: () => ({ workspaceDir: '/workspace', nestWorkspaces: true }), + getProjectHostSetups: () => [], + getAllWorktreeLineage: () => ({}), + getAllWorktreeMeta: () => rows, + getWorktreeMeta: (id: string) => rows[id], + getWorktreeMetaForHost: (id: string, hostId: string) => + rows[id]?.hostId === hostId ? rows[id] : undefined, + getAllWorktreeMetaForHost: () => rows, + setWorktreeMeta: (id: string, patch: Partial) => { + rows[id] = { ...rows[id], ...patch } as WorktreeMeta + return rows[id] + }, + setWorktreeMetaForHost: (id: string, hostId: string, patch: Partial) => { + rows[id] = { ...rows[id], ...patch, hostId } as WorktreeMeta + return rows[id] + } + } as unknown as Store +} + +/** The pre-change implementation, verbatim, as the equivalence oracle. */ +function buildDetectedGitWorktreesTwoPass( + store: Store, + target: Repo, + gitWorktrees: GitWorktreeInfo[], + allMetaOverride?: Record +) { + const settings = store.getSettings() + const knownOrcaLayouts = ownership.buildKnownOrcaWorkspaceLayouts(settings, target) + const isLegacyRepoForVisibility = ownership.isLegacyRepoForExternalWorktreeVisibility(target) + const liveWorktrees = dedupeWorktreesByPath(gitWorktrees.filter((info) => !info.prunable)) + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [target.path, ...liveWorktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(target, settings.worktreeVisibilityDefaults), + resolveConfiguredWorktreeBasePaths(target) + ) + const allMeta = allMetaOverride ?? store.getAllWorktreeMeta?.() + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === target.id).length + const detectedRows = liveWorktrees.map((info) => { + const worktreeId = `${target.id}::${info.path}` + const legacyMeta = store.getWorktreeMeta?.(worktreeId) + const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {}) + let meta = + readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(target)) ?? + getRepoOwnedWorktreeMeta(target, worktreeId, metaById, repoOwnerCount) + const worktree = mergeWorktree(target.id, info, meta, target.displayName) + const detected = ownership.toDetectedWorktree({ + repo: target, + worktree, + meta, + settings, + knownOrcaLayouts, + isLegacyRepoForVisibility, + worktreeVisibilitySourceMatcher + }) + if (!detected.visible) { + return detected + } + meta = resolveWorktreeMetaWithDiscoveryBackfill( + store, + target, + worktreeId, + allMeta, + repoOwnerCount + ) + return ownership.toDetectedWorktree({ + repo: target, + worktree: mergeWorktree(target.id, info, meta, target.displayName), + meta, + settings, + knownOrcaLayouts, + isLegacyRepoForVisibility, + worktreeVisibilitySourceMatcher + }) + }) + return projectResolvedWorktreeLineage(detectedRows, store.getAllWorktreeLineage?.() ?? {}) +} + +describe('buildDetectedGitWorktrees classification passes', () => { + beforeEach(() => { + toDetectedWorktreeSpy.mockClear() + // Discovery backfill stamps lastActivityAt from the clock; freeze it so equivalence is deterministic. + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + }) + + it('classifies each visible worktree once per catalog pass, not twice', () => { + const paths = ['/workspace/one', '/workspace/two', '/workspace/three'] + const meta = Object.fromEntries( + paths.map((path) => [`${repo.id}::${path}`, settledMeta({ displayName: path })]) + ) + const store = createStore(meta) + + const detected = buildDetectedGitWorktrees(store, repo, paths.map(gitWorktree), meta) + + expect(detected).toHaveLength(3) + expect(detected.every((row) => row.visible)).toBe(true) + expect(toDetectedWorktreeSpy).toHaveBeenCalledTimes(paths.length) + }) + + it('reads the locator-keyed metadata row only when no host snapshot is available', () => { + const worktreeId = `${repo.id}::/workspace/one` + const meta = { [worktreeId]: settledMeta() } + const store = createStore(meta) + const legacyReads = vi.spyOn(store, 'getWorktreeMeta') + + buildDetectedGitWorktrees(store, repo, [gitWorktree('/workspace/one')], meta) + expect(legacyReads).not.toHaveBeenCalled() + + // Partial stores (compatibility shapes) expose no snapshot, so the locator-keyed lookup must still run. + const partialStore = createStore(meta) as Partial + delete partialStore.getAllWorktreeMeta + delete partialStore.getAllWorktreeMetaForHost + delete partialStore.getWorktreeMetaForHost + const partialLegacyReads = vi.spyOn(partialStore as Store, 'getWorktreeMeta') + + const rows = buildDetectedGitWorktrees( + partialStore as Store, + repo, + [gitWorktree('/workspace/one')], + undefined + ) + expect(partialLegacyReads).toHaveBeenCalledWith(worktreeId) + expect(rows[0]).toMatchObject({ id: worktreeId, lastActivityAt: 5 }) + }) + + it.each([ + ['settled metadata', () => settledMeta()], + ['metadata needing discovery backfill', () => ({ orcaCreatedAt: 1 }) as WorktreeMeta], + ['no metadata at all', () => undefined] + ])('emits a catalog deep-equal to the two-pass build for %s', (_label, makeMeta) => { + const worktreeId = `${repo.id}::/workspace/one` + const seed = makeMeta() + const build = (fn: typeof buildDetectedGitWorktrees) => + fn( + createStore(seed ? { [worktreeId]: seed } : {}), + repo, + [gitWorktree('/workspace/one'), gitWorktree('/workspace/hidden-external')], + seed ? { [worktreeId]: seed } : {} + ) + + expect(build(buildDetectedGitWorktrees)).toEqual(build(buildDetectedGitWorktreesTwoPass)) + }) + + it('emits a catalog deep-equal to the two-pass build for a folder-style listing with no host snapshot', () => { + const worktreeId = `${repo.id}::/workspace/one` + const seed = settledMeta() + const build = (fn: typeof buildDetectedGitWorktrees) => + fn(createStore({ [worktreeId]: seed }), repo, [gitWorktree('/workspace/one')], undefined) + + expect(build(buildDetectedGitWorktrees)).toEqual(build(buildDetectedGitWorktreesTwoPass)) + }) +}) diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts index 7ec2cac1fc9..5c734d8bcd9 100644 --- a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -155,9 +155,10 @@ export function buildDetectedGitWorktrees( const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length const detected = liveWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` - const legacyMeta = store.getWorktreeMeta?.(worktreeId) + // Why: the locator-keyed row is only a stand-in for a missing host snapshot, so don't read it when we have one. + const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {}) - let meta = + const meta = readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) ?? getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) @@ -174,17 +175,21 @@ export function buildDetectedGitWorktrees( return detected } - meta = resolveWorktreeMetaWithDiscoveryBackfill( + const backfilledMeta = resolveWorktreeMetaWithDiscoveryBackfill( store, repo, worktreeId, allMeta, repoOwnerCount ) + // Why: backfill hands back the same object when it wrote nothing, and both builders are pure over it. + if (backfilledMeta === meta) { + return detected + } return toDetectedWorktree({ repo, - worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), - meta, + worktree: mergeWorktree(repo.id, gitWorktree, backfilledMeta, repo.displayName), + meta: backfilledMeta, settings, knownOrcaLayouts, isLegacyRepoForVisibility, diff --git a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts index 6af4d45c3cd..b17677ddfcc 100644 --- a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts +++ b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts @@ -40,8 +40,9 @@ export function resolveWorktreeMetaWithDiscoveryBackfill( repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length ): WorktreeMeta { const executionHostId = getRepoExecutionHostId(repo) - const legacyMeta = store.getWorktreeMeta?.(worktreeId) const allMeta = allMetaOverride ?? store.getAllWorktreeMeta?.() + // Why: the locator-keyed row is only a stand-in for a missing snapshot, so don't read it when we have one. + const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined const existing = readWorktreeMetaForHost(store, worktreeId, executionHostId) ?? getRepoOwnedWorktreeMeta( diff --git a/src/main/orca-chromium-process-pids.ts b/src/main/orca-chromium-process-pids.ts index cbce761cf8f..3282f3a4d66 100644 --- a/src/main/orca-chromium-process-pids.ts +++ b/src/main/orca-chromium-process-pids.ts @@ -1,4 +1,5 @@ import { getAppEnvironment, hasAppEnvironment } from '../shared/app-environment' +import { recordCoalescedDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' /** * PIDs of Orca's own Chromium processes — browser, renderers, GPU, utilities. @@ -17,6 +18,14 @@ import { getAppEnvironment, hasAppEnvironment } from '../shared/app-environment' * walk is refused. That is why a refusal only blocks the pid-addressed walk and * every gated site still kills its own root through the child handle. * + * Why failure stays open rather than refusing everything: a refusal is not free. + * `terminateWindowsProcessTree` resolves without killing, and + * `killSourceControlAgentProcess` returns that straight to a caller that then + * releases the managed-home lock, so failing closed would trade one unreadable + * metrics table for every PTY, git, codex and notebook tree in main leaking at + * once. The `own_chromium_pids_unreadable` crumb is the price of that choice: + * without it a throw is byte-identical to "no Chromium on this host". + * * Host coverage: only Electron main installs a Chromium-backed AppEnvironment * (main-process-preflight). The standalone daemon installs none and `orcad` * installs a Node one whose `getAppMetrics()` is `[]`, so this set is empty in @@ -36,7 +45,26 @@ export function readOrcaChromiumProcessPids(): ReadonlySet { .map((metric) => metric.pid) .filter((pid) => Number.isInteger(pid) && pid > 0) return new Set(pids) - } catch { + } catch (error) { + recordUnreadableOwnChromiumMetrics(error) return new Set() } } + +// Why coalesced: the gate reads this set on every tree kill, so a persistently +// broken metrics table would otherwise flood the 30-slot ring it shares. +const UNREADABLE_METRICS_COALESCE_MS = 60_000 + +function recordUnreadableOwnChromiumMetrics(error: unknown): void { + try { + recordCoalescedDurableCrashBreadcrumb({ + name: 'own_chromium_pids_unreadable', + data: { cause: error instanceof Error ? error.message : String(error) }, + coalesceKey: 'own-chromium-pids-unreadable', + minIntervalMs: UNREADABLE_METRICS_COALESCE_MS + }) + } catch { + // Diagnostics must never turn an admitted kill into a thrown one: callers + // read this set outside their own try. + } +} diff --git a/src/main/own-chromium-tree-kill-guard.test.ts b/src/main/own-chromium-tree-kill-guard.test.ts index 315ec4f1dc4..7b9c30687bc 100644 --- a/src/main/own-chromium-tree-kill-guard.test.ts +++ b/src/main/own-chromium-tree-kill-guard.test.ts @@ -182,6 +182,40 @@ describe('refusing to tree-kill our own Chromium processes', () => { }) }) + /** + * Fail-open is the deliberate choice — see `orca-chromium-process-pids.ts` for + * why refusing everything is worse — so the crumb is the only thing that keeps + * an unreadable metrics table distinguishable from a host that has no Chromium. + */ + it('leaves proof, and still admits the kill, when the Chromium metrics cannot be read', () => { + appMetricsMock.mockImplementation(() => { + throw new Error('getAppMetrics unavailable') + }) + + expect([...readOrcaChromiumProcessPids()]).toEqual([]) + // Coalesced: the gate reads this set on every kill, so a broken table must + // not evict the ring it shares with the refusal crumb. + expect([...readOrcaChromiumProcessPids()]).toEqual([]) + expect( + admitSelfInitiatedTreeKill({ + pid: RENDERER_PID, + site: 'pty-descendant-sweep', + scope: 'win-taskkill-tree' + }) + ).toBe(true) + + expect( + getCrashBreadcrumbSnapshot().filter( + (breadcrumb) => breadcrumb.name === 'own_chromium_pids_unreadable' + ) + ).toEqual([ + expect.objectContaining({ + name: 'own_chromium_pids_unreadable', + data: expect.objectContaining({ cause: 'getAppMetrics unavailable' }) + }) + ]) + }) + it('refuses an own-Chromium pid at the gate the account teardowns share', () => { expect( admitSelfInitiatedTreeKill({ diff --git a/src/main/persistence-loading-store-extraction.test.ts b/src/main/persistence-loading-store-extraction.test.ts index e48971d6c5c..a3c5e248909 100644 --- a/src/main/persistence-loading-store-extraction.test.ts +++ b/src/main/persistence-loading-store-extraction.test.ts @@ -116,6 +116,47 @@ describe('loading Store extraction seams', () => { }) }) + it('timestamps persistence-load-done before resolving its details closure', () => { + const sentinel = 'startup-diagnostics-workspace-session-sentinel-ordering' + vi.stubEnv('ORCA_STARTUP_DIAGNOSTICS', '1') + const state = getDefaultPersistedState(testState.dir) + state.workspaceSession = { ...state.workspaceSession, activeTabId: sentinel } + writeDataFile(state) + + // Fake clock only the details closure advances, so a post-closure timestamp is unambiguous. + let clock = 0 + const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => clock) + const realStringify = JSON.stringify + const stringifySpy = vi.spyOn(JSON, 'stringify').mockImplementation((( + value: unknown, + ...rest: unknown[] + ) => { + if ( + value && + typeof value === 'object' && + (value as { activeTabId?: unknown }).activeTabId === sentinel + ) { + clock += 1000 + } + return (realStringify as (...args: unknown[]) => string)(value, ...rest) + }) as typeof JSON.stringify) + + try { + const store = createStore() + store.freezeWrites() + } finally { + stringifySpy.mockRestore() + nowSpy.mockRestore() + } + + const loadDoneCall = logStartupDiagnosticMock.mock.calls.find( + ([event]) => event === 'persistence-load-done' + ) + const details = loadDoneCall?.[1] as Record | undefined + expect(details?.workspaceSessionBytes).toEqual(expect.any(Number)) + expect(details?.t).toBe(0) + }) + it('accepts the first JSON-parseable backup even when an older backup has richer state', async () => { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), '{{corrupt-primary', 'utf-8') diff --git a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts new file mode 100644 index 00000000000..2f0dc41daaf --- /dev/null +++ b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { findTerminalTabIdForLeaf } from './runtime/workspace-session-terminal-membership-authority' +import { testState, createStore, makeTerminalTab } from './persistence-test-harness' +import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +describe('findTerminalTabIdForLeaf after persistPtyBinding grafts a leaf', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + // `persistPtyBinding` grafts the leaf by assigning `layout.root` on the SAME layout object inside + // the SAME layouts record (pty-binding-persistence.ts), so the resolver has to answer from the + // tree that is there now, not from anything derived on an earlier call. + it('resolves a leaf grafted in place by a split spawn', async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-source' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-source' } + } + } + }) + // A reader runs first, exactly as the syncWindowGraph lease sweep does. + expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)).toBe('tab1') + + expect( + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_2, + ptyId: 'pty-split' + }) + ).toBe(true) + + expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_2)).toBe('tab1') + expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)).toBe('tab1') + }) + + // The other in-place graft: an empty persisted layout gets its first durable root. + it('resolves the first leaf grafted onto an empty layout', async () => { + const store = await createStore() + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: null })] + }, + terminalLayoutsByTabId: { + tab1: { root: null, activeLeafId: null, expandedLeafId: null, ptyIdsByLeafId: {} } + } + }) + expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)).toBeUndefined() + + expect( + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-first' + }) + ).toBe(true) + + expect(findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1)).toBe('tab1') + }) +}) diff --git a/src/main/persistence-ssh-lease-reattach-reclaim.test.ts b/src/main/persistence-ssh-lease-reattach-reclaim.test.ts index 9d6c74f710e..b2088fa616b 100644 --- a/src/main/persistence-ssh-lease-reattach-reclaim.test.ts +++ b/src/main/persistence-ssh-lease-reattach-reclaim.test.ts @@ -49,14 +49,16 @@ describe('ssh remote pty lease reclaim after a proven reattach', () => { expect(sshRemotePtyLeaseAllowsReattach(lease)).toBe(true) }) - it('leaves a terminated lease absorbing even when the id appears in a reattach batch', async () => { + it('never lets a reattach batch revive an operator-closed id', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' }) store.markSshRemotePtyLease('ssh-1', 'pty-1', 'terminated') await store.markSshRemotePtyLeasesAttachedAsync('ssh-1', ['pty-1']) - expect(store.getSshRemotePtyLeases('ssh-1')[0]).toMatchObject({ state: 'terminated' }) + // The unbound tombstone is retired at close, and the batch only ever updates existing rows — + // so the id stays out of the reattach set either way. + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) }) it('does not revive an expired lease from an unqualified bulk attach', async () => { diff --git a/src/main/persistence-ssh-lease-tombstone-retention.test.ts b/src/main/persistence-ssh-lease-tombstone-retention.test.ts new file mode 100644 index 00000000000..feafdd766e4 --- /dev/null +++ b/src/main/persistence-ssh-lease-tombstone-retention.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createStore, testState } from './persistence-test-harness' +import { TEST_LEAF_1 } from './persistence-session-fixtures' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +describe('operator-closed SSH lease tombstones', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + /** A pane whose lease froze `tab-old` before `detachTerminalPaneToTab` moved it to `tab-new`. + * The binding scrub matches tab-qualified, so it cannot reach this row's binding. */ + async function storeWithDetachedPaneBinding(): Promise>> { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + worktreeId: 'wt1', + tabId: 'tab-old', + leafId: TEST_LEAF_1, + state: 'attached' + }) + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'wt1', + activeTabId: 'tab-new', + tabsByWorktree: { + wt1: [ + { + id: 'tab-new', + worktreeId: 'wt1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ptyId: null + } + ] + }, + terminalLayoutsByTabId: { + 'tab-new': { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' } + } + } + }) + return store + } + + it('keeps the tombstone while a binding the scrub could not reach still names the pty', async () => { + const store = await storeWithDetachedPaneBinding() + + store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') + + // `isRestorablePtyBinding` still consults this row to refuse replaying that binding. + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ + expect.objectContaining({ ptyId: 'remote-pty', state: 'terminated' }) + ]) + expect(store.getWorkspaceSession().terminalLayoutsByTabId['tab-new'].ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'ssh:ssh-1@@remote-pty' + }) + }) + + it('keeps an operator-closed lease that still owes an undelivered stop', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty', state: 'attached' }) + store.recordSshRemotePtyKillIntent('ssh-1', 'remote-pty', { + incarnationId: 'inc-1', + requestedAt: 1, + attempts: 0 + }) + + store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') + + expect(store.getSshRemotePtyKillIntents('ssh-1', 2)).toHaveLength(1) + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ + expect.objectContaining({ ptyId: 'remote-pty', state: 'terminated' }) + ]) + }) + + // `expired` is never evidence the shell died, and `sweepOrphanedRelayPtys` reads these ids as its + // leave-alone list, so dropping one would authorize stopping a process left running on purpose. + it('keeps a superseded expired lease when a sibling pane is closed', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty-1', + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + state: 'attached' + }) + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty-2', + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + state: 'attached' + }) + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'remote-pty-3', state: 'attached' }) + + store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty-3', 'terminated') + + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ + expect.objectContaining({ + ptyId: 'remote-pty-1', + state: 'expired', + supersededBy: 'remote-pty-2' + }), + expect.objectContaining({ ptyId: 'remote-pty-2', state: 'attached' }) + ]) + }) + + it('retires every unreachable tombstone for the target, not only the one just closed', async () => { + const store = await createStore() + for (const ptyId of ['remote-pty-1', 'remote-pty-2', 'remote-pty-3']) { + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId, state: 'terminated' }) + } + store.upsertSshRemotePtyLease({ targetId: 'ssh-2', ptyId: 'other-pty', state: 'terminated' }) + expect(store.getSshRemotePtyLeases()).toHaveLength(4) + + store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty-1', 'terminated') + + // Other targets are untouched: the pass is scoped to the one whose bindings were just scrubbed. + expect(store.getSshRemotePtyLeases()).toEqual([ + expect.objectContaining({ targetId: 'ssh-2', ptyId: 'other-pty' }) + ]) + }) +}) diff --git a/src/main/persistence-ssh-remote-pty-leases.test.ts b/src/main/persistence-ssh-remote-pty-leases.test.ts index 464138f69d4..d4cdc79285c 100644 --- a/src/main/persistence-ssh-remote-pty-leases.test.ts +++ b/src/main/persistence-ssh-remote-pty-leases.test.ts @@ -526,12 +526,9 @@ describe('Store', () => { store.markSshRemotePtyLeases('ssh-1', 'terminated') const session = store.getWorkspaceSession() - expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ - expect.objectContaining({ - ptyId: 'remote-pty', - state: 'terminated' - }) - ]) + // The scrub is what retires the row: with no binding left naming the id, the tombstone routes + // nothing and is dropped in the same write. + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) @@ -622,12 +619,8 @@ describe('Store', () => { store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') - expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ - expect.objectContaining({ - ptyId: 'remote-pty', - state: 'terminated' - }) - ]) + // An unresolved id would have left the lease `attached`; this unbound row is retired instead. + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) }) // `expired` never means the shell exited — every writer records that the CLIENT lost its route @@ -658,12 +651,7 @@ describe('Store', () => { store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') const session = store.getWorkspaceSession() - expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ - expect.objectContaining({ - ptyId: 'remote-pty', - state: 'terminated' - }) - ]) + expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) expect(session.tabsByWorktree.wt1[0].ptyId).toBeNull() expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index 7b628b4b678..e2c04495e92 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -6,6 +6,8 @@ import type { Repo } from '../shared/repo-types' import type { TerminalTab } from '../shared/terminal-tab-types' import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import type { PersistedState } from '../shared/persisted-state-types' +import { hydrateWorktreeMetaAliasProjection } from './persistence/loading-store/worktree-meta-alias-projection' import { Store } from './persistence/loading-store/store' import { initDataPath } from './persistence/loading-store/user-data-path' @@ -38,8 +40,16 @@ export function writeDataFile(data: unknown): void { writeFileSync(dataFile(), JSON.stringify(data, null, 2), 'utf-8') } +/** + * The persisted state as a reader gets it, not the raw bytes: the serializer omits any + * `worktreeMetaByIdentity` row the locator row regenerates, and every consumer of this file -- + * including the Store's own load path -- rebuilds those before looking at them. Tests that need + * the literal bytes parse the file themselves (see `worktree-meta-alias-projection.test.ts`). + */ export function readDataFile(): unknown { - return JSON.parse(readFileSync(dataFile(), 'utf-8')) + const parsed = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + hydrateWorktreeMetaAliasProjection(parsed) + return parsed } export function symlinkDirectorySync(target: string, linkPath: string): void { diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index 46db8c4f0c7..f06e5b0ece7 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -1,9 +1,9 @@ -import { toSshExecutionHostId } from '../../../shared/execution-host' import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' +import { pruneRetiredSshRemotePtyLeaseTombstones } from './ssh-pty-lease-tombstone-retention' +import { supersedeSiblingLeasesForPane } from './ssh-pty-pane-supersession' export type SshPtyLeaseOperations = { state: PersistedState @@ -15,91 +15,6 @@ export type SshPtyLeaseOperations = { flushDurableStateOrThrowAsync: () => Promise } -/** - * The PTY a pane is durably bound to, keyed on the leaf alone — the only remint-stable half of a - * pane key, since `detachTerminalPaneToTab` moves a live pane and leaves its lease naming the tab - * it left. - * - * Reads both partitions deliberately. Main writes some SSH pane bindings to `ssh:` and - * some to `local`, so a reader that consulted one would see "unbound" for a live pane and expire - * its lease. Reading both makes this fence correct whichever partition the binding landed in. - */ -function durablyBoundPtyIdForPane( - operations: SshPtyLeaseOperations, - targetId: string, - leafId: string -): string | undefined { - const findLeafBinding = (session: WorkspaceSessionState | undefined): string | undefined => - Object.values(session?.terminalLayoutsByTabId ?? {}).find( - (layout) => layout?.ptyIdsByLeafId?.[leafId] - )?.ptyIdsByLeafId?.[leafId] - const boundPtyId = - findLeafBinding(operations.state.workspaceSession) ?? - findLeafBinding(operations.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)]) - return boundPtyId ? operations.toComparablePtyId(targetId, boundPtyId) : undefined -} - -/** - * One pane owns at most one live remote PTY. Lease identity is `(targetId, ptyId)` alone, so a - * pane re-leasing under a new relay id leaves its predecessor live with nothing to retire it and - * the next reattach fans out over both — the reported 2 -> 19 -> 20 across three reconnects. - * - * Superseded leases are marked `expired`, never `terminated`: losing a lease is not evidence the - * shell died, so the remote process is deliberately left running. They also carry `supersededBy`, - * which is what keeps them out of the bulk reattach set now that plain `expired` no longer does — - * the winner's ptyId is already in hand here, so recording it needs no relay-start identity. - */ -function supersedeSiblingLeasesForPane( - operations: SshPtyLeaseOperations, - winner: SshRemotePtyLease, - now: number -): void { - if (!winner.worktreeId || !winner.leafId) { - return - } - if (winner.state === 'terminated' || winner.state === 'expired') { - return - } - // At upsert time the arriving lease may not be the one the pane is bound to yet. Expiring the - // bound predecessor would detach a live pane, so leave both live and let reattach arbitrate - // with the binding in hand. - const boundPtyId = durablyBoundPtyIdForPane(operations, winner.targetId, winner.leafId) - if (boundPtyId && boundPtyId !== winner.ptyId) { - return - } - const superseded: SshRemotePtyLease[] = [] - for (const lease of operations.state.sshRemotePtyLeases ?? []) { - if ( - lease.ptyId === winner.ptyId || - lease.targetId !== winner.targetId || - lease.worktreeId !== winner.worktreeId || - // Leaf only: a lease freezes its tabId, so a pane broken out into a new tab would otherwise - // never compete with its own predecessor — which is the reported cardinality growth. - lease.leafId !== winner.leafId || - lease.state === 'terminated' - ) { - continue - } - if (lease.state === 'expired') { - // An already-expired predecessor is superseded by the same evidence, and marking it is what - // bounds the reattach set: without this, every past orphan for this pane stays reattachable - // forever. `updatedAt` stays put — bumping it would make a stale lease look recent to - // `getRecentExpiredSshLease`. - lease.supersededBy = winner.ptyId - continue - } - lease.state = 'expired' - lease.supersededBy = winner.ptyId - lease.updatedAt = now - superseded.push(lease) - } - if (superseded.length > 0) { - // Why: matching on lease ptyId first means this scrubs only the predecessor's stale binding — - // the winner's own binding cannot match and is left intact. - operations.clearBindingsForLeases(winner.targetId, superseded) - } -} - /** * Only `terminated` unbinds a pane. It is the operator-close state and the one written after a * host-acknowledged stop; `expired` records that the CLIENT lost its route and says nothing about @@ -231,7 +146,11 @@ function updateSshRemotePtyLeaseStates( const bindingsChanged = shouldClearBindings ? operations.clearBindingsForLeases(targetId, leasesToClear) : false - return changed || bindingsChanged + // Why after the scrub: it is the scrub that makes the tombstones unreachable. + const tombstonesPruned = shouldClearBindings + ? pruneRetiredSshRemotePtyLeaseTombstones(operations, targetId) + : false + return changed || bindingsChanged || tombstonesPruned } export function markSshRemotePtyLeases( @@ -301,10 +220,11 @@ export function markSshRemotePtyLease( } const shouldClearBindings = leaseStateWithdrawsBinding(state) if (lease.state === state) { - if ( - (shouldClearBindings && operations.clearBindingsForLeases(targetId, [lease])) || - recycledChanged - ) { + const bindingsCleared = + shouldClearBindings && operations.clearBindingsForLeases(targetId, [lease]) + const tombstonesPruned = + shouldClearBindings && pruneRetiredSshRemotePtyLeaseTombstones(operations, targetId) + if (bindingsCleared || tombstonesPruned || recycledChanged) { operations.flush() } return @@ -319,6 +239,7 @@ export function markSshRemotePtyLease( } if (shouldClearBindings) { operations.clearBindingsForLeases(targetId, [lease]) + pruneRetiredSshRemotePtyLeaseTombstones(operations, targetId) } operations.flush() } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts new file mode 100644 index 00000000000..261162a3825 --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-tombstone-retention.ts @@ -0,0 +1,89 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' + +export type SshPtyLeaseTombstoneRetentionOperations = { + state: PersistedState + toComparablePtyId: (targetId: string, ptyId: string) => string +} + +/** A routing tombstone with nothing left to route: the operator closed this PTY and no stop is + * still owed for it. `expired` is deliberately not here — it says only that the CLIENT lost its + * route (docs/reference/ssh-execution-boundary.md), and `sweepOrphanedRelayPtys` reads those ids + * as its leave-alone list, so deleting one would authorize stopping a remote shell that + * supersession left running on purpose. */ +function isRetiredRoutingTombstone(lease: SshRemotePtyLease, targetId: string): boolean { + return ( + lease.targetId === targetId && lease.state === 'terminated' && lease.pendingKill === undefined + ) +} + +/** Every stored-form relay pty id some persisted pane binding still names for this target. + * + * Reads all partitions, not only the two `clearSshRemotePtyBindingsForLeases` scrubs: this answer + * authorizes a delete, so a partition left unscanned would be a binding whose tombstone we dropped. + */ +function boundRelayPtyIds( + operations: SshPtyLeaseTombstoneRetentionOperations, + targetId: string +): Set { + const bound = new Set() + const sessions = [ + operations.state.workspaceSession, + ...Object.values(operations.state.workspaceSessionsByHostId ?? {}) + ] + for (const session of sessions) { + if (!session) { + continue + } + for (const tabs of Object.values(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (tab.ptyId) { + bound.add(operations.toComparablePtyId(targetId, tab.ptyId)) + } + } + } + for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) { + for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) { + bound.add(operations.toComparablePtyId(targetId, ptyId)) + } + } + } + return bound +} + +/** + * Deletes the `terminated` rows nothing can reach, bounding an array that otherwise only grew. + * + * `terminated` is written with a binding scrub in the same call, so once no persisted binding names + * the id the row answers no question any reader asks. Reattach refuses it + * (`sshRemotePtyLeaseAllowsReattach`), pane recovery matches on `expired` only, the orphan sweep + * already classes it neither routed nor expired, and `ssh:reset` / `ssh:terminateSessions` skip it + * outright — every one of those behaves identically on an absent row. The one reader that can still + * observe it is `isRestorablePtyBinding`, and only through a binding whose pty id matches, which is + * exactly what the reachability test rules out. A `pendingKill` is an undelivered stop, so those + * rows stay until the replay retires them. + * + * The reachability test is not redundant with the scrub: a lease freezes its `tabId`, so a pane + * broken out into a new tab leaves a binding the scrub's tab-qualified match no longer reaches. + * + * Does not re-arm the local-worktree-metadata prune gate: a `terminated` lease no longer counts as + * a persisted workspace owner, so dropping one cannot make any metadata row more removable. + */ +export function pruneRetiredSshRemotePtyLeaseTombstones( + operations: SshPtyLeaseTombstoneRetentionOperations, + targetId: string +): boolean { + const leases = operations.state.sshRemotePtyLeases ?? [] + if (!leases.some((lease) => isRetiredRoutingTombstone(lease, targetId))) { + return false + } + const bound = boundRelayPtyIds(operations, targetId) + const retained = leases.filter( + (lease) => !isRetiredRoutingTombstone(lease, targetId) || bound.has(lease.ptyId) + ) + if (retained.length === leases.length) { + return false + } + operations.state.sshRemotePtyLeases = retained + return true +} diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-pane-supersession.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-pane-supersession.ts new file mode 100644 index 00000000000..61d6b934db5 --- /dev/null +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-pane-supersession.ts @@ -0,0 +1,201 @@ +import { toSshExecutionHostId } from '../../../shared/execution-host' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { SshPtyLeaseOperations } from './ssh-pty-lease-operations' + +/** + * Every PTY id any partition binds to this pane, most authoritative first. + * + * Keyed on the leaf alone — the only remint-stable half of a pane key, since + * `detachTerminalPaneToTab` moves a live pane and leaves its lease naming the tab it left. + * + * Returns a LIST, and reads the target's own partition first, because the two partitions disagree + * for the length of a reconnect and this resolved that disagreement backwards. Main writes an SSH + * pane's binding to `ssh:`, while a stale copy of the same leaf survives in `local`; + * consulting `local` first therefore named the PREDECESSOR as the pane's current PTY on every relay + * restart. Supersession then took that expired predecessor as its winner and returned without + * marking anything — the per-reconnect lease growth. Both partitions are still read, because a + * reader that consulted only one would see "unbound" for a live pane and expire its lease. + */ +function durablyBoundPtyIdsForPane( + operations: SshPtyLeaseOperations, + targetId: string, + leafId: string +): string[] { + const findLeafBindings = (session: WorkspaceSessionState | undefined): string[] => + Object.values(session?.terminalLayoutsByTabId ?? {}) + .map((layout) => layout?.ptyIdsByLeafId?.[leafId]) + .filter((ptyId): ptyId is string => Boolean(ptyId)) + const ordered = [ + ...findLeafBindings( + operations.state.workspaceSessionsByHostId?.[toSshExecutionHostId(targetId)] + ), + ...findLeafBindings(operations.state.workspaceSession) + ] + return [...new Set(ordered.map((ptyId) => operations.toComparablePtyId(targetId, ptyId)))] +} + +/** A lease this client still holds a route to, as opposed to one it has already lost. */ +function isLiveLeaseState(state: SshRemotePtyLease['state']): boolean { + return state === 'attached' || state === 'detached' +} + +/** + * One pane owns at most one live remote PTY. Lease identity is `(targetId, ptyId)` alone, so a + * pane re-leasing under a new relay id leaves its predecessor live with nothing to retire it and + * the next reattach fans out over both — the reported 2 -> 19 -> 20 across three reconnects. + * + * Superseded leases are marked `expired`, never `terminated`: losing a lease is not evidence the + * shell died, so the remote process is deliberately left running. They also carry `supersededBy`, + * which is what keeps them out of the bulk reattach set now that plain `expired` no longer does — + * the winner's ptyId is already in hand here, so recording it needs no relay-start identity. + */ +export function supersedeSiblingLeasesForPane( + operations: SshPtyLeaseOperations, + winner: SshRemotePtyLease, + now: number +): boolean { + if (!winner.worktreeId || !winner.leafId) { + return false + } + if (winner.state === 'terminated' || winner.state === 'expired') { + return false + } + // At upsert time the arriving lease may not be the one the pane is bound to yet. Expiring the + // bound predecessor would detach a live pane, so leave both live and let reattach arbitrate + // with the binding in hand. `supersedeSshRemotePtyLeasesForBoundPane` re-runs this once the + // binding write lands, so a caller that upserts before it binds is not left bailed forever. + // Membership rather than equality: during a reconnect the two partitions name different PTYs for + // the same leaf, and requiring the winner to match the FIRST one read is what made this bail. + const boundPtyIds = durablyBoundPtyIdsForPane(operations, winner.targetId, winner.leafId) + if (boundPtyIds.length > 0 && !boundPtyIds.includes(winner.ptyId)) { + return false + } + let marked = false + const superseded: SshRemotePtyLease[] = [] + for (const lease of operations.state.sshRemotePtyLeases ?? []) { + if ( + lease.ptyId === winner.ptyId || + lease.targetId !== winner.targetId || + lease.worktreeId !== winner.worktreeId || + // Leaf only: a lease freezes its tabId, so a pane broken out into a new tab would otherwise + // never compete with its own predecessor — which is the reported cardinality growth. + lease.leafId !== winner.leafId || + lease.state === 'terminated' || + // Never retire a shell the pane is BOTH still bound to and still routable to. The stale + // partition can name a predecessor, and retiring that is the point; retiring a live one + // would strand a running remote process behind a pane that can no longer reach it. + (boundPtyIds.includes(lease.ptyId) && isLiveLeaseState(lease.state)) + ) { + continue + } + if (lease.state === 'expired') { + // An already-expired predecessor is superseded by the same evidence, and marking it is what + // bounds the reattach set: without this, every past orphan for this pane stays reattachable + // forever. `updatedAt` stays put — bumping it would make a stale lease look recent to + // `getRecentExpiredSshLease`. + marked ||= lease.supersededBy !== winner.ptyId + lease.supersededBy = winner.ptyId + continue + } + lease.state = 'expired' + lease.supersededBy = winner.ptyId + lease.updatedAt = now + marked = true + superseded.push(lease) + } + if (superseded.length > 0) { + // Why: matching on lease ptyId first means this scrubs only the predecessor's stale binding — + // the winner's own binding cannot match and is left intact. + operations.clearBindingsForLeases(winner.targetId, superseded) + } + return marked +} + +/** + * Supersede from the lease the pane's binding names — preferring a LIVE one when the partitions + * disagree, since a reconnect leaves the stale partition naming an already-expired predecessor and + * an expired winner supersedes nothing. + */ +function supersedeFromBoundPane( + operations: SshPtyLeaseOperations, + targetId: string, + leafId: string, + now: number +): boolean { + if (!isTerminalLeafId(leafId)) { + return false + } + const boundPtyIds = durablyBoundPtyIdsForPane(operations, targetId, leafId) + if (boundPtyIds.length === 0) { + // No binding names this pane, so nothing here is evidence about which shell owns it. Leaving + // every lease reattachable is the deliberate direction: an orphan must stay askable. + return false + } + const candidates = (operations.state.sshRemotePtyLeases ?? []).filter( + (lease) => + lease.targetId === targetId && lease.leafId === leafId && boundPtyIds.includes(lease.ptyId) + ) + const winner = candidates.find((lease) => isLiveLeaseState(lease.state)) + const marked = winner ? supersedeSiblingLeasesForPane(operations, winner, now) : false + return marked +} + +/** + * The binding-side trigger for supersession, and the reason the two writes that together claim a + * pane are commutative. + * + * `upsertSshRemotePtyLease` is the only other trigger, and it bails whenever the pane's durable + * binding still names the predecessor. A spawn path that upserts its lease BEFORE it writes the + * binding therefore bails and never re-runs on its own. Re-resolving the winner from the binding + * is safe in the other direction too: it supersedes only from the lease the pane is actually bound + * to, so it can never strand a live orphan. + */ +export function supersedeSshRemotePtyLeasesForBoundPane( + operations: SshPtyLeaseOperations, + targetId: string, + leafId: string +): void { + if (supersedeFromBoundPane(operations, targetId, leafId, Date.now())) { + operations.flush() + } +} + +/** + * Bound the reattach set to one lease per pane, re-derived from each pane's CURRENT binding. + * + * The spawn-side trigger cannot be sufficient alone, and measuring the shipped path is what showed + * it: a pane's binding has several writers — the spawn commit, the relay's reattach bind, and the + * renderer's debounced layout publish — and the last of those lands well after the spawn commit + * that leased the pty. A predecessor that was still bound when its successor was claimed therefore + * keeps its reattachability forever, because nothing revisits it once the binding catches up. The + * observed rows agreed on target, worktree, tab and leaf and still carried no mark. + * + * Running this immediately before the reattach set is read makes the answer independent of which + * writer bound the pane and when. It also repairs stores written by earlier builds, where these + * rows have already accumulated and no spawn-time trigger would ever revisit them. + * + * Panes with no binding are skipped rather than pruned: absence of a binding is not evidence about + * which shell owns the pane, and a genuine orphan has to stay askable + * (docs/reference/ssh-execution-boundary.md). + */ +export function reconcileSshRemotePtyLeasesForTarget( + operations: SshPtyLeaseOperations, + targetId: string +): void { + const leafIds = new Set() + for (const lease of operations.state.sshRemotePtyLeases ?? []) { + if (lease.targetId === targetId && lease.leafId) { + leafIds.add(lease.leafId) + } + } + const now = Date.now() + let changed = false + for (const leafId of leafIds) { + changed = supersedeFromBoundPane(operations, targetId, leafId, now) || changed + } + if (changed) { + operations.flush() + } +} diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index 5bd6e572ab1..e4034fa2b17 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -51,8 +51,10 @@ function logPersistenceStartupMilestone( if (!isStartupDiagnosticsEnabled()) { return } + // Why: snapshot `t` before resolving lazy details — otherwise an expensive details closure is billed to the milestone it measures. + const t = Math.round(performance.now()) const resolvedDetails = typeof details === 'function' ? details() : details - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...resolvedDetails }) + logStartupDiagnostic(event, { t, ...resolvedDetails }) } import type { StoreRuntimeState } from './store-runtime-state' diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 39d625c525a..987a6e81a17 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -25,6 +25,7 @@ import { normalizeLoadedProjectCatalog } from './normalize-loaded-state-collections' import { normalizeRetiredNameRegistryMap } from './retired-name-registry-normalization' +import { hydrateWorktreeMetaAliasProjection } from './worktree-meta-alias-projection' export function normalizeLoadedProfileState( parsed: PersistedState, @@ -53,6 +54,13 @@ export function normalizeLoadedProfileState( folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( parsed.folderWorkspaceDiffComments ), + // Rebuilds the identity rows the serializer left to the locator map, and restores the shared + // object reference JSON.parse splits. Not `markNeedsSave`: this IS the canonical on-disk shape. + // Conditional so a file with no identity map keeps none, rather than gaining an own key whose + // value is `undefined`. + ...(parsed.worktreeMetaByIdentity === undefined + ? {} + : { worktreeMetaByIdentity: hydrateWorktreeMetaAliasProjection(parsed) }), worktreeLineageById: parsed.worktreeLineageById ?? {}, mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( parsed.mobileClientTabSelectionsByDeviceId diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 8768a47e50a..75aa19ad24b 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -23,6 +23,10 @@ import { type SshPtyLeaseOperations, upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation } from '../leasing-ssh-ptys/ssh-pty-lease-operations' +import { + reconcileSshRemotePtyLeasesForTarget as reconcileSshRemotePtyLeasesForTargetOperation, + supersedeSshRemotePtyLeasesForBoundPane as supersedeSshRemotePtyLeasesForBoundPaneOperation +} from '../leasing-ssh-ptys/ssh-pty-pane-supersession' import { getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, @@ -95,6 +99,28 @@ export class SshLeaseRecoveryOperations { upsertSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), lease) } + /** + * Re-run pane supersession from the binding rather than from an arriving lease. Spawn commits + * call this after their binding write so it does not matter whether the lease or the binding + * landed first; see `supersedeSshRemotePtyLeasesForBoundPane`. + */ + supersedeSshRemotePtyLeasesForBoundPane(targetId: string, leafId: string): void { + supersedeSshRemotePtyLeasesForBoundPaneOperation( + getSshPtyLeaseOperations(this), + targetId, + leafId + ) + } + + /** + * Re-derive one reattachable lease per pane from each pane's current binding. Called on the + * connect path immediately before the reattach set is read; see + * `reconcileSshRemotePtyLeasesForTarget`. + */ + reconcileSshRemotePtyLeasesForTarget(targetId: string): void { + reconcileSshRemotePtyLeasesForTargetOperation(getSshPtyLeaseOperations(this), targetId) + } + markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { markSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId, state) } diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index 844a0381fce..c8557f846af 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -8,6 +8,7 @@ import { } from '../../protected-secret-persistence' import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' import { omitDefaultWorktreeMetaFieldsInMap } from '../../../shared/worktree/meta-persisted-defaults' +import { projectWorktreeMetaByIdentityOntoLocators } from './worktree-meta-alias-projection' import { withoutRedundantPartitionGlobals } from '../../../shared/workspace-session-host-field-ownership' import { @@ -68,16 +69,28 @@ export class StateSerializationSecretHandlingOperations { const encrypted = encryptToSentinel(slot, plaintext ?? '') return encrypted || null } + // Ordered before the default omission on purpose: the two maps hold the SAME row object, so + // the projection settles almost every row on a reference check. Omitting first rebuilds each + // row twice into two distinct objects and forces a deep compare per row instead. Omission is + // a pure function of the value, so a pair equal here is equal after it too -- and it never + // touches `hostId`/`instanceId`, which is what the reader re-derives the omitted key from. + const projectedWorktreeMetaByIdentity = + this.runtime.state.worktreeMetaByIdentity === undefined + ? undefined + : projectWorktreeMetaByIdentityOntoLocators( + this.runtime.state.worktreeMetaByIdentity, + this.runtime.state + ) // Why: clone before encrypting secrets so in-memory this.state stays plaintext. const stateToSave = { ...this.getDurableState(), // Default-valued metadata slots are re-filled at load (normalizeWorktreeLinkedItemMetadata), // so omitting them here is lossless and drops ~12% of the file on a heavy install. worktreeMeta: omitDefaultWorktreeMetaFieldsInMap(this.runtime.state.worktreeMeta), - ...(this.runtime.state.worktreeMetaByIdentity !== undefined + ...(projectedWorktreeMetaByIdentity !== undefined ? { worktreeMetaByIdentity: omitDefaultWorktreeMetaFieldsInMap( - this.runtime.state.worktreeMetaByIdentity + projectedWorktreeMetaByIdentity ) } : {}), diff --git a/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts b/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts new file mode 100644 index 00000000000..f324787cdf3 --- /dev/null +++ b/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts @@ -0,0 +1,421 @@ +/** + * `setWorktreeMetaForHost` puts one object in both `worktreeMeta` and `worktreeMetaByIdentity`, so + * a heavy profile serializes every metadata row twice. On a measured 3.64 MB install 1,347 of + * 1,349 locator rows were byte-identical to their identity twin and cost 540 KB per save. + * + * These tests drive the real Store over a seeded corpus that contains every shape the projection + * has to get right -- identical twins, divergent twins, rows with no identity at all, one locator + * claimed by two hosts, an alias whose locator row was pruned away, a dangling identity key, and + * an ambiguous alias with two instances behind one locator -- and pin the properties that make the + * omission safe: load(save(x)) deep-equals x, an old-serializer file and a new-serializer file load + * to the same state, the locator map is never reduced (which is what makes a downgrade lossless), + * and a build with no rebuild at all recovers every row from the file the new build wrote. + */ +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity' +import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { normalizeWorktreeLinkedItemMetadata } from '../tracking-repos/worktree-metadata-normalization' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const { Store } = await import('./store') + +const REPO_ID = 'repo-1' +const LOCAL = 'local' +const REMOTE = 'ssh:user@host' +const TWIN_ROWS = 400 +/** Recent enough that the 30-day stale-metadata GC leaves the fixture alone. */ +const RECENTLY = Date.now() + +/** Seeded so the corpus is the same on every run and a failure is reproducible. */ +function seededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state * 1_664_525 + 1_013_904_223) >>> 0 + return state / 0x1_0000_0000 + } +} + +const stores: InstanceType[] = [] +afterEach(() => { + for (const store of stores.splice(0)) { + store.freezeWrites() + } + vi.restoreAllMocks() +}) + +function openStore(dataFile: string): InstanceType { + const store = new Store({ dataFile }) + stores.push(store) + return store +} + +function tempDataFile(): string { + return join(realpathSync(mkdtempSync(join(tmpdir(), 'orca-alias-projection-'))), 'orca-data.json') +} + +function worktreeId(index: number): string { + return `${REPO_ID}::/tmp/wt-${index}` +} + +/** Every optional slot exercised on a fraction of rows, so a row that must stay written does. */ +function meta(index: number, random: () => number, overrides: Partial = {}) { + const rich = random() < 0.25 + return { + instanceId: `instance-${index}`, + hostId: LOCAL, + displayName: `workspace-${index}`, + comment: rich ? `note ${index}` : '', + linkedIssue: null, + linkedPR: rich ? index : null, + linkedLinearIssue: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: random() < 0.3, + isPinned: rich, + sortOrder: RECENTLY + index, + manualOrder: rich ? index : undefined, + lastActivityAt: RECENTLY + index, + createdAt: RECENTLY, + baseRef: rich ? 'main' : undefined, + workspaceStatus: 'none', + ...overrides + } as WorktreeMeta +} + +type Fixture = { + state: PersistedState + /** Identity keys the locator row regenerates on its own, so they must leave the file. */ + omittable: string[] + /** Identity keys no locator row regenerates, so they must stay on disk. */ + irreducible: string[] +} + +/** + * A file in the pre-change shape: every alias' identity row duplicated into `worktreeMeta`, which + * is exactly what the old serializer wrote. + */ +function buildFixture(): Fixture { + const random = seededRandom(20_260_903) + const worktreeMeta: Record = {} + const worktreeMetaByIdentity: Record = {} + const worktreeIdentityAliases: Record = {} + const omittable: string[] = [] + const irreducible: string[] = [] + + const link = (id: string, host: string, row: WorktreeMeta): string => { + const identityKey = canonicalWorktreeIdentity({ + worktreeId: id, + executionHostId: host as never, + instanceId: row.instanceId as string + }) + worktreeMetaByIdentity[identityKey] = row + worktreeIdentityAliases[composeWorktreeHostIdentity(host as never, id)] = [identityKey] + return identityKey + } + + // 1. The common case: the identity row and the locator row are the same value. + for (let index = 0; index < TWIN_ROWS; index++) { + const row = meta(index, random) + worktreeMeta[worktreeId(index)] = { ...row } + omittable.push(link(worktreeId(index), LOCAL, row)) + } + // 2. Divergent twin: the locator row carries a value the identity row does not. + const divergent = worktreeId(TWIN_ROWS) + const divergentRow = meta(TWIN_ROWS, random) + worktreeMeta[divergent] = { ...divergentRow, displayName: 'locator-only-name' } + irreducible.push(link(divergent, LOCAL, divergentRow)) + // 3. No identity twin at all, and no hostId — the shape of Orca's synthetic pseudo-worktrees. + for (const pseudo of ['global-floating-terminal', 'onboarding-setup-terminal']) { + worktreeMeta[pseudo] = meta(0, random, { hostId: undefined, displayName: pseudo }) + } + // 4. One locator claimed by two hosts: nothing on disk records which one owns the projection. + const contested = worktreeId(TWIN_ROWS + 1) + const localClaim = meta(TWIN_ROWS + 1, random) + const remoteClaim = meta(TWIN_ROWS + 1, random, { + hostId: REMOTE as never, + instanceId: `instance-${TWIN_ROWS + 1}-remote`, + lastActivityAt: RECENTLY + 99_999 + }) + worktreeMeta[contested] = { ...localClaim } + // Only the host the locator row names can regenerate a key from it, so the other host's row stays. + omittable.push(link(contested, LOCAL, localClaim)) + irreducible.push(link(contested, REMOTE, remoteClaim)) + // 5. An alias whose locator row a host-scoped prune already removed: rebuilding it would + // resurrect a workspace the user deleted. + const voided = worktreeId(TWIN_ROWS + 2) + irreducible.push(link(voided, REMOTE, meta(TWIN_ROWS + 2, random, { hostId: REMOTE as never }))) + // 6. A dangling identity key: the alias points at a row that is not there. + const dangling = worktreeId(TWIN_ROWS + 3) + worktreeMeta[dangling] = meta(TWIN_ROWS + 3, random) + worktreeIdentityAliases[composeWorktreeHostIdentity(LOCAL, dangling)] = ['wt2:local:missing'] + // 7. An ambiguous alias — two instances behind one locator. `setWorktreeMetaForHost` refuses to + // write one, so it is a repair state and its locator row must stay written in full. + const ambiguous = worktreeId(TWIN_ROWS + 4) + const claimA = meta(TWIN_ROWS + 4, random) + const claimB = meta(TWIN_ROWS + 4, random, { + instanceId: `instance-${TWIN_ROWS + 4}-b`, + displayName: 'second-instance', + lastActivityAt: RECENTLY + 99_999 + }) + worktreeMeta[ambiguous] = { ...claimA } + const ambiguousKey = link(ambiguous, LOCAL, claimA) + irreducible.push(ambiguousKey) + const secondKey = canonicalWorktreeIdentity({ + worktreeId: ambiguous, + executionHostId: LOCAL as never, + instanceId: claimB.instanceId as string + }) + worktreeMetaByIdentity[secondKey] = claimB + worktreeIdentityAliases[composeWorktreeHostIdentity(LOCAL, ambiguous)] = [ambiguousKey, secondKey] + irreducible.push(secondKey) + + return { + state: { + // Registered: the load-time deregistered-repo sweep drops residue rows for unknown repos. + repos: [{ id: REPO_ID, name: REPO_ID, path: '/tmp/repo-1', worktreesPath: '/tmp' }], + projects: [], + worktreeMeta, + worktreeMetaByIdentity, + worktreeIdentityAliases, + worktreeLineageById: {}, + workspaceLineageByChildKey: {} + } as unknown as PersistedState, + omittable, + irreducible + } +} + +function writeFixture(dataFile: string, state: PersistedState): void { + writeFileSync(dataFile, JSON.stringify(state), 'utf-8') +} + +function snapshot(store: InstanceType) { + return { + meta: structuredClone(store.getAllWorktreeMeta()), + local: structuredClone(store.getAllWorktreeMetaForHost(LOCAL)), + remote: structuredClone(store.getAllWorktreeMetaForHost(REMOTE as never)) + } +} + +describe('worktree meta alias projection', () => { + it('round-trips every corpus shape and writes only the identity rows no locator regenerates', () => { + const fixture = buildFixture() + const dataFile = tempDataFile() + writeFixture(dataFile, fixture.state) + + // One load+flush first, so the baseline is not comparing against the one-time settings + // migrations a synthetic fixture triggers (same reason as state-write-round-trip.test.ts). + openStore(dataFile).flush() + + const loaded = openStore(dataFile) + const before = snapshot(loaded) + loaded.flush() + const rewritten = readFileSync(dataFile, 'utf-8') + const onDisk = JSON.parse(rewritten) as PersistedState + + // The counter this change exists for: 401 regenerable identity rows leave the file. + expect(Object.keys(onDisk.worktreeMetaByIdentity ?? {}).sort()).toEqual( + [...fixture.irreducible].sort() + ) + expect(fixture.omittable).toHaveLength(TWIN_ROWS + 1) + // ...and the locator map, which is what regenerates them, is written in full. This is the + // property the downgrade story rests on, so it is asserted as a set, not a count. + expect(Object.keys(onDisk.worktreeMeta).sort()).toEqual(Object.keys(before.meta).sort()) + + // load(save(x)) deep-equals x, for every reader of the metadata maps. + const reloaded = openStore(dataFile) + expect(reloaded.getAllWorktreeMeta()).toEqual(before.meta) + expect(reloaded.getAllWorktreeMetaForHost(LOCAL)).toEqual(before.local) + expect(reloaded.getAllWorktreeMetaForHost(REMOTE as never)).toEqual(before.remote) + // The locator row a host-scoped prune already removed stays removed. + expect(reloaded.getAllWorktreeMeta()).not.toHaveProperty(worktreeId(TWIN_ROWS + 2)) + // The contested locator keeps the host that owned the projection, not the newer claim. + expect(reloaded.getAllWorktreeMeta()[worktreeId(TWIN_ROWS + 1)]?.hostId).toBe(LOCAL) + // The ambiguous locator keeps its own row, not the newer instance behind the same alias. + expect(reloaded.getAllWorktreeMeta()[worktreeId(TWIN_ROWS + 4)]?.displayName).toBe( + `workspace-${TWIN_ROWS + 4}` + ) + + // A quiet app does not rewrite the file with new content on the next flush. + reloaded.flush() + expect(readFileSync(dataFile, 'utf-8')).toBe(rewritten) + }) + + /** + * The risk this projection direction exists to remove. A build without the rebuild -- an older + * one, or any raw reader of the file -- gets a complete `worktreeMeta`; its normalizer drops the + * now-dangling aliases and `migrateLegacyWorktreeMetadata` re-mints the identical identity key + * from the `instanceId` the locator row still carries. Nothing is lost at any step. + */ + it('loses no row on a build that has no rebuild at all', () => { + const fixture = buildFixture() + const dataFile = tempDataFile() + writeFixture(dataFile, fixture.state) + openStore(dataFile).flush() + const upgraded = openStore(dataFile) + const before = snapshot(upgraded) + upgraded.flush() + + // What a build without this change does with that file: parse it, run the metadata normalizer + // it already ships (untouched here), write the result back. + const downgraded = JSON.parse(readFileSync(dataFile, 'utf-8')) as PersistedState + normalizeWorktreeLinkedItemMetadata(downgraded) + expect(Object.keys(downgraded.worktreeMeta).sort()).toEqual(Object.keys(before.meta).sort()) + // It drops the aliases whose identity row is not there; it never touches a locator row. + expect(downgraded.worktreeIdentityAliases).not.toHaveProperty( + composeWorktreeHostIdentity(LOCAL, worktreeId(0)) + ) + writeFileSync(dataFile, JSON.stringify(downgraded), 'utf-8') + + // Every reader is where it started, with no rebuild and without touching a row first. + const rolledBack = openStore(dataFile) + expect(rolledBack.getAllWorktreeMeta()).toEqual(before.meta) + expect(rolledBack.getAllWorktreeMetaForHost(LOCAL)).toEqual(before.local) + expect(rolledBack.getAllWorktreeMetaForHost(REMOTE as never)).toEqual(before.remote) + + // ...and the first touch re-mints the SAME identity key the save omitted, so re-upgrading + // compacts the same row again rather than stranding a second lineage for it. + expect(rolledBack.getWorktreeMetaForHost(worktreeId(0), LOCAL)).toEqual( + before.meta[worktreeId(0)] + ) + rolledBack.flush() + const reminted = JSON.parse(readFileSync(dataFile, 'utf-8')) as PersistedState + expect( + reminted.worktreeIdentityAliases?.[composeWorktreeHostIdentity(LOCAL, worktreeId(0))] + ).toEqual([ + canonicalWorktreeIdentity({ + worktreeId: worktreeId(0), + executionHostId: LOCAL as never, + instanceId: 'instance-0' + }) + ]) + }) + + it('rebuilds the identity rows as the same objects the locator map holds', () => { + const fixture = buildFixture() + const dataFile = tempDataFile() + writeFixture(dataFile, fixture.state) + openStore(dataFile).flush() + + const store = openStore(dataFile) + const rebuilt = store.getAllWorktreeMeta() + // JSON.parse splits the one object the write path shared into two; the rebuild puts it back, + // worth ~0.46 MB of heap on the measured 3.64 MB profile. + let shared = 0 + for (let index = 0; index < TWIN_ROWS; index++) { + if (store.getWorktreeMetaForHost(worktreeId(index), LOCAL) === rebuilt[worktreeId(index)]) { + shared++ + } + } + expect(shared).toBe(TWIN_ROWS) + }) + + it('loads an old-serializer file and a new-serializer file to the same state', () => { + const fixture = buildFixture() + const legacyFile = tempDataFile() + writeFixture(legacyFile, fixture.state) + const fromLegacy = openStore(legacyFile) + // Writing it back produces the new, projected shape in place. + fromLegacy.flush() + + const compactFile = tempDataFile() + writeFileSync(compactFile, readFileSync(legacyFile)) + const fromCompact = openStore(compactFile) + + expect(fromCompact.getAllWorktreeMeta()).toEqual(fromLegacy.getAllWorktreeMeta()) + expect(fromCompact.getAllWorktreeMetaForHost(LOCAL)).toEqual( + fromLegacy.getAllWorktreeMetaForHost(LOCAL) + ) + expect(fromCompact.getAllWorktreeMetaForHost(REMOTE as never)).toEqual( + fromLegacy.getAllWorktreeMetaForHost(REMOTE as never) + ) + }) + + it('keeps every locator row when the alias map is missing or unreadable', () => { + for (const aliases of [undefined, null, [], { 'local|x': 'not-an-array' }]) { + const fixture = buildFixture() + const dataFile = tempDataFile() + writeFixture(dataFile, { + ...fixture.state, + worktreeIdentityAliases: aliases as never + }) + const store = openStore(dataFile) + // Nothing resolvable, so nothing is omitted -- and a garbled alias map costs exactly what it + // costs today, because every row's name/pin/links is still in the locator map. + expect(Object.keys(store.getAllWorktreeMeta()).length).toBe( + Object.keys(fixture.state.worktreeMeta).length + ) + expect(store.getAllWorktreeMeta()[worktreeId(0)]?.displayName).toBe('workspace-0') + store.flush() + const onDisk = JSON.parse(readFileSync(dataFile, 'utf-8')) as PersistedState + expect(Object.keys(onDisk.worktreeMeta).length).toBe( + Object.keys(fixture.state.worktreeMeta).length + ) + // The identity rows a garbled alias map strands are pruned exactly as they are today; the + // projection never adds to that, because it only omits a row an alias can rebuild. + expect(openStore(dataFile).getAllWorktreeMeta()).toEqual(store.getAllWorktreeMeta()) + } + }) + + /** + * A file that never had an identity map must not gain one: the rebuild returns the parsed value + * unchanged for a non-record, so an unconditional spread would give the loaded state an own + * `worktreeMetaByIdentity: undefined` -- a key that outranks the defaults spread and reaches + * every `Object.hasOwn`/`in` reader as present-but-empty. + */ + it('never materializes an identity map a file did not have', () => { + const dataFile = tempDataFile() + writeFileSync( + dataFile, + JSON.stringify({ + repos: [{ id: REPO_ID, name: REPO_ID, path: '/tmp/repo-1', worktreesPath: '/tmp' }], + worktreeMeta: { [worktreeId(0)]: meta(0, seededRandom(1)) }, + worktreeLineageById: { + [`${REPO_ID}::/tmp/child`]: { + parentWorktreeId: `${REPO_ID}::/tmp/parent`, + createdAt: RECENTLY + } + }, + workspaceLineageByChildKey: { + [`worktree:${REPO_ID}::/tmp/child`]: { + parentWorkspaceKey: `worktree:${REPO_ID}::/tmp/parent`, + createdAt: RECENTLY + } + } + }), + 'utf-8' + ) + + const store = openStore(dataFile) + expect(Object.keys(store.getAllWorktreeMeta())).toEqual([worktreeId(0)]) + store.flush() + + const onDisk = JSON.parse(readFileSync(dataFile, 'utf-8')) as PersistedState + expect(Object.hasOwn(onDisk, 'worktreeMetaByIdentity')).toBe(false) + // The locator map and its lineage companions are all still there, untouched by the projection. + expect(Object.keys(onDisk.worktreeMeta)).toEqual([worktreeId(0)]) + expect(Object.keys(onDisk.worktreeLineageById)).toEqual([`${REPO_ID}::/tmp/child`]) + expect(Object.keys(onDisk.workspaceLineageByChildKey)).toEqual([ + `worktree:${REPO_ID}::/tmp/child` + ]) + }) +}) diff --git a/src/main/persistence/loading-store/worktree-meta-alias-projection.ts b/src/main/persistence/loading-store/worktree-meta-alias-projection.ts new file mode 100644 index 00000000000..4789f38d445 --- /dev/null +++ b/src/main/persistence/loading-store/worktree-meta-alias-projection.ts @@ -0,0 +1,149 @@ +/** + * `setWorktreeMetaForHost` stores one object in both `worktreeMeta` and `worktreeMetaByIdentity`, + * so the profile serializes every metadata row twice. On a measured 3.64 MB install, 1,347 of + * 1,349 locator rows were byte-identical to their identity twin: 540 KB of identity rows + * re-serialized on every debounced save and re-parsed on every launch. + * + * The identity row is the copy that is dropped, never the locator row, and only when the locator + * row *regenerates its own key*: `wt2::` is a pure function of two fields the + * locator row still carries. That direction is what makes the change free of a format marker. + * "Alias present, identity row absent, locator row derives the key" is not a state any build ever + * writes deliberately -- `pruneUnreferencedWorktreeIdentityMeta` only drops rows whose alias is + * already gone, and `normalizeWorktreeLinkedItemMetadata` only drops aliases whose row is already + * gone -- and it is a state every build since #16691 already heals, to exactly the row this + * rebuild produces, via `migrateLegacyWorktreeMetadata`. So a downgrade is lossless by + * construction: the old build sees a complete `worktreeMeta`, drops the dangling aliases, and + * re-mints the identical identity key from the row's preserved `instanceId` on first read. + * + * The rebuild also reinstates the shared object reference that `JSON.parse` splits in two. + */ +import { isDeepStrictEqual } from 'node:util' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { canonicalWorktreeIdentity } from '../../../shared/worktree/identity' +import { + getExecutionHostIdFromWorktreeHostIdentity, + getWorktreeIdFromHostIdentity +} from '../../../shared/worktree/host-qualified-identity' + +/** Every slice of a parsed profile file the projection reads; `PersistedState` satisfies it. */ +export type WorktreeMetaAliasProjectionSource = Pick< + PersistedState, + 'worktreeMeta' | 'worktreeIdentityAliases' +> + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * The identity key an alias' own locator row regenerates, or undefined when it does not. + * + * The single definition of the omission rule: writer and reader both go through it, so they cannot + * disagree about which key is derivable. `hostId` and `instanceId` are not in + * `WORKTREE_META_PERSISTED_DEFAULTS`, so the answer is the same before and after default omission. + */ +function identityKeyDerivedFromLocatorRow(alias: string, locatorRow: unknown): string | undefined { + if (!isPlainRecord(locatorRow)) { + return undefined + } + const { hostId, instanceId } = locatorRow as WorktreeMeta + if (typeof hostId !== 'string' || !hostId || typeof instanceId !== 'string' || !instanceId) { + return undefined + } + // The alias must name the same host, or the key the reader derives is not the key it replaces. + if (getExecutionHostIdFromWorktreeHostIdentity(alias) !== hostId) { + return undefined + } + return canonicalWorktreeIdentity({ + worktreeId: getWorktreeIdFromHostIdentity(alias), + executionHostId: hostId, + instanceId + }) +} + +/** + * Identity key -> the locator row that regenerates it, for every alias that does so unambiguously. + * + * A key two aliases both derive is left out entirely: which locator row would rebuild it would + * then depend on object key order, which is not a durable contract across a JSON round trip. An + * alias carrying more than one key is left out too -- `setWorktreeMetaForHost` refuses to write + * one, so it is a repair state, and only one of its rows could ever be derivable anyway. + */ +function derivableIdentityRows( + state: WorktreeMetaAliasProjectionSource +): Map { + const derivable = new Map() + const aliases = state.worktreeIdentityAliases + const worktreeMeta = state.worktreeMeta as unknown + if (!isPlainRecord(aliases) || !isPlainRecord(worktreeMeta)) { + return derivable + } + const contested = new Set() + for (const [alias, identityKeys] of Object.entries(aliases)) { + if (!Array.isArray(identityKeys) || identityKeys.length !== 1) { + continue + } + const locatorRow = worktreeMeta[getWorktreeIdFromHostIdentity(alias)] + const derivedKey = identityKeyDerivedFromLocatorRow(alias, locatorRow) + if (derivedKey === undefined || derivedKey !== identityKeys[0]) { + continue + } + if (derivable.has(derivedKey)) { + contested.add(derivedKey) + continue + } + derivable.set(derivedKey, locatorRow as WorktreeMeta) + } + for (const key of contested) { + derivable.delete(key) + } + return derivable +} + +/** + * Serialize side, on the raw in-memory maps: an untouched row is the same object in both, so the + * common case settles on a reference check and never a deep compare. + */ +export function projectWorktreeMetaByIdentityOntoLocators( + worktreeMetaByIdentity: Record, + state: WorktreeMetaAliasProjectionSource +): Record { + let projected: Record | undefined + for (const [identityKey, locatorRow] of derivableIdentityRows(state)) { + const identityRow = worktreeMetaByIdentity[identityKey] + if (!isPlainRecord(identityRow)) { + continue + } + if (identityRow !== locatorRow && !isDeepStrictEqual(identityRow, locatorRow)) { + continue + } + projected ??= { ...worktreeMetaByIdentity } + delete projected[identityKey] + } + return projected ?? worktreeMetaByIdentity +} + +/** + * Load side, in place. Runs before the metadata normalizers, because + * `normalizeWorktreeLinkedItemMetadata` drops an alias whose identity row is not there yet. + * + * Only ever adds a key the locator row already fully describes, so an untouched legacy file is a + * no-op on it (nothing is missing) and a garbled one loses no more than it does today. + */ +export function hydrateWorktreeMetaAliasProjection( + parsed: WorktreeMetaAliasProjectionSource & Pick +): Record | undefined { + const worktreeMetaByIdentity = parsed.worktreeMetaByIdentity + if (!isPlainRecord(worktreeMetaByIdentity)) { + return worktreeMetaByIdentity + } + for (const [identityKey, locatorRow] of derivableIdentityRows(parsed)) { + if (Object.hasOwn(worktreeMetaByIdentity, identityKey)) { + continue + } + // Same reference in both maps, as every in-session write leaves it. + worktreeMetaByIdentity[identityKey] = locatorRow + } + return worktreeMetaByIdentity +} diff --git a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts index cdad14034e2..0588a2f6dae 100644 --- a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts +++ b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts @@ -287,6 +287,64 @@ describe('pruneSessionlessMissingLocalWorktreeMetadataForRepo', () => { } }) + // A route-retired lease is a tombstone, not a claim: counting one pinned its worktree's metadata + // row for good, so the prune could never make progress on it (#17775). + it('does not let route-retired SSH leases pin a metadata row', () => { + const state = makeState() + const liveIds = Array.from({ length: 3 }, (_, i) => `${REPO_ID}::/workspace/live-${i}`) + const terminatedIds = Array.from({ length: 5 }, (_, i) => `${REPO_ID}::/workspace/closed-${i}`) + const supersededIds = Array.from({ length: 4 }, (_, i) => `${REPO_ID}::/workspace/lost-${i}`) + const recycledIds = [`${REPO_ID}::/workspace/recycled`] + const allIds = [...liveIds, ...terminatedIds, ...supersededIds, ...recycledIds] + for (const worktreeId of allIds) { + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + } + const lease = (worktreeId: string, index: number, extra: object) => ({ + targetId: 'builder', + ptyId: `pty-${index}`, + worktreeId, + createdAt: 1, + updatedAt: 1, + ...extra + }) + state.sshRemotePtyLeases = [ + ...liveIds.map((id, i) => lease(id, i, { state: 'detached' })), + ...terminatedIds.map((id, i) => lease(id, 100 + i, { state: 'terminated' })), + ...supersededIds.map((id, i) => + lease(id, 200 + i, { state: 'expired', supersededBy: 'pty-9' }) + ), + ...recycledIds.map((id, i) => lease(id, 300 + i, { state: 'expired', relayIdRecycled: true })) + ] as never + + const scan = capture(state) + + expect(pruneCaptured(state, scan, allIds).sort()).toEqual( + [...terminatedIds, ...supersededIds, ...recycledIds].sort() + ) + expect(Object.keys(state.worktreeMeta).sort()).toEqual([...liveIds].sort()) + }) + + // A plain `expired` lease says only that the CLIENT lost its route, so its pane is still + // recoverable and its metadata row is still owned (docs/reference/ssh-execution-boundary.md). + it('keeps a metadata row pinned by an unmarked expired lease', () => { + const state = makeState() + const worktreeId = `${REPO_ID}::/workspace/orphaned` + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + const scan = capture(state) + state.sshRemotePtyLeases = [ + { + targetId: 'builder', + ptyId: 'pty', + worktreeId, + state: 'expired', + createdAt: 1, + updatedAt: 1 + } + ] + + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([]) + }) + it('preserves canonically equivalent session and top-level owners', () => { const candidateId = `${REPO_ID}::/workspace/Café`.normalize('NFC') const ownerId = candidateId.normalize('NFD') diff --git a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts index 0e03a560a17..db24c25bcf4 100644 --- a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts +++ b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts @@ -2,6 +2,7 @@ import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { PersistedState } from '../../../shared/persisted-state-types' import { getRepoKind } from '../../../shared/repo-kind' +import { sshRemotePtyLeaseAllowsReattach } from '../../../shared/ssh-types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../../shared/worktree/id' import { isWslUncPath } from '../../../shared/wsl-paths' @@ -40,6 +41,13 @@ function collectPersistedWorkspaceOwners( } } for (const lease of state.sshRemotePtyLeases) { + // A lease that can never be reattached is a routing tombstone, not a claim on a workspace: + // `terminated` is the operator close, and an `expired` row marked `supersededBy` / + // `relayIdRecycled` already lost its pane to a newer lease. Counting them as owners pinned + // their worktree's metadata row permanently, so the prune could never make progress (#17775). + if (!sshRemotePtyLeaseAllowsReattach(lease)) { + continue + } add(lease.worktreeId) } for (const entry of state.migrationUnsupportedPtyEntries) { diff --git a/src/main/project-runtime-git-options.ts b/src/main/project-runtime-git-options.ts index 808d31d5fcf..20aa0e9659a 100644 --- a/src/main/project-runtime-git-options.ts +++ b/src/main/project-runtime-git-options.ts @@ -102,7 +102,12 @@ export function getWorktreeMirrorDistro( store: ProjectRuntimeResolutionStore, repo: Repo ): string | undefined { - const projectRuntime = resolveLocalProjectRuntimeForRepo(store, repo) + return getWorktreeMirrorDistroForRuntime(resolveLocalProjectRuntimeForRepo(store, repo)) +} + +export function getWorktreeMirrorDistroForRuntime( + projectRuntime: ProjectExecutionRuntimeResolution | undefined +): string | undefined { if (!projectRuntime || projectRuntime.status !== 'resolved') { return undefined } diff --git a/src/main/providers/agent-foreground-process-batch.ts b/src/main/providers/agent-foreground-process-batch.ts index 12b60164446..414e57afcb3 100644 --- a/src/main/providers/agent-foreground-process-batch.ts +++ b/src/main/providers/agent-foreground-process-batch.ts @@ -29,7 +29,10 @@ export type BatchedForegroundProcessResult = { processName: string | null reason?: string /** Set only when the table was readable: every process group attached to this PTY's terminal is - * the shell's own, and none of them is stopped. Left absent when we could not observe it. */ + * the shell's own, none of them is stopped, AND that group's only member is the shell itself. + * Left absent when we could not observe it. Keeps the tty-shaped name because it is on the wire + * (`ForegroundProcessEvidence`); the value only ever got stricter, so an old client reading it + * skips more, never less. */ shellOwnsEveryTtyProcessGroup?: boolean } @@ -62,30 +65,45 @@ export type BatchedForegroundProcessOptions = { stats?: ProcessTableIndexStats } -/** Which process groups occupy each controlling terminal, and which terminals hold a stopped - * process. */ -type TtyOccupancy = { +/** The two units a forced stop can reach, indexed from one capture: which process groups occupy + * each controlling terminal (which terminals hold a stopped process), and how many rows belong to + * each process group anywhere on the host. */ +type PaneOccupancy = { processGroupsByTty: ReadonlyMap> stoppedTtys: ReadonlySet + /** Rows per `pgid`, counted over the WHOLE table with no tty filter — that is the point of it. + * A member that shares the shell's group but has no controlling terminal is reachable by + * `killpg` and invisible to every tty-shaped index. */ + rowsByProcessGroup: ReadonlyMap + /** True when some row carried no `pgid`, so the group counts are incomplete and cannot support + * an idleness claim. */ + processGroupsIncomplete: boolean } -const ttyOccupancyByCapture = new WeakMap() +const paneOccupancyByCapture = new WeakMap() -/** Index the capture by controlling terminal. +/** Index the capture by controlling terminal and by process group. * - * Keyed on `tpgid` because the snapshot carries no tty column and does not need one: a process - * group belongs to exactly one session, a session to at most one controlling terminal, so two - * rows reporting the same live `tpgid` are on the same tty. Memoized per capture, since the - * per-pane cadence poll and `pty.listProcesses` share one TTL-cached table. */ -function getTtyOccupancy(rows: readonly ProcessTableRow[]): TtyOccupancy { - const cached = ttyOccupancyByCapture.get(rows) + * The tty half is keyed on `tpgid` because the snapshot carries no tty column and does not need + * one: a process group belongs to exactly one session, a session to at most one controlling + * terminal, so two rows reporting the same live `tpgid` are on the same tty. Memoized per capture, + * since the per-pane cadence poll and `pty.listProcesses` share one TTL-cached table. */ +function getPaneOccupancy(rows: readonly ProcessTableRow[]): PaneOccupancy { + const cached = paneOccupancyByCapture.get(rows) if (cached) { return cached } const processGroupsByTty = new Map>() const stoppedTtys = new Set() + const rowsByProcessGroup = new Map() + let processGroupsIncomplete = false for (const row of rows) { - if (row.pgid === undefined || row.tpgid === undefined || row.tpgid <= 0) { + if (row.pgid === undefined) { + processGroupsIncomplete = true + continue + } + rowsByProcessGroup.set(row.pgid, (rowsByProcessGroup.get(row.pgid) ?? 0) + 1) + if (row.tpgid === undefined || row.tpgid <= 0) { continue } let groups = processGroupsByTty.get(row.tpgid) @@ -99,8 +117,13 @@ function getTtyOccupancy(rows: readonly ProcessTableRow[]): TtyOccupancy { stoppedTtys.add(row.tpgid) } } - const occupancy: TtyOccupancy = { processGroupsByTty, stoppedTtys } - ttyOccupancyByCapture.set(rows, occupancy) + const occupancy: PaneOccupancy = { + processGroupsByTty, + stoppedTtys, + rowsByProcessGroup, + processGroupsIncomplete + } + paneOccupancyByCapture.set(rows, occupancy) return occupancy } @@ -161,7 +184,7 @@ export function resolveAgentForegroundProcessesFromIndex( } } - const occupancy = getTtyOccupancy(index.rows) + const occupancy = getPaneOccupancy(index.rows) return requests.map((request) => { const root = lookupProcessTableIndex(index, (value) => value.byPid.get(request.rootPid)) if (!root) { @@ -185,20 +208,40 @@ export function resolveAgentForegroundProcessesFromIndex( reason: 'no_controlling_tty' } } - // The only host-observable "nothing is running here" signal, and it has to be read off the - // whole tty rather than off `tpgid === pgid`. A backgrounded `pnpm build &` and a Ctrl-Z'd - // editor both leave the shell owning the foreground group, byte-identical to an idle prompt; - // what separates them is a second process group attached to the pane's terminal. That is also - // exactly the blast radius of the stop this attests to — `forceKillPosixPtyProcessGroups` - // SIGKILLs every process group on the tty — so the evidence and the kill now measure the same - // thing. A reader may treat `false` as "busy" and must never treat absence as "idle". + // The only host-observable "nothing is running here" signal, and it takes TWO measurements + // because the stop it authorizes has two units. `forceKillPosixPtyProcessGroups` collects every + // process group on the pane's tty and then `killpg`s each one, so the blast radius is + // (groups on the tty) x (members of those groups, wherever they are). Neither half implies the + // other, so both are required: + // + // tty: a backgrounded `pnpm build &` and a Ctrl-Z'd editor both hand the terminal back, so + // the shell's row is byte-identical to an idle prompt. What separates them is a second + // process group attached to the pane's terminal. + // group: with job control off (`set +m`, common in non-interactive and dumb-terminal shells, + // and settable by the user at the prompt) a background job KEEPS the shell's pgid, so + // the tty shows one group and that group is running a build. Same for a child that + // drops the controlling terminal without `setsid` (`tpgid == -1`, absent from every + // tty index, still reachable by `killpg`) and for a double-forked grandchild that + // reparents to pid 1 and so never appears in the ppid walk below. + // + // Residual after both, written down because the predicate cannot see it: the capture is a + // snapshot, so work started between the `ps` and the signal is invisible — bounded, not + // removed, by RELAY_PTY_SWEEP_MAX_EVIDENCE_AGE_MS on the reading side; and a process the host's + // own `ps` cannot enumerate (another PID namespace, `hidepid=2`, a table truncated by a + // permission boundary) is unobservable here while `killpg` still reaches it. + // + // A reader may treat `false` as "busy" and must never treat absence as "idle". const ttyProcessGroups = occupancy.processGroupsByTty.get(root.tpgid) const shellOwnsEveryTtyProcessGroup = root.tpgid === root.pgid && ttyProcessGroups !== undefined && ttyProcessGroups.size === 1 && ttyProcessGroups.has(root.pgid) && - !occupancy.stoppedTtys.has(root.tpgid) + !occupancy.stoppedTtys.has(root.tpgid) && + !occupancy.processGroupsIncomplete && + // The root always counts itself, so exactly one row in its group means the group IS the + // shell — no separate leader check, and no set of pids retained per capture. + occupancy.rowsByProcessGroup.get(root.pgid) === 1 const allCandidates = rowsByOwner.get(root.pid) ?? [] const foregroundCandidates = allCandidates.filter((row) => row.pgid === root.tpgid) const fallbackProcess = request.fallbackProcess diff --git a/src/main/providers/ssh-git-read-provider.ts b/src/main/providers/ssh-git-read-provider.ts index 5cbcbc17b5a..cbf20271003 100644 --- a/src/main/providers/ssh-git-read-provider.ts +++ b/src/main/providers/ssh-git-read-provider.ts @@ -44,7 +44,8 @@ export class SshGitReadProvider { } } - private invalidateGitReads(): void { + /** Overridden by subclasses that own additional read caches (worktree listings). */ + protected invalidateGitReads(): void { this.gitDiffReadDedupe.clear() this.statusReadLeaseOwner.invalidate() this.upstreamStatusReadOwner.invalidate() diff --git a/src/main/providers/ssh-git-worktree-list-dedupe.test.ts b/src/main/providers/ssh-git-worktree-list-dedupe.test.ts new file mode 100644 index 00000000000..4030dbe87e4 --- /dev/null +++ b/src/main/providers/ssh-git-worktree-list-dedupe.test.ts @@ -0,0 +1,156 @@ +/** + * Local repos coalesce concurrent `git worktree list` scans (`shareWorktreeScan`); the SSH path + * branched away from that and paid one relay round trip per independent caller (`worktrees:list`, + * `worktrees:listAll`, the space repo scan, provisioned-root adoption). These are call counters. + */ +import { describe, expect, it } from 'vitest' +import { SshGitProvider } from './ssh-git-provider' +import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' + +const REPO_PATH = '/home/user/repo' + +const WORKTREES = [ + { path: REPO_PATH, head: 'abc123', branch: 'main', isBare: false, isMainWorktree: true } +] + +type Deferred = { resolve: (value: unknown) => void; reject: (error: unknown) => void } + +/** Holds `git.listWorktrees` open so overlap is deterministic; answers everything else at once. */ +function createPendingListMux(): { mux: MockMultiplexer; listDeferreds: Deferred[] } { + const mux = createMockMux() + const listDeferreds: Deferred[] = [] + mux.request.mockImplementation((method: string) => { + if (method !== 'git.listWorktrees') { + return Promise.resolve(undefined) + } + return new Promise((resolve, reject) => { + listDeferreds.push({ resolve, reject }) + }) + }) + return { mux, listDeferreds } +} + +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +function countListRequests(mux: MockMultiplexer): number { + return mux.request.mock.calls.filter((call) => call[0] === 'git.listWorktrees').length +} + +describe('SSH git.listWorktrees in-flight dedupe', () => { + it('collapses concurrent listings of one repo into a single relay request', async () => { + const { mux, listDeferreds } = createPendingListMux() + const provider = new SshGitProvider('conn-1', mux as never) + + const listings = Array.from({ length: 6 }, () => provider.listWorktrees(REPO_PATH)) + await flush() + + expect(countListRequests(mux)).toBe(1) + expect(mux.request).toHaveBeenCalledWith( + 'git.listWorktrees', + { repoPath: REPO_PATH }, + { signal: undefined } + ) + + listDeferreds[0].resolve(WORKTREES) + expect(await Promise.all(listings)).toEqual(Array.from({ length: 6 }, () => WORKTREES)) + }) + + it('does not share across repos or connections', async () => { + const { mux } = createPendingListMux() + const provider = new SshGitProvider('conn-1', mux as never) + + void provider.listWorktrees(REPO_PATH) + void provider.listWorktrees('/home/user/other') + await flush() + expect(countListRequests(mux)).toBe(2) + + const second = createPendingListMux() + void new SshGitProvider('conn-2', second.mux as never).listWorktrees(REPO_PATH) + await flush() + + expect(countListRequests(second.mux)).toBe(1) + expect(countListRequests(mux)).toBe(2) + }) + + it('keeps a signalled listing on its own request', async () => { + const { mux } = createPendingListMux() + const provider = new SshGitProvider('conn-1', mux as never) + + void provider.listWorktrees(REPO_PATH) + await flush() + const controller = new AbortController() + void provider.listWorktrees(REPO_PATH, { signal: controller.signal }) + await flush() + + expect(countListRequests(mux)).toBe(2) + expect(mux.request).toHaveBeenCalledWith( + 'git.listWorktrees', + { repoPath: REPO_PATH }, + { signal: controller.signal } + ) + }) + + it('re-requests after the shared listing settles instead of caching it', async () => { + const { mux, listDeferreds } = createPendingListMux() + const provider = new SshGitProvider('conn-1', mux as never) + + const first = provider.listWorktrees(REPO_PATH) + await flush() + listDeferreds[0].resolve(WORKTREES) + await first + + void provider.listWorktrees(REPO_PATH) + await flush() + + expect(countListRequests(mux)).toBe(2) + }) + + it.each([ + ['addWorktree', (p: SshGitProvider) => p.addWorktree(REPO_PATH, 'feature', '/home/user/feat')], + ['removeWorktree', (p: SshGitProvider) => p.removeWorktree('/home/user/feat')] + ])('invalidates the shared listing after %s', async (_name, mutate) => { + const { mux } = createPendingListMux() + const provider = new SshGitProvider('conn-1', mux as never) + + void provider.listWorktrees(REPO_PATH) + await flush() + expect(countListRequests(mux)).toBe(1) + + await mutate(provider) + + // The catalog moved, so a joiner must not inherit the pre-mutation scan. + void provider.listWorktrees(REPO_PATH) + await flush() + expect(countListRequests(mux)).toBe(2) + }) + + it('shares a failed listing with its joiners and re-requests afterwards', async () => { + const { mux, listDeferreds } = createPendingListMux() + const provider = new SshGitProvider('conn-1', mux as never) + + const listings = [provider.listWorktrees(REPO_PATH), provider.listWorktrees(REPO_PATH)] + await flush() + expect(countListRequests(mux)).toBe(1) + + const failure = new Error('relay request failed') + listDeferreds[0].reject(failure) + await expect(listings[0]).rejects.toBe(failure) + await expect(listings[1]).rejects.toBe(failure) + + void provider.listWorktrees(REPO_PATH) + await flush() + expect(countListRequests(mux)).toBe(2) + }) + + it('refuses an unauthoritative relay answer for every joiner (#14004)', async () => { + const { mux, listDeferreds } = createPendingListMux() + const provider = new SshGitProvider('conn-1', mux as never) + + const listings = [provider.listWorktrees(REPO_PATH), provider.listWorktrees(REPO_PATH)] + await flush() + listDeferreds[0].resolve([]) + + await expect(listings[0]).rejects.toThrow() + await expect(listings[1]).rejects.toThrow() + }) +}) diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts index 8dae1413321..17e5b273de7 100644 --- a/src/main/providers/ssh-git-worktree-provider.ts +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -2,6 +2,7 @@ import type { GitStatusResult } from '../../shared/git-status-types' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { CapabilityProbeCache } from '../../shared/capability-probe-cache' +import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import { assertAuthoritativeWorktreeCatalog } from '../../shared/worktree/worktree-catalog-availability' import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' @@ -29,16 +30,35 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { private readonly worktreeIsCleanCapabilityCache = new CapabilityProbeCache< typeof WORKTREE_IS_CLEAN_CAPABILITY >(Number.POSITIVE_INFINITY) + // Scoped to this provider instance, so two SSH hosts never share an entry. + private readonly worktreeListDedupe = new InFlightPromiseDedupe() + protected override invalidateGitReads(): void { + super.invalidateGitReads() + this.worktreeListDedupe.clear() + } + + /** Un-signalled reads of one repo coalesce onto the request already in flight; nothing is cached. */ async listWorktrees( repoPath: string, options?: { signal?: AbortSignal } ): Promise { - const response = await this.mux.request( - 'git.listWorktrees', - { repoPath }, - { signal: options?.signal } + // Why: same rule as shareWorktreeScan — one caller's abort must not cancel the scan its + // joiners are still waiting on, so a signalled read keeps its own request. + if (options?.signal) { + return this.requestWorktreeList(repoPath, options.signal) + } + return this.worktreeListDedupe.run(stableInFlightKey(['listWorktrees', repoPath]), () => + this.requestWorktreeList(repoPath) ) + } + + /** The one real relay round trip a coalesced read's joiners all wait on. */ + private async requestWorktreeList( + repoPath: string, + signal?: AbortSignal + ): Promise { + const response = await this.mux.request('git.listWorktrees', { repoPath }, { signal }) // Why (#14004): relays before this fix answered a failed worktree scan with `[]`. Mixed versions are // normal, so refuse the shape here too — a Git repo always lists its own checkout. return assertAuthoritativeWorktreeCatalog(response, repoPath) diff --git a/src/main/providers/ssh-pty-errors.ts b/src/main/providers/ssh-pty-errors.ts index 92ee84941ad..4d312caa8b1 100644 --- a/src/main/providers/ssh-pty-errors.ts +++ b/src/main/providers/ssh-pty-errors.ts @@ -20,12 +20,16 @@ export function isSshPtyIdentityMismatchError(error: unknown): boolean { } /** - * A reachable relay answered for this exact PTY id and reported it absent — positive evidence of - * absence from the execution host, so `exited` rather than `unverifiable` - * (docs/reference/ssh-execution-boundary.md). Deliberately NOT raised for a transport failure, a - * request timeout, a disposed multiplexer, an identity mismatch (the id names a live PTY belonging - * to another pane), or `restoreRequired` (the PTY is live, only its source stream is not) — none of - * those observe the process, and treating them as absence orphans live remote work. + * A reachable relay answered for this exact PTY id and reported it absent, so the client may retire + * its own route to it. Deliberately NOT raised for a transport failure, a request timeout, a + * disposed multiplexer, an identity mismatch (the id names a live PTY belonging to another pane), + * or `restoreRequired` (the PTY is live, only its source stream is not) — none of those observe the + * process, and treating them as absence orphans live remote work. + * + * This is NOT itself a death certificate. `pty.attach` answers absent for an id its session map + * never had as readily as for a pid it probed — and after a relay restart that is every id the + * previous one minted. Certifying `exited` needs {@link SshPtyProvenExitedOnRelayError} + * (docs/reference/ssh-execution-boundary.md). * * Carries the same `SSH_SESSION_EXPIRED` message so message-based consumers are unaffected; only * callers that can act on the stronger verdict test the class. @@ -40,3 +44,24 @@ export class SshPtyAbsentFromRelayError extends Error { export function isSshPtyAbsentFromRelayError(error: unknown): boolean { return error instanceof SshPtyAbsentFromRelayError } + +/** + * The narrow half of {@link SshPtyAbsentFromRelayError}: the relay probed the pid and found it gone + * before answering absent, so this is the one attach refusal that observed the process and the only + * one that may certify a death. + * + * The parent class is raised for the whole union, which also contains "this session map has no such + * id" — every id minted before a relay restart, checked against nothing. Callers that only release + * client-side bookkeeping keep testing the parent; a caller about to record `exited` must test this + * (docs/reference/ssh-execution-boundary.md). + */ +export class SshPtyProvenExitedOnRelayError extends SshPtyAbsentFromRelayError { + constructor(message: string) { + super(message) + this.name = 'SshPtyProvenExitedOnRelayError' + } +} + +export function isSshPtyProvenExitedOnRelayError(error: unknown): boolean { + return error instanceof SshPtyProvenExitedOnRelayError +} diff --git a/src/main/providers/ssh-pty-inspect-observation-identity.test.ts b/src/main/providers/ssh-pty-inspect-observation-identity.test.ts new file mode 100644 index 00000000000..4e0dae1ead1 --- /dev/null +++ b/src/main/providers/ssh-pty-inspect-observation-identity.test.ts @@ -0,0 +1,68 @@ +/** + * Ratchet (#18419): `pty.inspectProcess` must NOT be in-flight coalesced the way the sibling git + * reads in `SshGitReadProvider` are. The host mints one `observationEpoch` per request and the + * pane foreground reader commits that epoch per read, so a shared reply reads as a stale replay to + * the second reader to settle — see the companion renderer proof in + * `src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts`. + * These are request counters, not timings. + */ +import { describe, expect, it, vi } from 'vitest' +import { createSshPtyProviderRpcOperations } from './ssh-pty-provider-rpc-operations' + +const RELAY_PTY_ID = 'pty-1' +const APP_PTY_ID = `ssh:conn-1@@${RELAY_PTY_ID}` +const INCARNATION_ID = 'inc-1' + +/** Answers `pty.inspectProcess` with a fresh host observation per request, held open on demand. */ +function createInspectingOperations(): { + operations: ReturnType + request: ReturnType + resolvers: ((value: unknown) => void)[] +} { + const resolvers: ((value: unknown) => void)[] = [] + const request = vi.fn(() => new Promise((resolve) => resolvers.push(resolve))) + return { + operations: createSshPtyProviderRpcOperations({ + mux: { request } as never, + toRelayPtyId: () => RELAY_PTY_ID + }), + request, + resolvers + } +} + +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('SSH pty.inspectProcess observation identity', () => { + it('gives each overlapping probe of one pane+incarnation its own host observation', async () => { + const { operations, request, resolvers } = createInspectingOperations() + + const first = operations.inspectProcess(APP_PTY_ID, { expectedIncarnationId: INCARNATION_ID }) + const second = operations.inspectProcess(APP_PTY_ID, { expectedIncarnationId: INCARNATION_ID }) + await flush() + + expect(request).toHaveBeenCalledTimes(2) + resolvers[0]({ foregroundProcess: 'claude', observationEpoch: 1 }) + resolvers[1]({ foregroundProcess: 'claude', observationEpoch: 2 }) + // Each read settles on the observation minted for it, never a neighbour's. + expect(await first).toMatchObject({ observationEpoch: 1 }) + expect(await second).toMatchObject({ observationEpoch: 2 }) + }) + + it('does not share a failed probe with an overlapping one', async () => { + const { operations, request, resolvers } = createInspectingOperations() + + const failing = operations.inspectProcess(APP_PTY_ID, { expectedIncarnationId: INCARNATION_ID }) + const overlapping = operations.inspectProcess(APP_PTY_ID, { + expectedIncarnationId: INCARNATION_ID + }) + await flush() + + expect(request).toHaveBeenCalledTimes(2) + resolvers[0](Promise.reject(new Error('relay dropped the probe'))) + resolvers[1]({ foregroundProcess: 'claude', observationEpoch: 1 }) + + await expect(failing).rejects.toThrow('relay dropped the probe') + expect(await overlapping).toMatchObject({ observationEpoch: 1 }) + }) +}) diff --git a/src/main/providers/ssh-pty-provider-rpc-operations.ts b/src/main/providers/ssh-pty-provider-rpc-operations.ts index 3f9953b9d1c..71ddbefce97 100644 --- a/src/main/providers/ssh-pty-provider-rpc-operations.ts +++ b/src/main/providers/ssh-pty-provider-rpc-operations.ts @@ -50,6 +50,10 @@ export function createSshPtyProviderRpcOperations({ mux, toRelayPtyId }: SshPtyP const result = await mux.request('pty.getForegroundProcess', { id: toRelayPtyId(id) }) return result as string | null }, + // Do NOT in-flight coalesce this the way the sibling git reads are: the host mints one + // `observationEpoch` per request and the pane foreground reader commits it per read, so a + // shared reply reads as a stale replay and degrades a `live` identity read to `unverifiable`. + // Guarded by ssh-pty-inspect-observation-identity.test.ts; #17525 removes the poll. inspectProcess: async ( id: string, options?: { expectedIncarnationId?: string } diff --git a/src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts b/src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts index 79ed97a8f19..bfcc174a4b6 100644 --- a/src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts +++ b/src/main/providers/ssh-pty-reattach-absence-discrimination.test.ts @@ -14,9 +14,11 @@ import { describe, expect, it, vi } from 'vitest' import { isSshPtyAbsentFromRelayError, + isSshPtyProvenExitedOnRelayError, SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR, SSH_SESSION_EXPIRED_ERROR } from './ssh-pty-errors' +import { PTY_ATTACH_PROVEN_EXITED_MARKER } from '../../shared/pty-attach-absence-evidence' import { reattachSshPtySessionForSpawn } from './ssh-pty-session-reattach' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' @@ -55,6 +57,20 @@ describe('an SSH reattach refusal says whether the host observed the PTY', () => expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR) expect(isSshPtyAbsentFromRelayError(error)).toBe(true) + // ...but absence from the relay is a union. An unmarked answer is also what a restarted relay + // gives for every id the previous one minted, checked against nothing, so it may not certify a + // death (docs/reference/ssh-execution-boundary.md). + expect(isSshPtyProvenExitedOnRelayError(error)).toBe(false) + }) + + it('separates the refusal the relay backed with a pid probe', async () => { + const error = await refusalFrom(async () => { + throw new Error(`PTY "${SESSION}" not found (${PTY_ATTACH_PROVEN_EXITED_MARKER})`) + }) + + expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR) + expect(isSshPtyAbsentFromRelayError(error)).toBe(true) + expect(isSshPtyProvenExitedOnRelayError(error)).toBe(true) }) it('gives a restoreRequired refusal its own token instead of the expiry text', async () => { @@ -79,5 +95,6 @@ describe('an SSH reattach refusal says whether the host observed the PTY', () => expect(error.message).toContain(SSH_SESSION_EXPIRED_ERROR) expect(isSshPtyAbsentFromRelayError(error)).toBe(false) + expect(isSshPtyProvenExitedOnRelayError(error)).toBe(false) }) }) diff --git a/src/main/providers/ssh-pty-session-reattach.ts b/src/main/providers/ssh-pty-session-reattach.ts index 530135ad310..f05373a03dc 100644 --- a/src/main/providers/ssh-pty-session-reattach.ts +++ b/src/main/providers/ssh-pty-session-reattach.ts @@ -5,9 +5,11 @@ import { SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR, SSH_SESSION_EXPIRED_ERROR, SshPtyAbsentFromRelayError, + SshPtyProvenExitedOnRelayError, isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from './ssh-pty-errors' +import { isProvenExitedPtyAttachRefusal } from '../../shared/pty-attach-absence-evidence' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import type { PtySpawnOptions, PtySpawnResult } from './types' import type { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' @@ -238,6 +240,13 @@ export async function reattachSshPtySession(args: { // Why the class: the relay answered for this exact id, so callers holding a pane binding may // retire it and spawn fresh. Plain `SSH_SESSION_EXPIRED` cannot say that — a restarted relay // renumbers from pty-1, so the message alone is indistinguishable from a lost link. + // + // Why the subclass: the relay marks the one refusal it backed with a pid probe. Without the + // marker the answer is the "no such id" union, which is not evidence the shell ended, so the + // narrow class is minted only when the relay said so (docs/reference/ssh-execution-boundary.md). + if (isProvenExitedPtyAttachRefusal(error)) { + throw new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}`) + } throw new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}`) } throw error diff --git a/src/main/proxy-guarded-fetch-call-site-audit.test.ts b/src/main/proxy-guarded-fetch-call-site-audit.test.ts new file mode 100644 index 00000000000..cf9e1f60609 --- /dev/null +++ b/src/main/proxy-guarded-fetch-call-site-audit.test.ts @@ -0,0 +1,140 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Startup applies the persisted proxy to `session.defaultSession` only, and +// `installElectronProxyRequestGuard(session.defaultSession)` is what actually holds requests +// until that apply (and every later proxy transition) settles. Two ways a main-process fetcher +// can escape that fence, both audited here: +// 1. a `net.fetch` / `net.request` that names another `session` or `partition` +// 2. a `.fetch(` on a `session.fromPartition(...)` session +// Known pre-existing gap outside this repo's reach: electron-updater runs on its own partition. +// +// Rule 2 entries map a file to its expected number of non-`net` `.fetch(` calls. A count change +// means a call site was added, removed, or moved: re-audit the file and update the count. +const AUDITED_NON_NET_FETCH_CALLS = new Map([ + // Isolated cookie-jar session, proxied by createOpenCodeRequestSession before any request. + ['main/rate-limits/opencode-go-usage-fetcher.ts', 2], + // Isolated cookie-jar session that does NOT apply the proxy — a pre-existing gap, not a + // regression: no proxy has ever reached this partition. Keep it listed so it stays visible. + ['main/rate-limits/minimax-request-context.ts', 2], + // Injected HttpClient, not a session: resolves to net.fetch on defaultSession + // (main/host/electron-http-client.ts) or to the global-fetch-audited Node fallback. + ['main/jira/authenticated-request.ts', 1] +]) + +// `globalThis.fetch` / `global.fetch` belong to global-fetch-call-site-audit.test.ts. +// `\s*` before `(`: the formatter never emits `net.fetch (url)`, but an unformatted call must not +// be a hole in a guard whose whole job is to fail on the call nobody reviewed. +const FETCH_CALL = /\.fetch\s*\(/g +const RECEIVER_IDENTIFIER = /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*$/ +const DEFAULT_SESSION_RECEIVERS = new Set(['net', 'globalThis', 'global']) +const NET_REQUEST_CALL = /(? { + const sources = auditedSourceFiles(__dirname) + + it('keeps every net.fetch/net.request on the guarded default session', () => { + const offenders: string[] = [] + for (const { file, content } of sources) { + for (const match of content.matchAll(NET_REQUEST_CALL)) { + const args = callArgumentText(content, match.index + match[0].length) + if (SESSION_SCOPED_OPTION.test(args)) { + offenders.push(`${file}:${content.slice(0, match.index).split('\n').length}`) + } + } + } + expect( + offenders.sort(), + 'This request names its own session/partition, so it is not covered by ' + + 'installElectronProxyRequestGuard(session.defaultSession) and startup never applies the ' + + 'persisted proxy to it. Either drop the option, or apply the proxy to that session ' + + 'yourself (see main/rate-limits/opencode-go-request-session.ts) and allowlist it here.' + ).toEqual([]) + }) + + it('keeps every non-default-session fetcher audited with its expected count', () => { + const found = new Map() + for (const { file, content } of sources) { + const hits = [...content.matchAll(FETCH_CALL)].filter((match) => { + const receiver = RECEIVER_IDENTIFIER.exec(content.slice(0, match.index))?.[1] + // A chained (`session.fromPartition(...).fetch(`) or member (`ctx.session.fetch(`) + // receiver has no bare trailing identifier, and is never the default session. + return receiver === undefined || !DEFAULT_SESSION_RECEIVERS.has(receiver) + }).length + if (hits > 0) { + found.set(file, hits) + } + } + + const drifted = [...found] + .filter(([file, count]) => AUDITED_NON_NET_FETCH_CALLS.get(file) !== count) + .map(([file, count]) => `${file}: found ${count} call(s)`) + .sort() + expect( + drifted, + 'A session.fromPartition(...) session is not covered by ' + + 'installElectronProxyRequestGuard(session.defaultSession), so nothing holds its requests ' + + 'until the proxy lands and startup never applies the proxy to it. Apply the proxy to that ' + + 'session yourself (see main/rate-limits/opencode-go-request-session.ts), then update ' + + 'AUDITED_NON_NET_FETCH_CALLS.' + ).toEqual([]) + + const stale = [...AUDITED_NON_NET_FETCH_CALLS.keys()].filter((file) => !found.has(file)).sort() + expect(stale, 'Remove audited entries whose .fetch( calls are gone.').toEqual([]) + }) +}) diff --git a/src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts b/src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts index 82986a4b3fd..3d8157c9a28 100644 --- a/src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts +++ b/src/main/runtime/expired-ssh-lease-pane-candidacy.test.ts @@ -12,6 +12,12 @@ const TARGET = 'ssh-target' const TAB_ID = 'tab-candidacy' type LeaseReader = { + workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate: ( + session: { terminalLayoutsByTabId?: Record }, + worktreeId: string, + tabs: { id: string; ptyId: string | null }[] + ) => boolean + collectRecentExpiredSshLeaseTabIds: (worktreeId: string) => ReadonlySet getRecentExpiredSshLease: ( worktreeId: string, tabId: string, @@ -87,4 +93,45 @@ describe('recent expired SSH lease candidacy', () => { ) expect(reader.hasRecentExpiredSshLeasePane(TEST_WORKTREE_ID, pane)).toBe(true) }) + + it('collects the same tabs the per-tab reader reports, in one sweep of the leases', () => { + const leases = [leaseFor('pty-1', { supersededBy: 'pty-2' }), leaseFor('pty-2')] + let sweeps = 0 + const reader = new OrcaRuntimeService({ + ...store, + getSshRemotePtyLeases: () => { + sweeps += 1 + return leases + } + }) as unknown as LeaseReader + const tabs = Array.from({ length: 8 }, (_, index) => ({ + id: index === 7 ? TAB_ID : `tab-${index}`, + ptyId: null + })) + + expect( + reader.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate( + { terminalLayoutsByTabId: {} }, + TEST_WORKTREE_ID, + tabs + ) + ).toBe(true) + // One sweep answers all eight tabs; the per-tab reader used to sweep once per tab. + expect(sweeps).toBe(1) + expect([...reader.collectRecentExpiredSshLeaseTabIds(TEST_WORKTREE_ID)]).toEqual([TAB_ID]) + }) + + it('reports no candidate when no lease names any of the worktree tabs', () => { + const reader = readerWithLeases([ + leaseFor('pty-1', { tabId: 'somewhere-else', leafId: undefined }) + ]) + + expect( + reader.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate( + { terminalLayoutsByTabId: {} }, + TEST_WORKTREE_ID, + [{ id: TAB_ID, ptyId: null }] + ) + ).toBe(false) + }) }) diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index adf9223b39a..684d6699fc1 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -36,7 +36,13 @@ const MOBILE_DYNAMIC_RPC_METHODS = [ 'github.resolveReviewThread', 'github.project.updateIssueCommentBySlug', 'github.project.deleteIssueCommentBySlug', - 'hostedReview.forBranch' + 'hostedReview.forBranch', + 'runtime.clientCapabilities.update', + 'agentSession.send', + 'agentSession.cancel', + 'agentSession.history', + 'agentSession.hold', + 'agentSession.release' ] const MOBILE_STREAMING_CLEANUP_RPC_METHODS = [ @@ -145,9 +151,28 @@ describe('mobile RPC allowlist', () => { ).toEqual([]) }) - it('does not expose structured agent sessions to mobile credentials', () => { + it('exposes only the mobile structured agent-session surface', () => { expect( [...mobileRpcAllowlist()].filter((method) => method.startsWith('agentSession.')) - ).toEqual([]) + ).toEqual([ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.ensure', + 'agentSession.send', + 'agentSession.cancel', + 'agentSession.close', + 'agentSession.respondToApproval', + 'agentSession.respondToQuestion', + 'agentSession.setOption', + 'agentSession.handoffStatus', + 'agentSession.options', + 'agentSession.history', + 'agentSession.subscribe', + 'agentSession.unsubscribe', + 'agentSession.hold', + 'agentSession.release' + ]) + expect(mobileRpcAllowlist().has('agentSession.attach')).toBe(false) + expect(mobileRpcAllowlist().has('agentSession.requestHandoff')).toBe(false) }) }) diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 9c6e5cda532..bd282b6575d 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -21,8 +21,10 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi tab: RuntimeMobileSessionAgentTab ): Promise { const host = getStructuredAgentSessionHost() - if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(tab.sessionId, false) + if (host) { + if (typeof host.setSessionTabVisibility === 'function') { + await host.setSessionTabVisibility(tab.sessionId, false) + } } const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null @@ -41,6 +43,10 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi } this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) + // Retire durable visibility and the runtime snapshot before stopping the provider. + if (typeof host?.close === 'function') { + await host.close(tab.sessionId) + } } // Why: a refused echoed close means the echoing client already pruned its diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index f9116f73405..f17a2285b83 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -4,7 +4,8 @@ import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktre import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { isFolderRepo } from '../../shared/repo-kind' -import { getRepoSshConnectionId } from '../../shared/execution-host' +import { resolveWorktreeCreateRoute } from '../worktree-create-execution-host-route' +import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch' import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create' import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' @@ -57,10 +58,14 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork draftStartup?.agent ?? (requestedAgentEnabled ? requestedAgent : undefined)) const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste - // Resolve the execution host once: SSH ownership has two spellings, and reading the raw - // `connectionId` field routes an `executionHostId: 'ssh:*'`-only repo down the local path, - // which runs `git worktree add` on the client against a remote path. - const sshConnectionId = getRepoSshConnectionId(repo) + // Resolve the execution host once, shared with the `worktrees:create` IPC entry point so the + // two cannot answer differently for the same repo. Reading the raw `connectionId` field routes + // an `executionHostId: 'ssh:*'`-only repo down the local path, which runs `git worktree add` on + // the client against a remote path. + const createRoute = resolveWorktreeCreateRoute(repo) + // `null` on a `runtime:` host is deliberate: its nested target is addressable only inside that + // environment, so the trust write must not go to a same-named target in this client's table. + const sshConnectionId = createRoute.kind === 'ssh' ? createRoute.connectionId : null if (isFolderRepo(repo)) { // A folder workspace is a registration, not a filesystem create, so it is host-agnostic — // except for the agent trust write, which must land on the host that will run the agent. @@ -97,20 +102,21 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork const lineageInput = args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) - if (sshConnectionId) { - // Why normalize the row: the remote-create pipeline reads `repo.connectionId!` at every - // depth, so hand it the connection the resolved host actually names. - const result = await this.createManagedRemoteWorktree( - { ...repo, connectionId: sshConnectionId }, - { - ...args, - activate: args.activate, - ...(effectiveStartup ? { startup: effectiveStartup } : {}), - ...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}), - ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), - ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) - } - ) + if (createRoute.kind === 'runtime') { + throw new ExecutionHostNotDispatchableError(createRoute.hostId) + } + if (createRoute.kind === 'ssh') { + // `createRoute.repo` carries the resolved connection in `connectionId`, because the + // remote-create pipeline still reads `repo.connectionId!` at every depth. See the workaround + // note in worktree-create-execution-host-route.ts. + const result = await this.createManagedRemoteWorktree(createRoute.repo, { + ...args, + activate: args.activate, + ...(effectiveStartup ? { startup: effectiveStartup } : {}), + ...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}), + ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), + ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) + }) const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) this.emitWorktreeLifecycle({ kind: 'created', diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 94fc77f8158..42c9c7ff6d3 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -1,7 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner } from './orca-runtime-structured-agent-session-recover-tui-owner' import { DEFAULT_WORKTREE_PS_LIMIT } from './orca-runtime-postlude' -import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import type { RuntimeWorktreePsResult } from '../../shared/runtime-types' import { buildRuntimeWorktreePsSummaries } from './runtime-worktree-ps-summaries' import { buildRuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths' import { @@ -15,6 +15,7 @@ import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identit import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { buildWorktreeListingPage } from './worktree-listing-host-scope' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -30,11 +31,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent async getWorktreePs( limit = DEFAULT_WORKTREE_PS_LIMIT, sourceDefaultsSupported = true - ): Promise<{ - worktrees: RuntimeWorktreePsSummary[] - totalCount: number - truncated: boolean - }> { + ): Promise { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') } @@ -111,11 +108,9 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent }) const sorted = [...summaries.values()].sort(compareWorktreePs) - return { - worktrees: sorted.slice(0, limit), - totalCount: sorted.length, - truncated: sorted.length > limit - } + // Why: the same cap starvation as worktree.list — a host whose rows all sort last gets no + // page at all, which is indistinguishable from it having no workspaces (#18104). + return buildWorktreeListingPage(sorted, limit, this.listKnownExecutionHostIds()) } listRepos(): Repo[] { diff --git a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts index c4687c96d14..a9f377e5a7b 100644 --- a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts @@ -82,17 +82,24 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or worktreeId: string, tabs: WorkspaceSessionState['tabsByWorktree'][string] ): boolean { + // Why resolved lazily and reused: the per-tab question is the same lease sweep with a + // different tabId, so asking it once per worktree answers every tab. Kept lazy so a + // worktree whose first tab already owns a serve/SSH pty never sweeps at all. + let recoverableTabIds: ReadonlySet | undefined return tabs.some((tab) => { if (this.isServeOrSshOwnedPtyId(tab.ptyId)) { return true } const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId - return ( - (leafPtyIds && - Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId))) || - // Why: expiry keeps pane coordinates so paired viewers can request a fresh shell. - this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null - ) + if ( + leafPtyIds && + Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId)) + ) { + return true + } + // Why: expiry keeps pane coordinates so paired viewers can request a fresh shell. + recoverableTabIds ??= this.collectRecentExpiredSshLeaseTabIds(worktreeId) + return recoverableTabIds.has(tab.id) }) } @@ -128,6 +135,54 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or ) } + /** + * Why eligibility belongs in the selection, not after it: a pane accumulates leases as it + * re-leases under new relay ids, so `(worktreeId, tabId, leafId)` names several. A superseded or + * relay-id-recycled predecessor is `expired` for a reason that already names its successor, and + * the unqualified callers use this answer to decide a pane is still recoverable — reporting one + * would offer paired viewers a recovery `recoverTerminalPane` then refuses. Picking the first + * ELIGIBLE orphan also keeps a predecessor from shadowing the successor that is genuinely + * reattachable. + */ + private isRecentExpiredSshLeaseForWorktree( + lease: ReturnType>[number], + worktreeId: string, + now: number + ): boolean { + return ( + lease.state === 'expired' && + lease.worktreeId === worktreeId && + sshRemotePtyLeaseAllowsReattach(lease) && + lease.updatedAt <= now && + now - lease.updatedAt <= SSH_PANE_RECOVERY_GRACE_MS + ) + } + + /** + * Leaf is the pane's identity; the frozen tabId is only trustworthy while nothing else can say + * where the leaf actually lives. + */ + private resolveExpiredSshLeaseTabId( + lease: ReturnType>[number] + ): string { + const currentTabId = lease.leafId + ? this.findCurrentTerminalTabIdForLeaf(lease.targetId, lease.leafId) + : undefined + return currentTabId ?? lease.tabId + } + + /** The tabs a recent eligible expired lease still names, resolved in one sweep of the leases. */ + protected collectRecentExpiredSshLeaseTabIds(worktreeId: string): ReadonlySet { + const now = Date.now() + const tabIds = new Set() + for (const lease of this.store?.getSshRemotePtyLeases?.() ?? []) { + if (this.isRecentExpiredSshLeaseForWorktree(lease, worktreeId, now)) { + tabIds.add(this.resolveExpiredSshLeaseTabId(lease)) + } + } + return tabIds + } + protected getRecentExpiredSshLease( worktreeId: string, tabId: string, @@ -137,34 +192,17 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or const now = Date.now() return ( this.store?.getSshRemotePtyLeases?.().find((lease) => { - if (lease.state !== 'expired' || lease.worktreeId !== worktreeId) { + if (!this.isRecentExpiredSshLeaseForWorktree(lease, worktreeId, now)) { return false } - // Why eligibility belongs in the selection, not after it: a pane accumulates leases as it - // re-leases under new relay ids, so `(worktreeId, tabId, leafId)` names several. A - // superseded or relay-id-recycled predecessor is `expired` for a reason that already names - // its successor, and the unqualified callers use this answer to decide a pane is still - // recoverable — reporting one would offer paired viewers a recovery `recoverTerminalPane` - // then refuses. Picking the first ELIGIBLE orphan also keeps a predecessor from shadowing - // the successor that is genuinely reattachable. - if (!sshRemotePtyLeaseAllowsReattach(lease)) { - return false - } - // Leaf is the pane's identity; the frozen tabId is only trustworthy while nothing else can - // say where the leaf actually lives. - const currentTabId = lease.leafId - ? this.findCurrentTerminalTabIdForLeaf(lease.targetId, lease.leafId) - : undefined return ( - (currentTabId ?? lease.tabId) === tabId && + this.resolveExpiredSshLeaseTabId(lease) === tabId && // Leases store RELAY form (`toStoredPtyId` -> `toRelaySshPtyId`); the runtime hands us // the APP form (`ssh:@@pty-3`). A raw `===` therefore never held for an SSH // pane, which is what kept this reader's only ptyId-qualified caller inert. (ptyId === undefined || lease.ptyId === toComparableRelaySshPtyId(lease.targetId, ptyId)) && - (leafId === undefined || lease.leafId === undefined || lease.leafId === leafId) && - lease.updatedAt <= now && - now - lease.updatedAt <= SSH_PANE_RECOVERY_GRACE_MS + (leafId === undefined || lease.leafId === undefined || lease.leafId === leafId) ) }) ?? null ) diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts index 5b45abd13fa..25a294c2a90 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts @@ -10,8 +10,7 @@ import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cl import { getRuntimeWorktreeRemovalOptionsKey } from './runtime-worktree-selection' import { withWorktreeSpan } from '../observability/instrumentation' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' -import { requireSshGitProvider } from '../providers/ssh-git-dispatch' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { resolveWorktreeRemovalRoute } from '../worktree-removal-execution-host-route' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { listWorktreesStrict } from '../git/worktree' import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' @@ -79,22 +78,24 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM if (orphanOrFolderResult) { return orphanOrFolderResult } - const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null - const localWorktreeGitOptions = repo.connectionId - ? {} - : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) + // One host for the whole removal. Listing on a different host from the one the prune and + // the delete use is how an `executionHostId: 'ssh:*'`-only row got listed remotely and + // deleted here; the route refuses rather than falling back to this machine. + const route = resolveWorktreeRemovalRoute(removalHostId) + const localWorktreeGitOptions = + route.kind === 'ssh' ? {} : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - const registeredWorktrees = repo.connectionId - ? await provider!.listWorktrees(repo.path) - : hasLocalWorktreeGitOptions - ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listWorktreesStrict(repo.path) + const registeredWorktrees = + route.kind === 'ssh' + ? await route.provider.listWorktrees(repo.path) + : hasLocalWorktreeGitOptions + ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listWorktreesStrict(repo.path) const removedMeta = resolveWorktreeRemovalMetadata( store, removalTarget.repoId, removalTarget.id, - cleanupHostId ?? getRepoExecutionHostId(repo) + removalHostId ) const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget const registeredWorktree = findRegisteredDeletableWorktree( @@ -111,8 +112,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM removedPushTarget, force, allowUnverifiedPtyStop, - provider, - fsProvider: fsProvider ?? null, + route, localOptions: localWorktreeGitOptions, store, acquireWatcherRemoval: this.acquireFileWatcherRemoval, @@ -123,7 +123,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM }), deleteHistory: () => deleteRemoteWorktreeHistory( - repo.connectionId ? this.getSshProviderFn?.(repo.connectionId) : undefined, + route.kind === 'ssh' ? this.getSshProviderFn?.(route.connectionId) : undefined, removalTarget.id ), finishRemoval: () => { @@ -145,13 +145,17 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } if ( - !repo.connectionId && + route.kind === 'local' && force === true && process.platform === 'win32' && (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && removedMeta && - (await isRuntimeWorktreePathMissing(repo, canonicalWorktreePath, localWorktreeGitOptions)) + (await isRuntimeWorktreePathMissing( + route.hostId, + canonicalWorktreePath, + localWorktreeGitOptions + )) ) { const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ canonicalWorktreePath, @@ -182,26 +186,27 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM this.notifyWorktreesChanged(repo.id) return removalResult ?? {} } - if (repo.connectionId) { + if (route.kind === 'ssh') { return removeRuntimeRegisteredRemoteWorktree({ repo, target: removalTarget, registeredWorktree, removedPushTarget, store, - provider: provider!, + provider: route.provider, + connectionId: route.connectionId, force, allowUnverifiedPtyStop, deleteBranch, acquireWatcherRemoval: this.acquireFileWatcherRemoval, stopPtys: () => this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { - connectionId: repo.connectionId!, + connectionId: route.connectionId, allowUnverifiedStop: allowUnverifiedPtyStop }), deleteHistory: () => deleteRemoteWorktreeHistory( - this.getSshProviderFn?.(repo.connectionId!), + this.getSshProviderFn?.(route.connectionId), removalTarget.id ), preserveBranchHead: (result, fallbackHead) => diff --git a/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts b/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts index d49dc410cd5..01f4f305803 100644 --- a/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts +++ b/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts @@ -91,7 +91,11 @@ export async function removeOrphanOrFolderWorktree({ if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) { throw new Error('Cannot delete the project root workspace. Remove the folder project instead.') } - const folderConnectionId = repo.connectionId?.trim() || null + // Resolved, not raw: a folder repo naming its owner only as `executionHostId: 'ssh:*'` used to + // tear down its PTYs and history on the client. A `runtime:` host answers null — its nested + // target is addressable only inside that environment, never from this client's SSH table. + const folderHost = parseExecutionHostId(removalHostId) + const folderConnectionId = folderHost?.kind === 'ssh' ? folderHost.targetId : null const folderSshPtyProvider = folderConnectionId ? runtime.getSshProviderFn?.(folderConnectionId) : undefined diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index 770ce0517a8..2f95dfeada1 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -87,6 +87,11 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { ) { super() this.store = store + store?.onSettingsChanged?.((updates) => { + if ('experimentalStructuredNativeChat' in updates) { + this.notifyMobileSessionTabsChanged() + } + }) const runtime = this as RuntimeCommandSurfaceHost installRuntimeFileCommandSurface(runtime, this.fileCommands) installRuntimeGitCommandSurface(runtime, this.gitCommands) diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 278ccd28a74..346006cd5fd 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -136,7 +136,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId listResolved: () => this.listResolvedWorktrees(), resolveRepo: (selector) => this.resolveRepoSelector(selector), selectRepos: (selector) => this.selectReposBySelector(selector), - scanRepo: (repo) => this.listRepoWorktreesForResolution(repo) + scanRepo: (repo) => this.listRepoWorktreesForResolution(repo), + listKnownHostIds: () => this.listKnownExecutionHostIds() }) protected readonly ptyForegroundAgent = new RuntimePtyForegroundAgent({ diff --git a/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts b/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts new file mode 100644 index 00000000000..90be5c61ba2 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +describe('structured native chat settings', () => { + it('republishes mobile session tabs when the host visibility setting changes', () => { + const settingsListeners: ((updates: Record) => void)[] = [] + const runtime = new OrcaRuntimeService({ + onSettingsChanged: vi.fn((listener) => { + settingsListeners.push(listener as (updates: Record) => void) + return vi.fn() + }) + } as never) + const notify = vi.spyOn(runtime, 'notifyMobileSessionTabsChanged').mockImplementation(() => {}) + + settingsListeners[0]?.({ compactWorktreeCards: true }) + expect(notify).not.toHaveBeenCalled() + + settingsListeners[0]?.({ experimentalStructuredNativeChat: true }) + expect(notify).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 52db9673705..9e752330445 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -205,6 +205,11 @@ describe('structured session cold restoration', () => { it('normalizes a restored tab id and removes it when closed', async () => { const runtime = new OrcaRuntimeService() const closeSessionTab = vi.fn(async () => undefined) + const closeStructuredSession = vi.fn(async () => { + const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') + expect(snapshot.tabs.some((tab) => tab.type === 'agent-session')).toBe(false) + }) + const setSessionTabVisibility = vi.fn(async () => undefined) runtime.setNotifier({ closeSessionTab } as never) const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean @@ -221,6 +226,8 @@ describe('structured session cold restoration', () => { setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, restoreReadableSessions: async () => undefined, + close: closeStructuredSession, + setSessionTabVisibility, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-session', @@ -304,6 +311,11 @@ describe('structured session cold restoration', () => { 'structured-agent-session-restored-session', 'workspace-1' ) + expect(closeStructuredSession).toHaveBeenCalledWith('restored-session') + expect(setSessionTabVisibility).toHaveBeenCalledWith('restored-session', false) + expect(setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( + closeStructuredSession.mock.invocationCallOrder[0]! + ) const closed = await runtime.listMobileSessionTabs('id:workspace-1') expect(closed.tabs.map((tab) => tab.id)).toEqual([ diff --git a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts index 2d6042c280e..965a1a1bc3f 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-04.spec.ts @@ -168,6 +168,8 @@ describe('OrcaRuntimeService', () => { agents: [] } ], + // Why: the summary now names the hosts it covered; an absent scope would read as absolute. + hostScope: { hostIds: ['local'], omittedHostIds: [] }, totalCount: 1, truncated: false }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts new file mode 100644 index 00000000000..981e9860463 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + OrcaRuntimeService, + addWorktree, + registerSshGitProvider, + unregisterSshGitProvider +} from '../orca-runtime-test-mocks.spec' +import { store } from '../orca-runtime-test-fixtures.spec' + +const RUNTIME_REPO_PATH = '/remote/repo' + +function makeRuntimeHostedStore(extraRepoFields: Record = {}) { + const repo = { + ...store.getRepos()[0]!, + path: RUNTIME_REPO_PATH, + executionHostId: 'runtime:env-1', + ...extraRepoFields + } + return { + ...store, + getRepos: () => [repo], + getRepo: (id: string) => (id === repo.id ? repo : undefined) + } +} + +describe('OrcaRuntimeService worktree create execution host', () => { + beforeEach(() => { + vi.mocked(addWorktree).mockClear() + }) + + it('refuses to create for a runtime-hosted repo with no nested SSH target', async () => { + const runtime = new OrcaRuntimeService(makeRuntimeHostedStore() as never) + + await expect( + runtime.createManagedWorktree({ repoSelector: 'id:repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(addWorktree).not.toHaveBeenCalled() + }) + + it('refuses a runtime-hosted repo whose nested SSH target is dialable in this namespace', async () => { + // `target-a` names a target inside env-1. The same-named one registered here is another + // machine, so creating through it would put the checkout on the wrong host. + const provider = { exec: vi.fn(), addWorktree: vi.fn(), listWorktrees: vi.fn() } + registerSshGitProvider('target-a', provider as never) + const runtime = new OrcaRuntimeService( + makeRuntimeHostedStore({ connectionId: 'target-a' }) as never + ) + + try { + await expect( + runtime.createManagedWorktree({ repoSelector: 'id:repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') + + expect(provider.addWorktree).not.toHaveBeenCalled() + expect(addWorktree).not.toHaveBeenCalled() + } finally { + unregisterSshGitProvider('target-a') + } + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts index beaa91036d0..06982cf179e 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts @@ -193,41 +193,69 @@ describe('OrcaRuntimeService', () => { }) it('does not coalesce concurrent same-id removals on different hosts', async () => { + const baseRepo = store.getRepos()[0]! + // The second owner names its host only in the migrated spelling, so its removal must reach the + // SSH host rather than joining the local one and running `git worktree remove` here. + const remoteRepo = { ...baseRepo, path: '/remote/repo', executionHostId: 'ssh:host-b' } const runtimeStore = { ...store, - getRepos: () => [ - { ...store.getRepos()[0], executionHostId: 'local' }, - { ...store.getRepos()[0], executionHostId: 'runtime:env-1' } - ] + getRepos: () => [{ ...baseRepo, executionHostId: 'local' }, remoteRepo] } const runtime = createWorktreeRemovalRuntime(runtimeStore) vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() }) const bothStarted = deferred() const finishRemovals = deferred() let startedCount = 0 - vi.mocked(removeWorktree).mockImplementation(async () => { + const startRemoval = async (): Promise> => { startedCount += 1 if (startedCount === 2) { bothStarted.resolve() } await finishRemovals.promise return {} - }) + } + vi.mocked(removeWorktree).mockImplementation(startRemoval) + const provider = { + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: remoteRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: TEST_WORKTREE_PATH, + head: 'def456', + branch: 'feature/test', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockImplementation(startRemoval) + } + registerSshGitProvider('host-b', provider as never) - const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'local') - const paired = runtime.removeManagedWorktree( - TEST_WORKTREE_ID, - true, - false, - false, - 'runtime:env-1' - ) + try { + const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'local') + const remote = runtime.removeManagedWorktree( + TEST_WORKTREE_ID, + true, + false, + false, + 'ssh:host-b' + ) - await bothStarted.promise - expect(removeWorktree).toHaveBeenCalledTimes(2) + await bothStarted.promise + expect(removeWorktree).toHaveBeenCalledTimes(1) + expect(provider.removeWorktree).toHaveBeenCalledTimes(1) - finishRemovals.resolve() - await expect(Promise.all([local, paired])).resolves.toEqual([{}, {}]) + finishRemovals.resolve() + await expect(Promise.all([local, remote])).resolves.toEqual([{}, {}]) + } finally { + unregisterSshGitProvider('host-b') + } }) it('rejects concurrent runtime worktree removals for the same id with different options', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts new file mode 100644 index 00000000000..8ec38e0e5e8 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts @@ -0,0 +1,216 @@ +import { + listWorktrees, + listWorktreesStrict, + registerSshFilesystemProvider, + registerSshGitProvider, + removeWorktree, + unregisterSshFilesystemProvider, + unregisterSshGitProvider +} from '../orca-runtime-test-mocks.spec' +import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TEST_WORKTREE_ID, + TEST_WORKTREE_PATH, + makeWorktreeMeta, + store +} from '../orca-runtime-test-fixtures.spec' +import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' +import type { ExecutionHostId } from '../../../shared/execution-host' + +const REMOTE_REPO_PATH = '/remote/repo' + +function missingPath(): never { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +} + +function makeGitProvider(worktrees: readonly unknown[]) { + return { + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + listWorktrees: vi.fn().mockResolvedValue(worktrees), + removeWorktree: vi.fn().mockResolvedValue({}) + } +} + +function makeRemoteRepoStore( + executionHostId: ExecutionHostId, + extraRepoFields: Record = {}, + metaOverrides: Partial = {} +) { + const repo = { + ...store.getRepos()[0]!, + path: REMOTE_REPO_PATH, + executionHostId, + ...extraRepoFields + } + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ hostId: executionHostId, ...metaOverrides }) + } + const removeWorktreeMeta = vi.fn((worktreeId: string, hostId?: string) => { + if (!hostId || metaById[worktreeId]?.hostId === hostId) { + delete metaById[worktreeId] + } + }) + return { + repo, + metaById, + removeWorktreeMeta, + runtimeStore: { + ...store, + getRepos: () => [repo], + getRepo: (id: string) => (id === repo.id ? repo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + }, + removeWorktreeMeta + } + } +} + +const REPO_ROOT_ENTRY = { + path: REMOTE_REPO_PATH, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true +} + +const REGISTERED_ENTRY = { + path: TEST_WORKTREE_PATH, + head: 'def456', + branch: 'feature/test', + isBare: false, + isMainWorktree: false +} + +describe('OrcaRuntimeService worktree removal execution host', () => { + beforeEach(() => { + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktreesStrict).mockClear() + vi.mocked(removeWorktree).mockClear() + }) + + it('removes a migrated-spelling SSH row on its own host, never this machine', async () => { + // No `connectionId` at all: the row names its owner only as `executionHostId: 'ssh:target-a'`. + const { runtimeStore, removeWorktreeMeta, metaById } = makeRemoteRepoStore('ssh:target-a') + const provider = makeGitProvider([REPO_ROOT_ENTRY, REGISTERED_ENTRY]) + registerSshGitProvider('target-a', provider as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) + vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() }) + + try { + await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a') + + expect(provider.listWorktrees).toHaveBeenCalledWith(REMOTE_REPO_PATH) + expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, true) + expect(listWorktreesStrict).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'ssh:target-a') + expect(metaById[TEST_WORKTREE_ID]).toBeUndefined() + } finally { + unregisterSshGitProvider('target-a') + } + }) + + it('runs the cleanup path for a migrated-spelling row entirely on its host', async () => { + // #18358 made an `executionHostId`-only row a removable cleanup candidate. Every step of the + // removal it starts — list, existence probe, delete, prune — must name the same host. + const { runtimeStore, removeWorktreeMeta } = makeRemoteRepoStore('ssh:target-a') + const provider = makeGitProvider([REPO_ROOT_ENTRY]) + const fsProvider = { stat: vi.fn(missingPath), deletePath: vi.fn() } + registerSshGitProvider('target-a', provider as never) + registerSshFilesystemProvider('target-a', fsProvider as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) + + try { + await expect( + runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a') + ).resolves.toEqual({}) + + expect(provider.listWorktrees).toHaveBeenCalledWith(REMOTE_REPO_PATH) + expect(fsProvider.stat).toHaveBeenCalledWith(TEST_WORKTREE_PATH) + expect(listWorktreesStrict).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(fsProvider.deletePath).not.toHaveBeenCalled() + expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'ssh:target-a') + } finally { + unregisterSshFilesystemProvider('target-a') + unregisterSshGitProvider('target-a') + } + }) + + it('keeps two simultaneously registered SSH hosts off each other paths', async () => { + const { runtimeStore } = makeRemoteRepoStore('ssh:target-b') + const providerA = makeGitProvider([REPO_ROOT_ENTRY, REGISTERED_ENTRY]) + const providerB = makeGitProvider([REPO_ROOT_ENTRY, REGISTERED_ENTRY]) + registerSshGitProvider('target-a', providerA as never) + registerSshGitProvider('target-b', providerB as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) + vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() }) + + try { + await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-b') + + expect(providerB.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, true) + expect(providerA.listWorktrees).not.toHaveBeenCalled() + expect(providerA.removeWorktree).not.toHaveBeenCalled() + } finally { + unregisterSshGitProvider('target-b') + unregisterSshGitProvider('target-a') + } + }) + + it('refuses an SSH row whose host is unreachable instead of deleting here', async () => { + const { runtimeStore, metaById } = makeRemoteRepoStore('ssh:target-a') + const runtime = createWorktreeRemovalRuntime(runtimeStore) + + await expect( + runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a') + ).rejects.toThrow('Remote connection dropped') + + expect(listWorktreesStrict).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(metaById[TEST_WORKTREE_ID]).toBeDefined() + }) + + it('refuses a runtime row with no nested SSH target rather than deleting locally', async () => { + const { runtimeStore, metaById } = makeRemoteRepoStore('runtime:env-1') + const runtime = createWorktreeRemovalRuntime(runtimeStore) + + await expect( + runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'runtime:env-1') + ).rejects.toThrow('not dispatched by this process') + + expect(listWorktreesStrict).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(metaById[TEST_WORKTREE_ID]).toBeDefined() + }) + + it('refuses a runtime row whose nested SSH target is dialable in this namespace', async () => { + // `connectionId: 'target-a'` names a target inside env-1, not the one registered here. The raw + // read dialled this client's same-named host and removed a worktree on the wrong machine. + const { runtimeStore, metaById } = makeRemoteRepoStore('runtime:env-1', { + connectionId: 'target-a' + }) + const provider = makeGitProvider([REPO_ROOT_ENTRY, REGISTERED_ENTRY]) + registerSshGitProvider('target-a', provider as never) + const runtime = createWorktreeRemovalRuntime(runtimeStore) + + try { + await expect( + runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'runtime:env-1') + ).rejects.toThrow('not dispatched by this process') + + // Selector resolution still lists through the raw field before removal begins — a read on + // the wrong namespace, tracked separately. Nothing destructive reaches it. + expect(provider.removeWorktree).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + expect(metaById[TEST_WORKTREE_ID]).toBeDefined() + } finally { + unregisterSshGitProvider('target-a') + } + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index aabfeb899c8..9d223233ef2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -18,6 +18,7 @@ await import('./orca-runtime-tests/terminal-listing.spec') await import('./orca-runtime-tests/worktree-selector-resolution.spec') await import('./orca-runtime-tests/local-worktree-creation.spec') await import('./orca-runtime-tests/local-worktree-creation-part-02.spec') +await import('./orca-runtime-tests/worktree-create-execution-host.spec') await import('./orca-runtime-tests/ssh-worktree-lifecycle.spec') await import('./orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec') await import('./orca-runtime-tests/ssh-worktree-lifecycle-part-03.spec') @@ -104,5 +105,6 @@ await import('./orca-runtime-tests/worktree-removal-and-reconciliation.spec') await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec') await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec') await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec') +await import('./orca-runtime-tests/worktree-removal-execution-host.spec') await import('./orca-runtime-tests/targeting-and-resilience.spec') await import('./orca-runtime-tests/worktree-scan-cache-ttl.spec') diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts index ae1cd4f45d1..c96238f13ee 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts @@ -6,6 +6,23 @@ import { paneKeyMatchSuffix } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +import { DISPATCH_CONTEXT_COLUMN_LIST } from '../row-column-lists' + +// Why: hoisted and wildcard-free so the graph-publish fan-out hits the SyncDatabase statement cache. +const ACTIVE_DISPATCH_BY_HANDLE_SQL = + // Why: newest-first like the pane lookups below — an unordered LIMIT 1 could pin a stale row if a handle ever has two active dispatches. + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts + WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` +const ACTIVE_DISPATCH_BY_PANE_KEY_SQL = `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts + WHERE assignee_pane_key = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` +const ACTIVE_DISPATCH_BY_PANE_SUFFIX_SQL = `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts + WHERE assignee_pane_key IS NOT NULL + AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1 + AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ? + ORDER BY rowid DESC LIMIT 1` +const LATEST_DISPATCH_BY_HANDLE_SQL = `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE assignee_handle = ? ORDER BY rowid DESC LIMIT 1` export function getActiveDispatchForTerminal( this: OrchestrationDb, @@ -116,14 +133,9 @@ export function findActiveDispatchForAssignee( assigneeHandle: string, assigneePaneKey?: string ): DispatchContextRow | undefined { - const byHandle = this.db - .prepare( - // Why: newest-first like the pane lookups below — an unordered LIMIT 1 could pin a stale row if a handle ever has two active dispatches. - `SELECT * FROM dispatch_contexts - WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') - ORDER BY rowid DESC LIMIT 1` - ) - .get(assigneeHandle) as DispatchContextRow | undefined + const byHandle = this.db.prepare(ACTIVE_DISPATCH_BY_HANDLE_SQL).get(assigneeHandle) as + | DispatchContextRow + | undefined if (byHandle) { return byHandle } @@ -132,13 +144,9 @@ export function findActiveDispatchForAssignee( return undefined } - const exactPane = this.db - .prepare( - `SELECT * FROM dispatch_contexts - WHERE assignee_pane_key = ? AND status IN ('pending', 'dispatched') - ORDER BY rowid DESC LIMIT 1` - ) - .get(assigneePaneKey) as DispatchContextRow | undefined + const exactPane = this.db.prepare(ACTIVE_DISPATCH_BY_PANE_KEY_SQL).get(assigneePaneKey) as + | DispatchContextRow + | undefined if (exactPane) { return exactPane } @@ -146,13 +154,7 @@ export function findActiveDispatchForAssignee( return undefined } return this.db - .prepare( - `SELECT * FROM dispatch_contexts - WHERE assignee_pane_key IS NOT NULL - AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1 - AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ? - ORDER BY rowid DESC LIMIT 1` - ) + .prepare(ACTIVE_DISPATCH_BY_PANE_SUFFIX_SQL) .get(paneKeyMatchSuffix(assigneePaneKey)) as DispatchContextRow | undefined } @@ -160,11 +162,9 @@ export function getLatestDispatchForTerminal( this: OrchestrationDb, handle: string ): DispatchContextRow | undefined { - return this.db - .prepare( - 'SELECT * FROM dispatch_contexts WHERE assignee_handle = ? ORDER BY rowid DESC LIMIT 1' - ) - .get(handle) as DispatchContextRow | undefined + return this.db.prepare(LATEST_DISPATCH_BY_HANDLE_SQL).get(handle) as + | DispatchContextRow + | undefined } export type DispatchLookupMethods = { diff --git a/src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts b/src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts new file mode 100644 index 00000000000..26f82410cfe --- /dev/null +++ b/src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts @@ -0,0 +1,148 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { RuntimeAgentOrchestrationProjection } from '../../runtime-agent-orchestration-projection' +import type { OrchestrationCompatibilityTerminalAuthority } from '../../runtime-terminal-contracts' +import type { RuntimeLeafRecord } from '../../runtime-terminal-state-records' +import { OrchestrationDb } from '../db' +import { createRootDispatch } from './root-dispatch-test-fixture' + +const COORDINATOR_HANDLE = 'term_coordinator' +const COORDINATOR_PANE = 'tab_c:leaf_c' +const WORKER_HANDLE = 'term_worker' +const WORKER_PANE = 'tab_w:leaf_w' +const IDLE_HANDLE = 'term_idle' +const IDLE_PANE = 'tab_i:leaf_i' + +// Why: mirrors SyncDatabase's `isStatementCacheable` — aggregate `(*)` is fine, any other `*` is not. +const WILDCARD_PROJECTION = /(? { + for (const db of openDatabases.splice(0)) { + try { + db.close() + } catch { + // already closed by the test + } + } + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function openDatabase(path: string): OrchestrationDb { + const db = new OrchestrationDb(path) + openDatabases.push(db) + return db +} + +function temporaryDatabasePath(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-orchestration-hot-path-')) + temporaryDirectories.push(directory) + return join(directory, 'orchestration.db') +} + +/** Counts real SQL compilations by wrapping the node:sqlite handle SyncDatabase prepares against. */ +function trackCompiledSql(db: OrchestrationDb): string[] { + const inner = (db.db as unknown as { db: { prepare(sql: string): unknown } }).db + const original = inner.prepare.bind(inner) + const compiled: string[] = [] + inner.prepare = (sql: string) => { + compiled.push(sql) + return original(sql) + } + return compiled +} + +function seedDispatchedWorker(db: OrchestrationDb): void { + const run = db.createRun({ + objective: 'demo', + coordinatorHandle: COORDINATOR_HANDLE, + coordinatorPaneKey: COORDINATOR_PANE + }) + const task = db.createTask({ + spec: 'ship the thing', + runId: run.id, + createdByTerminalHandle: COORDINATOR_HANDLE, + createdByPaneKey: COORDINATOR_PANE, + createdByProcessIncarnation: 'inc_1', + createdByRunGeneration: run.consumer_generation + }) + createRootDispatch(db, task.id, WORKER_HANDLE, WORKER_PANE) +} + +function buildProjection(db: OrchestrationDb): RuntimeAgentOrchestrationProjection { + const leaves = [{ ptyId: 'pty_w' }, { ptyId: 'pty_i' }] as unknown as RuntimeLeafRecord[] + const handleByLeaf = new Map([ + [leaves[0] as RuntimeLeafRecord, WORKER_HANDLE], + [leaves[1] as RuntimeLeafRecord, IDLE_HANDLE] + ]) + const paneByLeaf = new Map([ + [leaves[0] as RuntimeLeafRecord, WORKER_PANE], + [leaves[1] as RuntimeLeafRecord, IDLE_PANE] + ]) + return new RuntimeAgentOrchestrationProjection({ + getDb: () => db, + getLeaves: () => leaves, + getPtys: () => [], + issueLeafHandle: (leaf) => handleByLeaf.get(leaf) ?? '', + issuePtyHandle: () => '', + makePaneKey: (leaf) => paneByLeaf.get(leaf) ?? '', + getWorktreeId: () => null, + getHandleForPaneKey: (paneKey) => (paneKey === COORDINATOR_PANE ? COORDINATOR_HANDLE : null), + getPaneKey: (handle) => (handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : null), + getDispatchAuthority: (handle) => + handle === COORDINATOR_HANDLE + ? ({ + paneKey: COORDINATOR_PANE, + processIncarnation: 'inc_1' + } as OrchestrationCompatibilityTerminalAuthority) + : null + }) +} + +describe('orchestration hot-path statement compilation', () => { + it('compiles each hot-path SQL exactly once across repeated graph publishes', () => { + const db = openDatabase(':memory:') + seedDispatchedWorker(db) + const projection = buildProjection(db) + const compiled = trackCompiledSql(db) + + const publishes = [projection.buildByPaneKey()] + const compiledByFirstPublish = [...compiled] + for (let publish = 0; publish < 4; publish += 1) { + publishes.push(projection.buildByPaneKey()) + } + + const compilationsPerSql = new Map() + for (const sql of compiled) { + compilationsPerSql.set(sql, (compilationsPerSql.get(sql) ?? 0) + 1) + } + expect([...compilationsPerSql].filter(([, count]) => count > 1)).toEqual([]) + expect(compiled).toEqual(compiledByFirstPublish) + // Why: a cache that changed what the fan-out returns would be worse than the recompiles. + expect(publishes[0]).toBeDefined() + for (const publish of publishes) { + expect(publish).toEqual(publishes[0]) + } + }) + + // Why: `SELECT *` is what made these statements uncacheable, and a retained wildcard is the only + // way node:sqlite could build a row from stale column names after another connection's ALTER. + // Seeds on one connection and publishes on a second so every compilation here is hot-path SQL. + it('publishes without compiling a single wildcard projection', () => { + const path = temporaryDatabasePath() + seedDispatchedWorker(openDatabase(path)) + + const reader = openDatabase(path) + const compiled = trackCompiledSql(reader) + buildProjection(reader).buildByPaneKey() + + expect(compiled.length).toBeGreaterThan(0) + expect(compiled.filter((sql) => WILDCARD_PROJECTION.test(sql))).toEqual([]) + }) +}) diff --git a/src/main/runtime/orchestration/db/row-column-lists.test.ts b/src/main/runtime/orchestration/db/row-column-lists.test.ts new file mode 100644 index 00000000000..2c4041bebaa --- /dev/null +++ b/src/main/runtime/orchestration/db/row-column-lists.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './orchestration-db' +import { + DISPATCH_CONTEXT_COLUMNS, + RUN_COLUMNS, + selectColumns, + TASK_COLUMNS +} from './row-column-lists' + +let db: OrchestrationDb | undefined + +afterEach(() => { + db?.close() + db = undefined +}) + +function tableColumns(table: string): string[] { + const rows = (db as OrchestrationDb).db.pragma(`table_info(${table})`) as { name: string }[] + return rows.map((row) => row.name).sort() +} + +describe('row column lists', () => { + // Why: these lists replaced `SELECT *`, so a column added to the schema without being listed here + // would silently stop being read. tsc pins list↔type; this pins list↔schema. + it.each([ + ['runs', RUN_COLUMNS], + ['tasks', TASK_COLUMNS], + ['dispatch_contexts', DISPATCH_CONTEXT_COLUMNS] + ])('projects every %s column the migrated schema declares', (table, columns) => { + db = new OrchestrationDb(':memory:') + + expect([...columns].sort()).toEqual(tableColumns(table)) + }) + + it('qualifies each column when the statement joins under an alias', () => { + expect(selectColumns(['id', 'run_id'])).toBe('id, run_id') + expect(selectColumns(['id', 'run_id'], 't')).toBe('t.id, t.run_id') + }) + + // Why: an alias-qualified projection must key the returned row by the bare column name, exactly as + // the `t.*` it replaced did — otherwise every lineage consumer reads undefined. + it('returns bare column names for an alias-qualified projection', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'demo', + coordinatorHandle: 'term_c', + coordinatorPaneKey: 'tab_c:leaf_c' + }) + const task = db.createTask({ spec: 'work', runId: run.id }) + + const row = db.db + .prepare(`SELECT ${selectColumns(TASK_COLUMNS, 't')} FROM tasks t WHERE t.id = ?`) + .get(task.id) as Record + + expect(Object.keys(row).sort()).toEqual([...TASK_COLUMNS].sort()) + }) +}) diff --git a/src/main/runtime/orchestration/db/row-column-lists.ts b/src/main/runtime/orchestration/db/row-column-lists.ts new file mode 100644 index 00000000000..26255fe551a --- /dev/null +++ b/src/main/runtime/orchestration/db/row-column-lists.ts @@ -0,0 +1,82 @@ +import type { DispatchContextRow, RunRow, TaskRow } from '../types' + +// Why: `SyncDatabase` refuses to cache any `SELECT *` (node:sqlite can build the first row after a +// schema change from stale column names), so a wildcard read recompiles its SQL on every call. +// Spelling the projection out makes the hot-path statements cacheable by that existing LRU. +// Drift is caught twice: `satisfies` + the exhaustiveness assertions below pin list↔type at tsc, +// and `row-column-lists.test.ts` pins list↔schema against a freshly migrated database. + +export const RUN_COLUMNS = [ + 'id', + 'objective', + 'home_database', + 'coordinator_handle', + 'coordinator_pane_key', + 'consumer_generation', + 'legacy', + 'created_at', + 'updated_at' +] as const satisfies readonly (keyof RunRow)[] + +export const TASK_COLUMNS = [ + 'id', + 'run_id', + 'parent_id', + 'created_by_terminal_handle', + 'created_by_pane_key', + 'created_by_process_incarnation', + 'created_by_run_generation', + 'task_title', + 'display_name', + 'spec', + 'status', + 'deps', + 'result', + 'created_at', + 'completed_at' +] as const satisfies readonly (keyof TaskRow)[] + +export const DISPATCH_CONTEXT_COLUMNS = [ + 'id', + 'run_id', + 'task_id', + 'contract_version', + 'launch_token_hash', + 'assignee_handle', + 'assignee_pane_key', + 'capability_hash', + 'process_incarnation', + 'capability_revoked_at', + 'status', + 'failure_count', + 'last_failure', + 'termination_reason', + 'depth', + 'dispatched_at', + 'completed_at', + 'created_at', + 'last_heartbeat_at' +] as const satisfies readonly (keyof DispatchContextRow)[] + +// Compile check: a row field added without its column here would silently vanish from the +// projection that used to be `SELECT *`, so the missing key must fail the build. +type UnprojectedRunColumn = Exclude +type UnprojectedTaskColumn = Exclude +type UnprojectedDispatchContextColumn = Exclude< + keyof DispatchContextRow, + (typeof DISPATCH_CONTEXT_COLUMNS)[number] +> +const assertEveryRowColumnProjected: [ + UnprojectedRunColumn extends never ? true : never, + UnprojectedTaskColumn extends never ? true : never, + UnprojectedDispatchContextColumn extends never ? true : never +] = [true, true, true] +void assertEveryRowColumnProjected + +/** Projection list for a `SELECT`; `alias` qualifies each name for a joined table (`t.id, …`). */ +export function selectColumns(columns: readonly string[], alias?: string): string { + return columns.map((column) => (alias ? `${alias}.${column}` : column)).join(', ') +} + +export const RUN_COLUMN_LIST = selectColumns(RUN_COLUMNS) +export const DISPATCH_CONTEXT_COLUMN_LIST = selectColumns(DISPATCH_CONTEXT_COLUMNS) diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index 061a7b39497..84eeece7374 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -9,12 +9,20 @@ import { exposeRunTimestamps } from '../utc-timestamp' import { encodeRunListCursor, decodeRunListCursor } from '../run-list-cursor' import type { RunListPage } from '../run-list-page' import type { OrchestrationDb } from '../orchestration-db' +import { RUN_COLUMN_LIST } from '../row-column-lists' export type LegacyAdoptedMailboxOwner = { runId: string terminalHandle: string } +// Why: hoisted and wildcard-free so the per-publish run lookups hit the SyncDatabase statement cache. +const RUN_BY_ID_SQL = `SELECT ${RUN_COLUMN_LIST} FROM runs WHERE id = ?` +const RUNS_BOUND_TO_PANE_SQL = `SELECT ${RUN_COLUMN_LIST} FROM runs + WHERE coordinator_pane_key IS NOT NULL AND legacy = 0 + AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ? + ORDER BY rowid` + export function getRun(this: OrchestrationDb, id: string): RunRow | undefined { const run = this.getRunRaw(id) return run ? exposeRunTimestamps(run) : undefined @@ -103,14 +111,7 @@ export function getCurrentRunForPane(this: OrchestrationDb, paneKey: string): Ru // reminted tab halves keep matching and unparseable keys keep requiring an exact match. export function runsBoundToPane(this: OrchestrationDb, paneKey: string): RunRow[] { return ( - this.db - .prepare( - `SELECT * FROM runs - WHERE coordinator_pane_key IS NOT NULL AND legacy = 0 - AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ? - ORDER BY rowid` - ) - .all(paneKeyMatchSuffix(paneKey)) as RunRow[] + this.db.prepare(RUNS_BOUND_TO_PANE_SQL).all(paneKeyMatchSuffix(paneKey)) as RunRow[] ).filter( (run) => run.coordinator_pane_key !== null && isEquivalentPaneKey(run.coordinator_pane_key, paneKey) @@ -118,7 +119,7 @@ export function runsBoundToPane(this: OrchestrationDb, paneKey: string): RunRow[ } export function getRunRaw(this: OrchestrationDb, id: string): RunRow | undefined { - return this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id) as RunRow | undefined + return this.db.prepare(RUN_BY_ID_SQL).get(id) as RunRow | undefined } export function unbindOtherRunsForPane( diff --git a/src/main/runtime/orchestration/db/tasks/task-store.ts b/src/main/runtime/orchestration/db/tasks/task-store.ts index 9a0b15259ba..69316765a74 100644 --- a/src/main/runtime/orchestration/db/tasks/task-store.ts +++ b/src/main/runtime/orchestration/db/tasks/task-store.ts @@ -5,6 +5,7 @@ import { LEGACY_RUN_ID } from '../contract-constants' import { generateId } from '../generated-id' import type { TaskRuntimeLineageRow } from '../run-list-page' import type { OrchestrationDb } from '../orchestration-db' +import { selectColumns, TASK_COLUMNS } from '../row-column-lists' // ── Tasks ── @@ -81,24 +82,8 @@ export function createTask( return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow } -// Why: return the active creator Dispatch proof with the Task read; runtime still owns pane/process currency. -export function getTask(this: OrchestrationDb, id: string): TaskRow | undefined -export function getTask( - this: OrchestrationDb, - id: string, - dispatchRunId: string -): TaskRuntimeLineageRow | undefined -export function getTask( - this: OrchestrationDb, - id: string, - dispatchRunId?: string -): TaskRow | TaskRuntimeLineageRow | undefined { - if (dispatchRunId === undefined) { - return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined - } - return this.db - .prepare( - `SELECT t.*, +// Why: hoisted and wildcard-free so the per-publish lineage lookup hits the SyncDatabase statement cache. +const TASK_RUNTIME_LINEAGE_SQL = `SELECT ${selectColumns(TASK_COLUMNS, 't')}, creator.id AS creator_dispatch_id, creator.run_id AS creator_dispatch_run_id, creator.assignee_pane_key AS creator_dispatch_pane_key, @@ -114,8 +99,25 @@ export function getTask( LIMIT 1 ) WHERE t.id = ?` - ) - .get(dispatchRunId, id) as TaskRuntimeLineageRow | undefined + +// Why: return the active creator Dispatch proof with the Task read; runtime still owns pane/process currency. +export function getTask(this: OrchestrationDb, id: string): TaskRow | undefined +export function getTask( + this: OrchestrationDb, + id: string, + dispatchRunId: string +): TaskRuntimeLineageRow | undefined +export function getTask( + this: OrchestrationDb, + id: string, + dispatchRunId?: string +): TaskRow | TaskRuntimeLineageRow | undefined { + if (dispatchRunId === undefined) { + return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined + } + return this.db.prepare(TASK_RUNTIME_LINEAGE_SQL).get(dispatchRunId, id) as + | TaskRuntimeLineageRow + | undefined } export function listTasks( diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index 2975b67e631..d235f0ebacd 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' import { WebSocketServer, type WebSocket } from 'ws' import type { E2EEKeypair } from '../e2ee-keypair' +import { MOBILE_RELAY_CLOSE_CODE } from '../../../shared/mobile-relay-close-codes' import { RelayControlClient } from './relay-control-client' const encoder = new TextEncoder() @@ -550,4 +551,48 @@ describe('RelayControlClient scripted-socket lifecycle', () => { vi.advanceTimersByTime(91_000) expect(client.isLive()).toBe(false) }) + + it('ignores an unrecognized control message without closing the active control', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { client, socket, onClose } = scriptedControl() + await client.connect() + expect(client.isLive()).toBe(true) + + // A newer relay opcode the desktop schema does not know. Rule 2 of + // remote-wire-compatibility: an unknown-but-well-formed frame is dropped, + // never fatal to a live control. + socket.deliver({ type: 'relay-hint', v: 2, hint: 'future-feature' }) + + expect(client.isLive()).toBe(true) + expect(socket.readyState).toBe(1) + expect(onClose).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('ignores a reply whose request already timed out instead of self-closing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { client, socket, onClose } = scriptedControl() + await client.connect() + + // A relay control-error carrying a reqId with no live waiter — e.g. a late + // reply that arrived after the desktop's request deadline deleted it, or the + // relay's no-op error for a command it could not route. Must not be fatal. + socket.deliver({ type: 'control-error', reqId: 'expired-req', code: 'unknown_control_message' }) + + expect(client.isLive()).toBe(true) + expect(socket.readyState).toBe(1) + expect(onClose).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('still tears down a malformed (non-JSON) control frame', async () => { + const { client, socket, onClose } = scriptedControl() + await client.connect() + + socket.emit('message', 'not-json{', false) + + expect(client.isLive()).toBe(false) + expect(socket.readyState).toBe(3) + expect(onClose).toHaveBeenCalledWith(MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL) + }) }) diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts index 76816c81a3a..7e742173f72 100644 --- a/src/main/runtime/relay/relay-control-client.ts +++ b/src/main/runtime/relay/relay-control-client.ts @@ -234,7 +234,18 @@ export class RelayControlClient { if (this.requests.resolveMessage(message)) { return } - this.failProtocol('unknown control message') + // Drop a well-formed control message we do not recognize, matching how every + // other Orca decoder treats an unknown frame (see the silent-drop convention + // in docs/reference/remote-wire-compatibility.md). The control channel has no + // opcode negotiation step, so this reaches either a newer relay's message + // this build predates, or a reply whose request already timed out and has no + // waiter (relay control ops run DB transactions that can exceed the request + // deadline under load). Self-closing here was strictly worse than ignoring: + // it orphaned the relay session, which answered the phone with HOST_OFFLINE + // for the orphan-grace window plus the director's reconnect throttle — minutes + // of outage from a single stray frame. + const messageType = typeof message.type === 'string' ? message.type : 'unknown' + console.warn(`[relay] ignoring unrecognized control message type=${messageType}`) } private handleProofMessage(message: Record): void { diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 975988d203c..5e669ab702e 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -77,6 +77,8 @@ export type RpcContext = { clientKind?: 'mobile' | 'runtime' // Why: negotiation is bound to the authenticated socket, never asserted by a destructive request. clientCapabilities?: readonly RuntimeCapability[] + // Why: mobile v2 auth is exact-key validated; capability upgrades must mutate only the authenticated socket after auth. + updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void // Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields. orchestrationCapability?: string // Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index cc8322373b1..e3151c66b0e 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -10,6 +10,7 @@ export type RpcDispatchStreamingOptions = { pairedDeviceId?: string clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] + updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void pairing?: PairingRpcContext sendBinary?: (bytes: Uint8Array) => boolean | void registerBinaryStreamHandler?: ( diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 122e18f078a..c3febab1d62 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -29,8 +29,7 @@ import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] } -// oxfmt-ignore -type DispatchCallOptions = Pick +type DispatchCallOptions = RpcDispatchStreamingOptions export class RpcDispatcher { private readonly runtime: OrcaRuntimeService @@ -131,6 +130,7 @@ export class RpcDispatcher { clientId: options?.clientId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, + updateClientCapabilities: options?.updateClientCapabilities, orchestrationCapability: request.orchestrationCapability, authenticatedCallerFingerprint: mutation?.identity.callerFingerprint ?? diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 3bfb7303bd0..34596835294 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -31,6 +31,7 @@ describe('client UI RPC methods', () => { visibleTaskProviders: ['github', 'gitlab'], defaultRepoSelection: ['repo-1'], defaultLinearTeamSelection: ['team-1'], + experimentalStructuredNativeChat: true, compactWorktreeCards: true, minimaxGroupId: 'group-42', minimaxUsageModels: 'general,abab6.5', @@ -60,6 +61,24 @@ describe('client UI RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { settings } }) }) + it('rejects paired attempts to mutate the host-owned structured chat setting', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientSettings: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('settings.update', { experimentalStructuredNativeChat: true }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(runtime.updateClientSettings).not.toHaveBeenCalled() + }) + it('persists the runtime host task source settings for mobile Tasks', async () => { const settings = { defaultTuiAgent: null, diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 1bdaf224397..ba77b94803e 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -38,6 +38,7 @@ import { PLUGIN_METHODS } from './plugins' import { SKILL_METHODS } from './skills' import { CLIPBOARD_METHODS } from './clipboard' import { HOST_CAPABILITY_METHODS } from './host-capabilities' +import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities' import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' @@ -91,6 +92,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...SKILL_METHODS, ...CLIPBOARD_METHODS, ...HOST_CAPABILITY_METHODS, + ...RUNTIME_CLIENT_CAPABILITY_METHODS, ...CLIENT_EVENT_METHODS, ...CLIENT_UI_METHODS, ...EMULATOR_METHODS, diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts new file mode 100644 index 00000000000..c0fb2f250bd --- /dev/null +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities' + +function makeRequest(params: unknown): RpcRequest { + return { + id: 'req-1', + authToken: 'tok', + method: 'runtime.clientCapabilities.update', + params + } +} + +function dispatcher(): RpcDispatcher { + return new RpcDispatcher({ + runtime: { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService, + methods: RUNTIME_CLIENT_CAPABILITY_METHODS + }) +} + +describe('runtime.clientCapabilities.update', () => { + it('updates the authenticated socket capability set after auth', async () => { + const updateClientCapabilities = vi.fn() + + const response = await dispatcher().dispatch( + makeRequest({ + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }), + { clientKind: 'mobile', updateClientCapabilities } + ) + + expect(response).toMatchObject({ + ok: true, + result: { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } + }) + expect(updateClientCapabilities).toHaveBeenCalledWith([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + }) + + it('rejects malformed upgrades without mutating authenticated state', async () => { + const updateClientCapabilities = vi.fn() + + const response = await dispatcher().dispatch( + makeRequest({ + clientCapabilities: [42] + }), + { clientKind: 'mobile', updateClientCapabilities } + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(updateClientCapabilities).not.toHaveBeenCalled() + }) + + it('fails closed when a transport has no post-auth updater', async () => { + const response = await dispatcher().dispatch( + makeRequest({ + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }), + { clientKind: 'runtime' } + ) + + expect(response).toMatchObject({ + ok: false, + error: { message: 'client_capabilities_update_unsupported' } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts new file mode 100644 index 00000000000..a1ab53267b3 --- /dev/null +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import type { RuntimeCapability } from '../../../../shared/protocol-version' +import { defineMethod, type RpcAnyMethod } from '../core' + +const ClientCapabilitiesUpdate = z + .object({ + clientCapabilities: z.array(z.string().min(1).max(128)).max(64) + }) + .strict() + +export const RUNTIME_CLIENT_CAPABILITY_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'runtime.clientCapabilities.update', + params: ClientCapabilitiesUpdate, + handler: (params, { updateClientCapabilities }) => { + if (!updateClientCapabilities) { + throw new Error('client_capabilities_update_unsupported') + } + const clientCapabilities = params.clientCapabilities as RuntimeCapability[] + updateClientCapabilities(clientCapabilities) + return { clientCapabilities } + } + }) +] diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index 488ab69fd1e..226277f6ebd 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -75,6 +75,48 @@ describe('session tab structured capability mutations', () => { expect(fixture.calls[method.runtimeMethod]).not.toHaveBeenCalled() }) } + + it.each(['session.tabs.close', 'session.tabs.closeLifecycle'] as const)( + 'allows capable mobile clients to close structured tabs when the experiment is enabled (%s)', + async (method) => { + const snapshot = agentSnapshot() + const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), + listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), + closeMobileSessionTab + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + await dispatcher.dispatchStreaming( + { + id: 'request-1', + authToken: 'token', + method, + params: + method === 'session.tabs.close' + ? { worktree: 'id:wt-1', tabId: 'codex-session', reason: 'user' } + : { + worktree: 'id:wt-1', + tabId: 'codex-session', + reason: 'cleanup', + publicationEpoch: 'epoch-1', + terminal: 'pty-1' + } + }, + (response) => replies.push(response), + { + clientKind: 'mobile', + pairedDeviceId: 'paired-mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(JSON.parse(replies[0]!).ok).toBe(true) + expect(closeMobileSessionTab).toHaveBeenCalledOnce() + } + ) }) function createFixture(capabilities: RuntimeCapability[]) { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index e713f74f057..4a61a99bc20 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -96,6 +96,22 @@ describe('projectSessionTabAgentStatus', () => { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ]) ).toEqual(oldClient) + expect( + projectSessionTabAgentStatus( + snapshot, + 'mobile', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + false + ) + ).toEqual(oldClient) + + const capableMobile = projectSessionTabAgentStatus( + snapshot, + 'mobile', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ) + expect(capableMobile).toBe(snapshot) const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -133,6 +149,14 @@ describe('projectSessionTabAgentStatus', () => { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ]).tabs.map((tab) => tab.id) ).toEqual(['agent-session:codex']) + expect( + projectSessionTabAgentStatus( + snapshot, + 'mobile', + [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + true + ).tabs.map((tab) => tab.id) + ).toEqual(['agent-session:codex']) }) it('withholds session boundaries from legacy paired clients', () => { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index ac8cc0b2164..375b3b499d5 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,6 +1,5 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import type { @@ -9,18 +8,21 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' import type { TabGroupLayoutNode } from '../../../../shared/tab-types' +import { structuredNativeChatProjectionEnabled } from './structured-agent-session-policy' type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot export function projectSessionTabAgentStatus( payload: TPayload, clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: readonly RuntimeCapability[] | undefined + clientCapabilities: readonly RuntimeCapability[] | undefined, + structuredNativeChatEnabled?: boolean ): TPayload { - const structuredVisible = - clientKind !== 'mobile' && - (clientKind === undefined || - (clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false)) + const structuredVisible = structuredNativeChatProjectionEnabled({ + clientKind, + clientCapabilities, + structuredNativeChatEnabled + }) let projected = structuredVisible ? payload : projectAgentSessionTabsOut(payload, () => true) if (structuredVisible && clientKind !== undefined) { projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 50e56144f29..bd60ecd6ddf 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -4,6 +4,7 @@ import { defineMethod, type RpcAnyMethod } from '../core' import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection' import { projectSessionTabsForClient } from './session-tabs-inventory' +import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ defineMethod({ @@ -14,7 +15,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ const visible = projectSessionTabsForClient( await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), context.clientKind, - context.clientCapabilities + context.clientCapabilities, + context.clientKind === 'mobile' + ? isStructuredNativeChatEnabled(context.runtime) + : undefined ) assertProjectedSessionTabVisible(visible, params.tabId) } @@ -80,7 +84,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ const visible = projectSessionTabsForClient( await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), context.clientKind, - context.clientCapabilities + context.clientCapabilities, + context.clientKind === 'mobile' + ? isStructuredNativeChatEnabled(context.runtime) + : undefined ) assertProjectedSessionTabVisible(visible, params.tabId) } diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index 6b9e953e4e3..ba7c41000d0 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -6,6 +6,7 @@ import { translateProjectedSessionTabMove } from './session-tab-browser-placement-projection' import { projectSessionTabsForClient } from './session-tabs-inventory' +import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-tabs-schemas' export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ @@ -17,7 +18,8 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ const visible = projectSessionTabsForClient( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), clientKind, - clientCapabilities + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined ) assertProjectedSessionTabVisible(visible, params.tabId) } @@ -36,7 +38,12 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ }) } ) - return projectSessionTabsForMutationClient(result, clientKind, clientCapabilities) + return projectSessionTabsForMutationClient( + result, + clientKind, + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + ) } }), defineMethod({ @@ -46,7 +53,12 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ let translated: Parameters[2] = params if (clientKind) { const raw = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) - const projected = projectSessionTabsForClient(raw, clientKind, clientCapabilities) + const projected = projectSessionTabsForClient( + raw, + clientKind, + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + ) translated = translateProjectedSessionTabMove(raw, projected, params) } const base = { tabId: translated.tabId, targetGroupId: translated.targetGroupId } @@ -129,7 +141,8 @@ async function assertVisibleMutationTab( const visible = projectSessionTabsForClient( await runtime.listMobileSessionTabs(worktree, pairedDeviceId), clientKind, - clientCapabilities + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined ) assertProjectedSessionTabVisible(visible, tabId) } diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index fba9a460e86..5ab29ae51b5 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -4,6 +4,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime- import type { RpcContext } from '../core' import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection' +import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' type SessionTabsInventory = { snapshots: RuntimeMobileSessionTabsResult[] @@ -26,21 +27,38 @@ function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean { export function projectSessionTabsForClient( snapshot: RuntimeMobileSessionTabsResult, clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: Parameters[2] + clientCapabilities: Parameters[2], + structuredNativeChatEnabled?: boolean ): RuntimeMobileSessionTabsResult { return projectSessionTabBrowserPlacements( - projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), + projectSessionTabAgentStatus( + snapshot, + clientKind, + clientCapabilities, + structuredNativeChatEnabled + ), clientCapabilities ) } +function structuredNativeChatEnabledForContext(context: RpcContext): boolean | undefined { + return context.clientKind === 'mobile' + ? isStructuredNativeChatEnabled(context.runtime) + : undefined +} + function projectInventory( inventory: SessionTabsInventory, context: RpcContext ): SessionTabsInventory { return { snapshots: inventory.snapshots.map((snapshot) => - projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities) + projectSessionTabsForClient( + snapshot, + context.clientKind, + context.clientCapabilities, + structuredNativeChatEnabledForContext(context) + ) ), ...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context) ? { authoritative: true as const } @@ -109,7 +127,8 @@ export async function subscribeSessionTabsInventory( projectSessionTabsForClient( snapshot, context.clientKind, - context.clientCapabilities + context.clientCapabilities, + structuredNativeChatEnabledForContext(context) ) as SessionTabsChange const withoutNavigationIntent = (snapshot: SessionTabsChange): SessionTabsChange => { if (snapshot.navigationIntent === undefined) { diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index be61fc55edf..29131869fa0 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { SESSION_TAB_METHODS } from './session-tabs' function makeRequest(method: string, params?: unknown): RpcRequest { @@ -10,6 +13,48 @@ function makeRequest(method: string, params?: unknown): RpcRequest { } describe('session tab RPC methods', () => { + it('does not restore structured tabs for mobile while the host setting is off', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled() + }) + + it('restores structured tabs for mobile only after capability and setting are present', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })), + restoreStructuredAgentSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('session.tabs.list', { worktree: 'id:wt-1' }), + { + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + + expect(response.ok).toBe(true) + expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1) + }) + it('routes mobile-only activation without notifying desktop clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 3a322e33ed7..34d50a2a76b 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -15,6 +15,7 @@ import { import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods' import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' +import { isStructuredNativeChatEnabled } from './structured-agent-session-policy' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ @@ -22,11 +23,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ name: 'session.tabs.list', params: WorktreeTabSelector, handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { - await restoreStructuredTabsIfSupported(runtime, clientCapabilities) + await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) return projectSessionTabsForClient( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), clientKind, - clientCapabilities + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined ) } }), @@ -34,7 +36,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ name: 'session.tabs.listAll', params: null, handler: async (_params, context) => { - await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities) + await restoreStructuredTabsIfSupported(context) return listSessionTabsInventory(context) } }), @@ -89,7 +91,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ let unsubscribe = (): void => {} let closed = false let initialized = false - await restoreStructuredTabsIfSupported(runtime, clientCapabilities) + await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities }) const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) if (closed) { return @@ -115,7 +117,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ } emit({ type: 'snapshot', - ...projectSessionTabsForClient(initial, clientKind, clientCapabilities) + ...projectSessionTabsForClient( + initial, + clientKind, + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + ) }) initialized = true if (closed) { @@ -126,7 +133,12 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (snapshot.worktree === subscribedWorktree) { emit({ type: 'updated', - ...projectSessionTabsForClient(snapshot, clientKind, clientCapabilities) + ...projectSessionTabsForClient( + snapshot, + clientKind, + clientCapabilities, + clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined + ) }) } }, pairedDeviceId) @@ -157,7 +169,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ name: 'session.tabs.subscribeAll', params: null, handler: async (_params, context, emit) => { - await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities) + await restoreStructuredTabsIfSupported(context) return subscribeSessionTabsInventory(context, emit) } }), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 2dd317e08b0..33018c21f4d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -5,21 +5,18 @@ // handed a session it cannot render or drive — and, just as importantly, cannot make the host EXIST // by calling into it, which is an observable side effect. -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { RpcContext } from '../core' +import { supportsStructuredAgentSessions } from './structured-agent-session-policy' /** * In-process callers are the same build as the host, so they carry no negotiated * capability list; every remote client must say it can read structured sessions. */ export function supportsStructuredSessions(ctx: RpcContext): boolean { - return ( - ctx.clientKind === undefined || - (ctx.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) - ) + return supportsStructuredAgentSessions(ctx) } export function requireStructuredCapability(ctx: RpcContext): void { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts new file mode 100644 index 00000000000..4fe38474ec6 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -0,0 +1,47 @@ +import { + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' + +type StructuredPolicyContext = Pick & { + runtime?: Pick + structuredNativeChatEnabled?: boolean +} + +export function isStructuredNativeChatEnabled( + runtime: Pick +): boolean { + try { + return runtime.getClientSettings().experimentalStructuredNativeChat === true + } catch { + return false + } +} + +export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean { + if (context.clientKind === undefined) { + return true + } + const hasCapability = + context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true + if (!hasCapability) { + return false + } + if (context.clientKind !== 'mobile') { + return true + } + return ( + context.structuredNativeChatEnabled === true || + (context.runtime ? isStructuredNativeChatEnabled(context.runtime) : false) + ) +} + +export function structuredNativeChatProjectionEnabled(args: { + clientKind: 'mobile' | 'runtime' | undefined + clientCapabilities: readonly RuntimeCapability[] | undefined + structuredNativeChatEnabled?: boolean +}): boolean { + return supportsStructuredAgentSessions(args) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index c698cc7229c..b65e6eff825 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -111,7 +111,7 @@ function hostStub(): StructuredAgentSessionHost { return hostCalls as unknown as StructuredAgentSessionHost } -function dispatcher(): RpcDispatcher { +function dispatcher(runtimeOverrides: Record = {}): RpcDispatcher { runtimeCalls = { getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })), resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({ @@ -134,7 +134,8 @@ function dispatcher(): RpcDispatcher { registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn(), cleanupSubscriptionsByPrefix: vi.fn(), - ...runtimeCalls + ...runtimeCalls, + ...runtimeOverrides } return new RpcDispatcher({ runtime: runtime as unknown as OrcaRuntimeService, @@ -151,10 +152,11 @@ async function call( clientId?: string clientKind?: 'mobile' | 'runtime' clientCapabilities?: string[] - } + }, + runtimeOverrides: Record = {} ): Promise { const replies: RpcResponse[] = [] - await dispatcher().dispatchStreaming( + await dispatcher(runtimeOverrides).dispatchStreaming( request(method, params), (raw) => replies.push(JSON.parse(raw) as RpcResponse), client @@ -170,6 +172,10 @@ const STRUCTURED_CLIENT = { clientKind: 'runtime' as const, clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } +const STRUCTURED_MOBILE_CLIENT = { + clientKind: 'mobile' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} beforeEach(() => { setStructuredAgentSessionHost(hostStub()) @@ -186,6 +192,18 @@ describe('capability gating', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) expect(hostCalls.close).toHaveBeenCalledWith(SESSION) expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) + expect(hostCalls.setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( + hostCalls.close.mock.invocationCallOrder[0]! + ) + }) + + it('does not stop the provider when durable tab retirement fails', async () => { + hostCalls.setSessionTabVisibility.mockRejectedValueOnce(new Error('visibility write failed')) + + const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT) + + expect(response).toMatchObject({ ok: false }) + expect(hostCalls.close).not.toHaveBeenCalled() }) it('advertises the capability without bumping the protocol version', () => { @@ -250,6 +268,25 @@ describe('capability gating', () => { expect(hostCalls.send).toHaveBeenCalledTimes(1) }) + it('requires the host structured-chat setting for mobile clients', async () => { + const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, { + getClientSettings: () => ({ experimentalStructuredNativeChat: false }) + }) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(hostCalls.send).not.toHaveBeenCalled() + }) + + it('serves mobile clients only after capability and setting negotiation', async () => { + const response = await call('agentSession.send', sendParams(), STRUCTURED_MOBILE_CLIENT, { + getClientSettings: () => ({ experimentalStructuredNativeChat: true }) + }) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.send).toHaveBeenCalledTimes(1) + }) + it('serves an in-process caller, which negotiates no capabilities at all', async () => { const response = await call('agentSession.send', sendParams()) expect(response).toMatchObject({ ok: true }) @@ -292,6 +329,37 @@ describe('method routing', () => { ) }) + it('reports an unknown create outcome when attach commits before tab publication fails', async () => { + const worktree = 'id:workspace-1' + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'codex' } + }) + }), + worktree, + agent: 'codex' + } + + const response = await call('agentSession.create', params, STRUCTURED_CLIENT, { + publishStructuredAgentSessionTab: vi.fn(async () => { + throw new Error('publish failed') + }) + }) + + expect(hostCalls.attach).toHaveBeenCalledOnce() + expect(response).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + } + }) + }) + it('separates create from ensure by the fence the client may declare', async () => { const created = await call('agentSession.create', attachParams()) expect(created).toMatchObject({ ok: true }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 8ea85aa0e87..ffd23499a3e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -91,12 +91,23 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ envelope: { ...params.envelope, payloadFingerprint: hostFingerprint } }) if (result.ok && resolved.agent === 'codex') { - await ctx.runtime.publishStructuredAgentSessionTab({ - workspaceId: resolved.location.workspaceId, - sessionId: result.value.sessionId, - agent: 'codex', - activate: true - }) + try { + await ctx.runtime.publishStructuredAgentSessionTab({ + workspaceId: resolved.location.workspaceId, + sessionId: result.value.sessionId, + agent: 'codex', + activate: true + }) + } catch (error) { + console.warn('[agent-session] create committed before tab publication failed', error) + return { + ok: false, + refusal: { + code: 'agent_session_operation_unknown', + message: 'The Codex chat may have been created, but its tab could not be confirmed.' + } + } + } } return result } @@ -129,11 +140,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: OptionsParams, handler: async (params, ctx) => { const host = requireHost(ctx) - await host.close(params.sessionId) // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) } + await host.close(params.sessionId) return { ok: true as const } } }), diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts index c1f265cc4c8..4333713a445 100644 --- a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -1,11 +1,13 @@ -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RpcContext } from '../core' +import { supportsStructuredAgentSessions } from './structured-agent-session-policy' export async function restoreStructuredTabsIfSupported( - runtime: RpcContext['runtime'], - capabilities: readonly string[] | undefined + context: Pick ): Promise { - if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { - await runtime.restoreStructuredAgentSessionTabs() + if ( + supportsStructuredAgentSessions(context) && + typeof context.runtime.restoreStructuredAgentSessionTabs === 'function' + ) { + await context.runtime.restoreStructuredAgentSessionTabs() } } diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 75938d6573c..6eddcb748be 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -113,6 +113,7 @@ export class RpcStreamingDispatcher { pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, + updateClientCapabilities: options?.updateClientCapabilities, orchestrationCapability: request.orchestrationCapability, authenticatedCallerFingerprint: mutation?.identity.callerFingerprint ?? @@ -165,6 +166,7 @@ export class RpcStreamingDispatcher { pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, + updateClientCapabilities: options?.updateClientCapabilities, orchestrationCapability: request.orchestrationCapability, pairing: options?.pairing, sendBinary: options?.sendBinary, diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 41a251ce649..b9e6959c8da 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -32,6 +32,7 @@ export type RuntimeClientSettings = Pick< | 'defaultLinearTeamSelection' | 'githubProjects' | 'experimentalNewWorktreeCardStyle' + | 'experimentalStructuredNativeChat' | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' @@ -97,6 +98,7 @@ export class RuntimeClientSettingsController { defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, githubProjects: settings.githubProjects, experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, + experimentalStructuredNativeChat: settings.experimentalStructuredNativeChat === true, compactWorktreeCards: settings.compactWorktreeCards === true, minimaxGroupId: settings.minimaxGroupId ?? '', minimaxUsageModels: settings.minimaxUsageModels ?? 'general', diff --git a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts index 6df19517d64..4913b31bd7d 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts @@ -44,7 +44,8 @@ function queries( listResolved: async () => [], resolveRepo: async () => repo, selectRepos: () => [repo], - scanRepo: async () => ({ ok, worktrees: [...worktrees] }) + scanRepo: async () => ({ ok, worktrees: [...worktrees] }), + listKnownHostIds: () => [] }) } diff --git a/src/main/runtime/runtime-managed-worktree-queries.test.ts b/src/main/runtime/runtime-managed-worktree-queries.test.ts index 354b6653324..01df53cbd1d 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.test.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.test.ts @@ -46,7 +46,8 @@ function queries(store: RuntimeStore): RuntimeManagedWorktreeQueries { listResolved: async () => [], resolveRepo: async () => store.getRepos()[0]!, selectRepos: () => store.getRepos(), - scanRepo: async () => ({ ok: true, worktrees: [] }) + scanRepo: async () => ({ ok: true, worktrees: [] }), + listKnownHostIds: () => [] }) } diff --git a/src/main/runtime/runtime-managed-worktree-queries.ts b/src/main/runtime/runtime-managed-worktree-queries.ts index b0ed2bc4a3b..5a5812236af 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.ts @@ -1,7 +1,8 @@ import type { DetectedWorktreeListResult, Worktree } from '../../shared/worktree/types' import type { Repo } from '../../shared/repo-types' import type { RuntimeWorktreeListResult } from '../../shared/runtime-types' -import { getRepoExecutionHostId } from '../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { buildWorktreeListingPage } from './worktree-listing-host-scope' import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' import type { WorktreeMeta } from '../../shared/worktree/meta-types' @@ -38,6 +39,8 @@ type Dependencies = { resolveRepo(selector: string): Promise selectRepos(selector: string): Repo[] scanRepo(repo: Repo): Promise + /** Hosts this runtime has repos or workspaces on, so a host with no rows is still named. */ + listKnownHostIds(): Iterable } /** @@ -100,11 +103,9 @@ export class RuntimeManagedWorktreeQueries { (!repoId || worktree.repoId === repoId) && this.isVisible(worktree, matchers.get(worktree.repoId), sourceDefaultsSupported) ) - return { - worktrees: worktrees.slice(0, limit), - totalCount: worktrees.length, - truncated: worktrees.length > limit - } + // Why: a `--repo` listing was scoped by the caller, so naming every configured host as + // omitted would report a gap the caller deliberately excluded. + return buildWorktreeListingPage(worktrees, limit, repoId ? [] : this.deps.listKnownHostIds()) } resolveRepoForConnection(selector: string, connectionId?: string | null): Promise { diff --git a/src/main/runtime/runtime-metadata-ownership-watch.test.ts b/src/main/runtime/runtime-metadata-ownership-watch.test.ts index d8eb4196534..abaf95b6b4b 100644 --- a/src/main/runtime/runtime-metadata-ownership-watch.test.ts +++ b/src/main/runtime/runtime-metadata-ownership-watch.test.ts @@ -1,4 +1,6 @@ import { mkdtempSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -10,6 +12,82 @@ import { type RuntimeMetadataOwnershipWatch } from './runtime-metadata-ownership-watch' +// Counts blocking fs calls against orca-runtime.json so the poll tick's I/O stays off the main thread. +const metadataSyncCalls = vi.hoisted(() => { + const state = { recording: false, calls: [] as string[] } + return { + state, + record(fn: string, target: unknown): void { + if (state.recording && typeof target === 'string' && target.endsWith('orca-runtime.json')) { + state.calls.push(fn) + } + } + } +}) + +// Lets a test park the tick's async read so overlapping ticks are observable without wall clocks. +const metadataReadGate = vi.hoisted(() => { + const gate = { + hold: false, + reads: 0, + parked: [] as (() => void)[], + /** Reads handed to the real fs; parked ones are excluded so `whenIdle` stays answerable. */ + active: 0, + idle: [] as (() => void)[], + whenIdle(): Promise { + return gate.active === 0 + ? Promise.resolve() + : new Promise((resolve) => gate.idle.push(resolve)) + } + } + return gate +}) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + default: actual, + readFile: (async (target: unknown, options: never) => { + const call = (): unknown => + (actual.readFile as (...args: never[]) => unknown)(target as never, options) + if (typeof target !== 'string' || !target.endsWith('orca-runtime.json')) { + return call() + } + metadataReadGate.reads += 1 + if (metadataReadGate.hold) { + await new Promise((resolve) => metadataReadGate.parked.push(resolve)) + } + metadataReadGate.active += 1 + try { + return await call() + } finally { + metadataReadGate.active -= 1 + if (metadataReadGate.active === 0) { + for (const resolve of metadataReadGate.idle.splice(0)) { + resolve() + } + } + } + }) as typeof actual.readFile + } +}) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: (target: NodeFs.PathLike) => { + metadataSyncCalls.record('existsSync', target) + return actual.existsSync(target) + }, + readFileSync: ((target: never, options: never) => { + metadataSyncCalls.record('readFileSync', target) + return actual.readFileSync(target, options) + }) as typeof actual.readFileSync + } +}) + const OWNED_PID = 4242 const OWNED_RUNTIME_ID = 'rt_owner' const FOREIGN_LIVE_PID = 5151 @@ -81,6 +159,14 @@ describe('watchRuntimeMetadataOwnership', () => { const userDataPaths: string[] = [] afterEach(() => { + metadataSyncCalls.state.recording = false + metadataSyncCalls.state.calls.length = 0 + metadataReadGate.hold = false + metadataReadGate.reads = 0 + for (const resume of metadataReadGate.parked.splice(0)) { + resume() + } + metadataReadGate.idle.splice(0) for (const watch of watches.splice(0)) { watch.stop() } @@ -103,13 +189,32 @@ describe('watchRuntimeMetadataOwnership', () => { return watch } + function usePolledTimers(): void { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + } + + /** Waits out the tick's real read; everything after it resolves as microtasks. */ + async function settleReads(): Promise { + await new Promise((resolve) => setImmediate(resolve)) + await metadataReadGate.whenIdle() + await new Promise((resolve) => setImmediate(resolve)) + } + + /** Fires one interval at a time so each tick's async read settles before the next. */ + async function advancePolls(ms: number, stepMs = 1_000): Promise { + for (let elapsed = 0; elapsed < ms; elapsed += stepMs) { + await vi.advanceTimersByTimeAsync(stepMs) + await settleReads() + } + } + 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', () => { + it('republishes after a second instance clobbers the record and exits', async () => { const userDataPath = makeUserDataPath() writeRuntimeMetadata(userDataPath, record()) const watch = armWatch(userDataPath) @@ -118,7 +223,7 @@ describe('watchRuntimeMetadataOwnership', () => { userDataPath, record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' }) ) - watch.check() + await watch.check() expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID, @@ -126,28 +231,28 @@ describe('watchRuntimeMetadataOwnership', () => { }) }) - it('republishes a record that was deleted underneath the runtime', () => { + it('republishes a record that was deleted underneath the runtime', async () => { const userDataPath = makeUserDataPath() writeRuntimeMetadata(userDataPath, record()) const watch = armWatch(userDataPath) clearRuntimeMetadata(userDataPath) - watch.check() + await watch.check() expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID }) }) - it('replaces an unreadable record', () => { + it('replaces an unreadable record', async () => { const userDataPath = makeUserDataPath() const watch = armWatch(userDataPath) writeFileSync(getRuntimeMetadataPath(userDataPath), '{ truncated') - watch.check() + await watch.check() expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID }) }) - it('leaves a live sibling runtime in place', () => { + it('leaves a live sibling runtime in place', async () => { const userDataPath = makeUserDataPath() const watch = armWatch(userDataPath) writeRuntimeMetadata( @@ -155,13 +260,13 @@ describe('watchRuntimeMetadataOwnership', () => { record({ pid: FOREIGN_LIVE_PID, runtimeId: 'rt_second_instance' }) ) - watch.check() + await watch.check() expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: FOREIGN_LIVE_PID }) }) - it('reclaims on the poll interval without an explicit check', () => { - vi.useFakeTimers() + it('reclaims on the poll interval without an explicit check', async () => { + usePolledTimers() const userDataPath = makeUserDataPath() armWatch(userDataPath, 1_000) writeRuntimeMetadata( @@ -169,13 +274,13 @@ describe('watchRuntimeMetadataOwnership', () => { record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' }) ) - vi.advanceTimersByTime(1_000) + await advancePolls(1_000) expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID }) }) - it('stops reclaiming once the watch is stopped', () => { - vi.useFakeTimers() + it('stops reclaiming once the watch is stopped', async () => { + usePolledTimers() const userDataPath = makeUserDataPath() const watch = armWatch(userDataPath, 1_000) @@ -184,13 +289,59 @@ describe('watchRuntimeMetadataOwnership', () => { userDataPath, record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' }) ) - vi.advanceTimersByTime(5_000) + await advancePolls(5_000) expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: FOREIGN_DEAD_PID }) }) - it('keeps polling after a republish failure', () => { - vi.useFakeTimers() + it('reads the record off-thread, so the poll tick never blocks the main thread', async () => { + const userDataPath = makeUserDataPath() + writeRuntimeMetadata(userDataPath, record()) + const watch = armWatch(userDataPath) + + metadataSyncCalls.state.recording = true + await watch.check() + await watch.check() + metadataSyncCalls.state.recording = false + + expect(metadataSyncCalls.state.calls).toEqual([]) + }) + + it('treats a missing record as reclaimable without a pre-existence check', async () => { + const userDataPath = makeUserDataPath() + const watch = armWatch(userDataPath) + + metadataSyncCalls.state.recording = true + await watch.check() + metadataSyncCalls.state.recording = false + + expect(metadataSyncCalls.state.calls).toEqual([]) + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID }) + }) + + it('never runs two overlapping ownership checks', async () => { + usePolledTimers() + const userDataPath = makeUserDataPath() + writeRuntimeMetadata(userDataPath, record()) + armWatch(userDataPath, 1_000) + + metadataReadGate.hold = true + await advancePolls(3_000) + + expect(metadataReadGate.reads).toBe(1) + + metadataReadGate.hold = false + for (const resume of metadataReadGate.parked.splice(0)) { + resume() + } + await settleReads() + await advancePolls(1_000) + + expect(metadataReadGate.reads).toBe(2) + }) + + it('keeps polling after a republish failure', async () => { + usePolledTimers() const userDataPath = makeUserDataPath() const republish = vi .fn() @@ -209,7 +360,7 @@ describe('watchRuntimeMetadataOwnership', () => { }) watches.push(watch) - vi.advanceTimersByTime(2_000) + await advancePolls(2_000) expect(republish).toHaveBeenCalledTimes(2) expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID }) diff --git a/src/main/runtime/runtime-metadata-ownership-watch.ts b/src/main/runtime/runtime-metadata-ownership-watch.ts index ccdb8bf8f29..a8da6e9eb5a 100644 --- a/src/main/runtime/runtime-metadata-ownership-watch.ts +++ b/src/main/runtime/runtime-metadata-ownership-watch.ts @@ -1,5 +1,5 @@ import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' -import { readRuntimeMetadata } from './runtime-metadata' +import { readRuntimeMetadataAsync } from './runtime-metadata' /** * Why: `orca-runtime.json` is the CLI's only pointer at a live runtime, and it @@ -18,7 +18,7 @@ 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 + check: () => Promise stop: () => void } @@ -56,8 +56,9 @@ export function watchRuntimeMetadataOwnership( options: RuntimeMetadataOwnershipWatchOptions ): RuntimeMetadataOwnershipWatch { const isProcessRunning = options.isProcessRunning ?? isPidRunning - const check = (): void => { - const current = tryReadRuntimeMetadata(options.userDataPath) + let inFlight: Promise | null = null + const runCheck = async (): Promise => { + const current = await tryReadRuntimeMetadata(options.userDataPath) if ( !shouldReclaimRuntimeMetadata( current, @@ -77,8 +78,18 @@ export function watchRuntimeMetadataOwnership( } options.onReclaim?.(current) } + // Why: the read is off-thread now, so a slow volume could otherwise stack ticks on one file. + const check = (): Promise => { + inFlight ??= runCheck().finally(() => { + inFlight = null + }) + return inFlight + } - const timer = setInterval(check, options.pollIntervalMs ?? RUNTIME_METADATA_OWNERSHIP_POLL_MS) + const timer = setInterval( + () => void check(), + options.pollIntervalMs ?? RUNTIME_METADATA_OWNERSHIP_POLL_MS + ) // Why: discovery bookkeeping must never be the reason the process stays alive. timer.unref?.() return { @@ -87,9 +98,9 @@ export function watchRuntimeMetadataOwnership( } } -function tryReadRuntimeMetadata(userDataPath: string): RuntimeMetadata | null { +async function tryReadRuntimeMetadata(userDataPath: string): Promise { try { - return readRuntimeMetadata(userDataPath) + return await readRuntimeMetadataAsync(userDataPath) } catch (error) { // Why: an unparseable record is as useless to the CLI as a missing one, so treat it as reclaimable. console.warn( diff --git a/src/main/runtime/runtime-metadata.ts b/src/main/runtime/runtime-metadata.ts index bd43203252d..a4909d4723c 100644 --- a/src/main/runtime/runtime-metadata.ts +++ b/src/main/runtime/runtime-metadata.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync, rmSync } from 'node:fs' +import { readFile } from 'node:fs/promises' import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' import { writeSecureJsonFile } from '../../shared/secure-file' @@ -15,6 +16,22 @@ export function readRuntimeMetadata(userDataPath: string): RuntimeMetadata | nul return JSON.parse(readFileSync(metadataPath, 'utf-8')) as RuntimeMetadata } +/** Off-thread twin of {@link readRuntimeMetadata} for pollers; a missing file is not an error. */ +export async function readRuntimeMetadataAsync( + userDataPath: string +): Promise { + let raw: string + try { + raw = await readFile(getRuntimeMetadataPath(userDataPath), 'utf-8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + return JSON.parse(raw) as RuntimeMetadata +} + export function clearRuntimeMetadata(userDataPath: string): void { rmSync(getRuntimeMetadataPath(userDataPath), { force: true }) } diff --git a/src/main/runtime/runtime-registered-remote-worktree-removal.ts b/src/main/runtime/runtime-registered-remote-worktree-removal.ts index 09c7ac362f4..6eec18d52c8 100644 --- a/src/main/runtime/runtime-registered-remote-worktree-removal.ts +++ b/src/main/runtime/runtime-registered-remote-worktree-removal.ts @@ -13,6 +13,8 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: { removedPushTarget: GitPushTarget | undefined store: RuntimeStore provider: SshGitProvider + /** From the resolved removal route; `repo.connectionId!` answered null for an `ssh:`-only row. */ + connectionId: string force: boolean allowUnverifiedPtyStop: boolean deleteBranch: boolean @@ -28,8 +30,7 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: { ) => RemoveWorktreeResult finishRemoval: (result: RemoveWorktreeResult) => void }): Promise { - const { repo, target, registeredWorktree, provider } = args - const connectionId = repo.connectionId! + const { repo, target, registeredWorktree, provider, connectionId } = args const removeOptions = !args.deleteBranch ? { deleteBranch: args.deleteBranch } : {} const gate = await args.acquireWatcherRemoval(registeredWorktree.path, connectionId) let rawResult: RemoveWorktreeResult | undefined diff --git a/src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts b/src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts index ac213cae535..4735601c6a9 100644 --- a/src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts +++ b/src/main/runtime/runtime-rpc-metadata-lifecycle.test.ts @@ -7,6 +7,7 @@ import * as runtimeMetadataModule from './runtime-metadata' import { readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' import type { DeviceRegistry } from './device-registry' +import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' vi.mock('../git/worktree', () => { const worktrees = [ @@ -61,7 +62,7 @@ describe('OrcaRuntimeRpcServer', () => { authToken: 'second-instance-token', startedAt: 1 }) - server.checkRuntimeMetadataOwnership() + await server.checkRuntimeMetadataOwnership() expect(readRuntimeMetadata(userDataPath)).toEqual(published) @@ -86,7 +87,7 @@ describe('OrcaRuntimeRpcServer', () => { authToken: 'sibling-token', startedAt: 1 }) - server.checkRuntimeMetadataOwnership() + await server.checkRuntimeMetadataOwnership() expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_live_sibling' }) @@ -112,13 +113,46 @@ describe('OrcaRuntimeRpcServer', () => { authToken: 'second-instance-token', startedAt: 1 }) - server.checkRuntimeMetadataOwnership() + await server.checkRuntimeMetadataOwnership() expect(watchStop).toHaveBeenCalledTimes(1) expect(server['metadataOwnershipWatch']).toBeNull() expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_second_instance' }) }) + it('drops a republish from an ownership read that lands after the server stopped', async () => { + // Why: the read is off-thread now, so a tick can outlive stop(); the cleared + // activeTransports guard — not the interval teardown — is what stops it republishing. + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, pid: 1001 }) + await server.start() + + let releaseRead: (record: RuntimeMetadata | null) => void = () => {} + vi.spyOn(runtimeMetadataModule, 'readRuntimeMetadataAsync').mockImplementationOnce( + () => + new Promise((resolve) => { + releaseRead = resolve + }) + ) + const heldCheck = server.checkRuntimeMetadataOwnership() + + await server.stop() + writeRuntimeMetadata(userDataPath, { + runtimeId: 'rt_second_instance', + pid: 99999999, + transports: [], + authToken: 'second-instance-token', + startedAt: 1 + }) + // A missing record is the most reclaimable verdict there is, so an unguarded + // resume would rewrite the file the second instance just published. + releaseRead(null) + await heldCheck + + expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_second_instance' }) + }) + it('flushes a lastSeen refresh scheduled while transports stop', async () => { const server = new OrcaRuntimeRpcServer({ runtime: new OrcaRuntimeService(), diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index 57f5a61af2f..767ca885234 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -188,6 +188,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'repo.searchRefs', 'repo.sparsePresets', 'repo.update', + 'runtime.clientCapabilities.update', 'runtime.clientEvents.subscribe', 'runtime.clientEvents.unsubscribe', 'session.tabs.activate', @@ -201,6 +202,22 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'session.tabs.subscribeAll', 'session.tabs.unsubscribe', 'session.tabs.unsubscribeAll', + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.ensure', + 'agentSession.send', + 'agentSession.cancel', + 'agentSession.close', + 'agentSession.respondToApproval', + 'agentSession.respondToQuestion', + 'agentSession.setOption', + 'agentSession.handoffStatus', + 'agentSession.options', + 'agentSession.history', + 'agentSession.subscribe', + 'agentSession.unsubscribe', + 'agentSession.hold', + 'agentSession.release', 'nativeChat.readSession', 'nativeChat.subscribe', 'nativeChat.unsubscribe', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts index d6edaf78928..f79834fbcc2 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts @@ -2,8 +2,8 @@ import { RuntimeRpcMobilePairing } from './runtime-rpc-mobile-pairing' export class RuntimeRpcShutdown extends RuntimeRpcMobilePairing { /** Why: test-only seam — runs one ownership check instead of waiting out the poll interval. */ - checkRuntimeMetadataOwnership(): void { - this.metadataOwnershipWatch?.check() + checkRuntimeMetadataOwnership(): Promise { + return this.metadataOwnershipWatch?.check() ?? Promise.resolve() } async stop(): Promise { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index 86732e7430c..dd714b77528 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -141,6 +141,12 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. clientKind: device.scope, clientCapabilities: authenticatedSocket?.clientCapabilities, + updateClientCapabilities: + authenticatedSocket && device.scope === 'mobile' + ? (clientCapabilities) => { + authenticatedSocket.clientCapabilities = clientCapabilities + } + : undefined, pairing: pairingContext, signal: abortRegistration?.signal, sendBinary, diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 649a8ac49b7..6b9858bda0c 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -87,6 +87,7 @@ export type RuntimeStore = { terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] + experimentalStructuredNativeChat?: GlobalSettings['experimentalStructuredNativeChat'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] visibleTaskProviders?: GlobalSettings['visibleTaskProviders'] @@ -122,4 +123,5 @@ export type RuntimeStore = { updates: Partial, options?: { notifyListeners?: boolean; originWebContentsId?: number } ) => unknown + onSettingsChanged?: Store['onSettingsChanged'] } diff --git a/src/main/runtime/runtime-unregistered-worktree-removal.ts b/src/main/runtime/runtime-unregistered-worktree-removal.ts index 3d21998ef55..01de8820c1c 100644 --- a/src/main/runtime/runtime-unregistered-worktree-removal.ts +++ b/src/main/runtime/runtime-unregistered-worktree-removal.ts @@ -2,8 +2,10 @@ import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types import type { Repo } from '../../shared/repo-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import type { IFilesystemProvider } from '../providers/types' -import type { SshGitProvider } from '../providers/ssh-git-provider' +import { + getWorktreeRemovalConnectionId, + type WorktreeRemovalRoute +} from '../worktree-removal-execution-host-route' import { getLocalWorktreePathAccess, removeLocalWorktreePath, @@ -39,8 +41,8 @@ export async function removeRuntimeUnregisteredWorktree(args: { removedPushTarget: GitPushTarget | undefined force: boolean allowUnverifiedPtyStop: boolean - provider: SshGitProvider | null - fsProvider: IFilesystemProvider | null + /** One resolved host for the whole removal, replacing the `provider` whose `null` also meant local. */ + route: WorktreeRemovalRoute localOptions: LocalProjectWorktreeGitOptions store: RuntimeStore acquireWatcherRemoval: (path: string, connectionId?: string) => Promise @@ -48,21 +50,23 @@ export async function removeRuntimeUnregisteredWorktree(args: { deleteHistory: () => Promise finishRemoval: () => void }): Promise<{}> { - const { repo, target, registeredWorktrees, removedMeta } = args + const { repo, target, registeredWorktrees, removedMeta, route } = args let canCleanOrphanedDirectory = false if (canCleanupUnregisteredOrcaWorktreeDirectory({ meta: removedMeta })) { - if (repo.connectionId) { - if (!args.fsProvider) { + if (route.kind === 'ssh') { + const fsProvider = route.fsProvider + if (!fsProvider) { throw new Error('SSH filesystem provider unavailable') } - if (!args.fsProvider.lstat) { + const lstat = fsProvider.lstat + if (!lstat) { throw new Error('SSH filesystem provider lstat unavailable') } canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( target.path, repo.path, - (path) => args.fsProvider!.lstat!(path), - (path) => args.fsProvider!.readFile(path) + (path) => lstat(path), + (path) => fsProvider.readFile(path) ) } else { const access = getLocalWorktreePathAccess(args.localOptions) @@ -85,7 +89,7 @@ export async function removeRuntimeUnregisteredWorktree(args: { args.finishRemoval() return {} } - if (!repo.connectionId) { + if (route.kind === 'local') { const access = getLocalWorktreePathAccess(args.localOptions) const runtimeWorktreePath = toLocalWorktreeRuntimePath(target.path, args.localOptions) if ( @@ -108,7 +112,7 @@ export async function removeRuntimeUnregisteredWorktree(args: { return {} } } - if (await isRuntimeWorktreePathMissing(repo, target.path, args.localOptions)) { + if (await isRuntimeWorktreePathMissing(route.hostId, target.path, args.localOptions)) { if (!args.force && !removedMeta) { throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) } @@ -123,14 +127,19 @@ export async function removeRuntimeUnregisteredWorktree(args: { async function deleteUnregisteredDirectory( args: Parameters[0] ): Promise { - const connectionId = args.repo.connectionId?.trim() || undefined + const route = args.route + const connectionId = getWorktreeRemovalConnectionId(route) const gate = await args.acquireWatcherRemoval(args.target.path, connectionId) let completed = false try { await args.stopPtys(args.target.id, connectionId, args.allowUnverifiedPtyStop) - await (connectionId - ? args.fsProvider!.deletePath(args.target.path, true) - : removeLocalWorktreePath(args.target.path, args.localOptions)) + if (route.kind === 'local') { + await removeLocalWorktreePath(args.target.path, args.localOptions) + } else if (route.fsProvider) { + await route.fsProvider.deletePath(args.target.path, true) + } else { + throw new Error('SSH filesystem provider unavailable') + } completed = true } finally { await gate.finish(completed) @@ -142,9 +151,9 @@ async function deleteUnregisteredDirectory( async function cleanupPushTarget( args: Parameters[0] ): Promise { - await (args.repo.connectionId + await (args.route.kind === 'ssh' ? cleanupUnusedWorktreePushTargetRemoteSsh( - args.provider!, + args.route.provider, args.repo.path, args.target.id, args.removedPushTarget, diff --git a/src/main/runtime/runtime-worktree-filesystem.ts b/src/main/runtime/runtime-worktree-filesystem.ts index c2c5ac711c5..4f29919ca95 100644 --- a/src/main/runtime/runtime-worktree-filesystem.ts +++ b/src/main/runtime/runtime-worktree-filesystem.ts @@ -9,9 +9,12 @@ import { getLocalWorktreePathAccess, toLocalWorktreeRuntimePath } from '../local-worktree-filesystem' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + ExecutionHostNotDispatchableError, + resolveFilesystemRouteForHost +} from '../providers/execution-host-provider-dispatch' import { isWorktreePathMissing } from '../worktree-removal-safety' -import { getRepoExecutionHostId } from '../../shared/execution-host' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { @@ -22,19 +25,26 @@ import { import type { RuntimeStore } from './runtime-store-contract' import { gitStatusErrorMeansNotRepository } from './runtime-worktree-selection' +// Takes the resolved host rather than the repo: reading `repo.connectionId` answered "stat this on +// the client" for a row that names its owner only as `executionHostId: 'ssh:'`, which is +// the evidence a forced removal prunes registrations on. export async function isRuntimeWorktreePathMissing( - repo: Repo, + hostId: ExecutionHostId, worktreePath: string, localWorktreeGitOptions: { wslDistro?: string } = {} ): Promise { - if (!repo.connectionId) { + const route = resolveFilesystemRouteForHost(hostId) + if (route.kind === 'runtime') { + throw new ExecutionHostNotDispatchableError(route.hostId) + } + if (route.kind === 'local') { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) return isWorktreePathMissing( toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), access.statPath ) } - const fsProvider = getSshFilesystemProvider(repo.connectionId) + const fsProvider = route.provider return fsProvider ? isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) : false } diff --git a/src/main/runtime/terminal-leaf-tab-resolution.test.ts b/src/main/runtime/terminal-leaf-tab-resolution.test.ts new file mode 100644 index 00000000000..2fe8a6d0344 --- /dev/null +++ b/src/main/runtime/terminal-leaf-tab-resolution.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { findTerminalTabIdForLeaf } from './workspace-session-terminal-membership-authority' + +function layout(...leafIds: string[]): TerminalLayoutSnapshot { + let root = { type: 'leaf' as const, leafId: leafIds[0] } + for (const leafId of leafIds.slice(1)) { + root = { + type: 'split', + direction: 'row', + first: root, + second: { type: 'leaf' as const, leafId } + } as never + } + return { root, activeLeafId: leafIds[0], ptyIdsByLeafId: {} } as TerminalLayoutSnapshot +} + +function session(layouts: Record): WorkspaceSessionState { + return { terminalLayoutsByTabId: layouts } as WorkspaceSessionState +} + +describe('findTerminalTabIdForLeaf', () => { + it('resolves every leaf of a split tree to its tab', () => { + const state = session({ + 'tab-a': layout('leaf-1', 'leaf-2', 'leaf-3'), + 'tab-b': layout('leaf-4') + }) + expect(findTerminalTabIdForLeaf(state, 'leaf-2')).toBe('tab-a') + expect(findTerminalTabIdForLeaf(state, 'leaf-3')).toBe('tab-a') + expect(findTerminalTabIdForLeaf(state, 'leaf-4')).toBe('tab-b') + }) + + it('keeps the first tab in record order when two layouts claim one leaf', () => { + const layouts = { 'tab-a': layout('shared'), 'tab-b': layout('shared') } + expect(findTerminalTabIdForLeaf(session(layouts), 'shared')).toBe('tab-a') + }) + + it('answers misses, empty sessions and empty layouts with undefined', () => { + expect(findTerminalTabIdForLeaf(undefined, 'leaf-1')).toBeUndefined() + expect(findTerminalTabIdForLeaf(session({}), 'leaf-1')).toBeUndefined() + expect(findTerminalTabIdForLeaf(session({ 'tab-a': layout('leaf-1') }), 'nope')).toBeUndefined() + }) + + // The guard a leafId -> tabId cache needed and this scan does not: membership is read from the + // tree that is there NOW. `persistPtyBinding` grafts leaves by assigning into a layout already in + // the record, so anything memoized across calls has to be revalidated against every mutation + // shape a writer can produce - including one that leaves the root node's identity untouched. + it('reflects a subtree replaced in place after an earlier read', () => { + const tracked = layout('leaf-1', 'leaf-2') + const state = session({ 'tab-a': tracked }) + expect(findTerminalTabIdForLeaf(state, 'leaf-2')).toBe('tab-a') + expect(findTerminalTabIdForLeaf(state, 'leaf-9')).toBeUndefined() + + const root = tracked.root as { second: unknown } + root.second = { type: 'leaf', leafId: 'leaf-9' } + + expect(findTerminalTabIdForLeaf(state, 'leaf-9')).toBe('tab-a') + expect(findTerminalTabIdForLeaf(state, 'leaf-2')).toBeUndefined() + }) +}) diff --git a/src/main/runtime/terminal-tail-buffer.test.ts b/src/main/runtime/terminal-tail-buffer.test.ts new file mode 100644 index 00000000000..9851bd7fdc6 --- /dev/null +++ b/src/main/runtime/terminal-tail-buffer.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { MAX_TAIL_LINES } from './terminal-tail-limits' +import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' + +// Guards the per-chunk prefix work in appendNormalizedToTailBuffer: the retained char total is +// carried across appends and the redraw prefix is not re-scanned, so a saturated tail must not be +// walked once per chunk. Correctness is pinned by the cold/warm differential below — a "cold" run +// hands every append a fresh array so the memo always misses and every total is summed in full. + +type TailSim = { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null +} + +function newSim(): TailSim { + return { lines: [], partialLine: '', redrawCursor: null } +} + +type Step = ReturnType + +function feed(sim: TailSim, chunk: string, cold: boolean): Step { + const next = appendNormalizedToTailBuffer( + cold ? [...sim.lines] : sim.lines, + sim.partialLine, + chunk, + sim.redrawCursor + ) + sim.lines = next.lines + sim.partialLine = next.partialLine + sim.redrawCursor = next.redrawCursor + return next +} + +function mulberry32(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const ESC = String.fromCharCode(27) + +/** + * `short` fills the 2000-line cap and mixes in TUI redraws; `long` streams lines wide enough to + * hit the 256 KiB character cap first. Both eviction paths adjust the carried character total, so + * both need differential coverage, and a redraw's row truncation would keep `long` off its cap. + */ +function randomChunk(random: () => number, profile: 'short' | 'long'): string { + const roll = random() + if (profile === 'long') { + if (roll < 0.7) { + return `${'w'.repeat(1000 + Math.floor(random() * 3000))}\n` + } + if (roll < 0.8) { + return `\rspinner ${Math.floor(random() * 100)}%` + } + if (roll < 0.9) { + return 'trailing spaces here \n' + } + return roll < 0.95 ? '' : `no newline ${Math.floor(random() * 1000)}` + } + if (roll < 0.22) { + const lines: string[] = [] + for (let index = 0; index < 30; index += 1) { + lines.push(`burst ${Math.floor(random() * 1e6)}${random() < 0.3 ? ' ' : ''}`) + } + return `${lines.join('\n')}\n` + } + if (roll < 0.42) { + return `plain output ${Math.floor(random() * 1e6)}\n` + } + if (roll < 0.56) { + return `${' '.repeat(Math.floor(random() * 3))}\n` + } + if (roll < 0.68) { + const rows = 1 + Math.floor(random() * 12) + return `${ESC}[${rows}A${ESC}[2Kredrawn ${Math.floor(random() * 1000)}\n` + } + if (roll < 0.8) { + return `\rspinner ${Math.floor(random() * 100)}%` + } + if (roll < 0.86) { + return 'trailing spaces here \n' + } + if (roll < 0.92) { + return `multi\nline\nchunk ${Math.floor(random() * 1000)}\n` + } + if (roll < 0.96) { + return '' + } + return `no newline ${Math.floor(random() * 1000)}` +} + +describe('retained tail buffer prefix reuse', () => { + for (const profile of ['short', 'long'] as const) { + for (const seed of [3, 11, 91, 2024]) { + it(`carries the retained char total exactly (${profile}, seed ${seed})`, () => { + const random = mulberry32(seed) + const warm = newSim() + const cold = newSim() + let sawCap = false + for (let step = 0; step < 1400; step += 1) { + const chunk = randomChunk(random, profile) + const lineCountBefore = warm.lines.length + const warmStep = feed(warm, chunk, false) + const coldStep = feed(cold, chunk, true) + expect(warmStep.lines, `step ${step} lines`).toEqual(coldStep.lines) + expect(warmStep.partialLine, `step ${step} partial`).toBe(coldStep.partialLine) + expect(warmStep.truncated, `step ${step} truncated`).toBe(coldStep.truncated) + expect(warmStep.redrawCursor, `step ${step} cursor`).toEqual(coldStep.redrawCursor) + expect(warmStep.newCompleteLines, `step ${step} newCompleteLines`).toBe( + coldStep.newCompleteLines + ) + expect(warmStep.newlyCompletedLines, `step ${step} newlyCompletedLines`).toEqual( + coldStep.newlyCompletedLines + ) + sawCap = + sawCap || + (profile === 'short' + ? warm.lines.length >= MAX_TAIL_LINES + : // Lines dropped below the line cap on an append-only chunk == character-cap eviction. + !chunk.includes(ESC) && + warm.lines.length < MAX_TAIL_LINES && + lineCountBefore + warmStep.newlyCompletedLines.length > warm.lines.length) + } + // Guard against a vacuous pass: the profile's eviction path must have run. + expect(sawCap).toBe(true) + }) + } + } + + it('does not walk the untouched redraw prefix on every chunk', () => { + const sim = newSim() + for (let index = 0; index < MAX_TAIL_LINES + 200; index += 1) { + feed(sim, `streaming build output line ${index}\n`, false) + } + expect(sim.lines.length).toBe(MAX_TAIL_LINES) + + const redrawChunk = `${ESC}[3A${ESC}[2Krewritten row${ESC}[2B\n` + const spy = vi.spyOn(String.prototype, 'charCodeAt') + let prefixTouches = 0 + try { + feed(sim, redrawChunk, false) + prefixTouches = spy.mock.calls.length + } finally { + spy.mockRestore() + } + // Before this change the prefix trailing-space scan alone cost one charCodeAt per retained + // row (~1990); the chunk itself accounts for well under a hundred. + expect(prefixTouches).toBeLessThan(300) + }) +}) diff --git a/src/main/runtime/terminal-tail-buffer.ts b/src/main/runtime/terminal-tail-buffer.ts index 0cf551e92c6..141b14da6ec 100644 --- a/src/main/runtime/terminal-tail-buffer.ts +++ b/src/main/runtime/terminal-tail-buffer.ts @@ -1,4 +1,5 @@ import { containsTerminalVerticalLineControl } from './terminal-ansi-normalization' +import { carryTerminalTailSentinelMatches } from './terminal-tail-sentinel-index' import { applyTerminalLineControls, processTerminalTailCompleteSegments, @@ -11,6 +12,122 @@ import { type RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' +type RetainedTailLineStats = { + totalChars: number + /** Whether every line is already right-trimmed, so the redraw prefix trim is a no-op. */ + rightTrimmed: boolean +} + +// Why weak + array-keyed: the tail is replaced (never mutated) on every append, so an entry dies +// with the array it describes and only the live tail per PTY is retained. Carrying the char total +// this way replaces a full-tail re-sum on every chunk. +const tailLineStatsByLines = new WeakMap() + +function getRetainedTailLineStats(lines: readonly string[]): RetainedTailLineStats { + const cached = tailLineStatsByLines.get(lines) + if (cached) { + return cached + } + let totalChars = 0 + let rightTrimmed = true + for (const line of lines) { + totalChars += line.length + if (rightTrimmed && trimTerminalLineRight(line) !== line) { + rightTrimmed = false + } + } + const stats = { totalChars, rightTrimmed } + tailLineStatsByLines.set(lines, stats) + return stats +} + +type CarriedTailBuild = { + lines: string[] + /** Whether a retention cap dropped a row. */ + truncated: boolean +} + +/** + * The only way to produce a next tail array: `previousLines[keepStart, keepEnd) ++ appended`, + * capped by `MAX_TAIL_LINES` and — when `charCapPartialChars` is non-null — `MAX_TAIL_CHARS`. + * + * Why a constructor rather than three call sites doing their own arithmetic: the carried-match + * window handed to the sentinel index, the character total, and the array itself are all derived + * here from the same keep bounds, including whatever the caps drop, so they cannot disagree. The + * one thing a caller still has to get right is that every row in `appended` is already + * right-trimmed, which every producer of retained rows does. + */ +function buildCarriedTailLines( + previousLines: string[], + keepStart: number, + keepEnd: number, + appended: readonly string[], + charCapPartialChars: number | null +): CarriedTailBuild { + const keptCount = keepEnd > keepStart ? keepEnd - keepStart : 0 + let totalChars = 0 + let carriedRightTrimmed = true + if (keptCount > 0) { + const previousStats = getRetainedTailLineStats(previousLines) + totalChars = previousStats.totalChars + carriedRightTrimmed = previousStats.rightTrimmed + for (let index = 0; index < keepStart; index += 1) { + totalChars -= previousLines[index]!.length + } + for (let index = keepEnd; index < previousLines.length; index += 1) { + totalChars -= previousLines[index]!.length + } + } + for (const line of appended) { + totalChars += line.length + } + + // Both caps only ever drop from the front, so resolve them against the virtual concatenation + // before the array exists; the surviving keep bounds then define the carried window exactly. + const combinedLength = keptCount + appended.length + let dropCount = combinedLength > MAX_TAIL_LINES ? combinedLength - MAX_TAIL_LINES : 0 + for (let index = 0; index < dropCount; index += 1) { + totalChars -= ( + index < keptCount ? previousLines[keepStart + index]! : appended[index - keptCount]! + ).length + } + if (charCapPartialChars !== null) { + const charBudget = MAX_TAIL_CHARS - charCapPartialChars + while (dropCount < combinedLength && totalChars > charBudget) { + totalChars -= ( + dropCount < keptCount + ? previousLines[keepStart + dropCount]! + : appended[dropCount - keptCount]! + ).length + dropCount += 1 + } + } + + if ( + dropCount === 0 && + appended.length === 0 && + keepStart === 0 && + keepEnd === previousLines.length + ) { + return { lines: previousLines, truncated: false } + } + + const droppedFromCarried = dropCount < keptCount ? dropCount : keptCount + const carriedSourceStart = keepStart + droppedFromCarried + const carriedCount = keptCount - droppedFromCarried + const lines = previousLines.slice(carriedSourceStart, keepEnd) + for (let index = dropCount - droppedFromCarried; index < appended.length; index += 1) { + lines.push(appended[index]!) + } + + tailLineStatsByLines.set(lines, { + totalChars, + rightTrimmed: carriedCount === 0 || carriedRightTrimmed + }) + carryTerminalTailSentinelMatches(previousLines, lines, carriedSourceStart, carriedCount) + return { lines, truncated: dropCount > 0 } +} + export function appendNormalizedToTailBuffer( previousLines: string[], previousPartialLine: string, @@ -52,42 +169,35 @@ export function appendNormalizedToTailBuffer( // Why: status UIs redraw one line via CR/backspace/erase; retain the latest redraw segment instead of appending every spinner frame. const segments = splitRetainedTerminalTailSegments(combinedChunk) const pieces = processTerminalTailCompleteSegments(segments.completeSegments) - const newlyCompletedLines = pieces.map((line) => trimTerminalLineRight(line)) + const newlyCompletedLines: string[] = [] + for (const piece of pieces) { + newlyCompletedLines.push(trimTerminalLineRight(piece)) + } const partialResult = applyTerminalLineControls(segments.partialSegment) const nextPartialLine = trimTerminalLineRight(partialResult.text) const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) const newCompleteLines = segments.completeLineCount const omittedNewCompleteLines = newCompleteLines - pieces.length - let nextLines = - newCompleteLines > 0 - ? [...(omittedNewCompleteLines > 0 ? [] : previousLines), ...newlyCompletedLines] - : previousLines - let truncated = + + // The plain path only ever appends, so the whole previous tail carries unless it was discarded. + const carriesPreviousLines = newCompleteLines === 0 || omittedNewCompleteLines === 0 + const built = buildCarriedTailLines( + previousLines, + carriesPreviousLines ? 0 : previousLines.length, + previousLines.length, + newlyCompletedLines, + // Why gated: a chunk that neither completes a line nor grows the partial cannot breach the + // character cap, and re-checking it would evict on a tail that has not changed size. + newCompleteLines > 0 || retainedPartialLine.length > previousPartialLine.length + ? retainedPartialLine.length + : null + ) + const nextLines = built.lines + const truncated = previousPartialWasCapped || omittedNewCompleteLines > 0 || - nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS - - if (nextLines.length > MAX_TAIL_LINES) { - nextLines = nextLines.slice(nextLines.length - MAX_TAIL_LINES) - truncated = true - } - - if (newCompleteLines > 0 || retainedPartialLine.length > previousPartialLine.length) { - if (nextLines === previousLines) { - nextLines = [...previousLines] - } - let totalChars = - nextLines.reduce((sum, line) => sum + line.length, 0) + retainedPartialLine.length - let trimStartIndex = 0 - while (trimStartIndex < nextLines.length && totalChars > MAX_TAIL_CHARS) { - totalChars -= nextLines[trimStartIndex].length - trimStartIndex += 1 - } - if (trimStartIndex > 0) { - nextLines = nextLines.slice(trimStartIndex) - truncated = true - } - } + nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS || + built.truncated const redrawCursor = !partialResult.hadControl || partialResult.cursorColumn === nextPartialLine.length @@ -145,13 +255,22 @@ function appendNormalizedToMultilineTailBuffer( const windowRows = maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS if (windowRows >= previousLines.length) { - return appendNormalizedToMultilineTailBufferUnwindowed( + const unwindowed = appendNormalizedToMultilineTailBufferUnwindowed( previousLines, boundedPreviousPartialLine, normalizedChunk, previousPartialWasCapped, previousRedrawCursor ) + if (unwindowed.lines === previousLines) { + return unwindowed + } + // Why nothing carries: an unwindowed redraw may rewrite any retained row. Both caps were + // already applied inside the unwindowed builder, so the constructor only registers here. + return { + ...unwindowed, + lines: buildCarriedTailLines(previousLines, 0, 0, unwindowed.lines, null).lines + } } const prefixLength = previousLines.length - windowRows const suffix = previousLines.slice(prefixLength) @@ -162,41 +281,39 @@ function appendNormalizedToMultilineTailBuffer( previousPartialWasCapped, previousRedrawCursor ) - let lines = previousLines.slice(0, prefixLength) - // Why: the shared prefix must match the unwindowed finalize's trailing-space trim without paying a regex per untouched row. - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]! - const lastChar = line.charCodeAt(line.length - 1) - if (lastChar === 32 || lastChar === 9) { - lines[index] = line.replace(/[ \t]+$/g, '') + // The window provably cannot reach the prefix, so it carries unchanged — unless the tail + // entered un-right-trimmed, in which case the prefix has to be rewritten to match the + // unwindowed finalize's trailing-space trim and is therefore no longer the previous tail's rows. + const previousStats = getRetainedTailLineStats(previousLines) + let keepEnd = prefixLength + let appended: readonly string[] = windowed.lines + if (!previousStats.rightTrimmed) { + const rewritten = previousLines.slice(0, prefixLength) + for (let index = 0; index < rewritten.length; index += 1) { + const line = rewritten[index]! + const lastChar = line.charCodeAt(line.length - 1) + if (lastChar === 32 || lastChar === 9) { + rewritten[index] = line.replace(/[ \t]+$/g, '') + } } + for (const line of windowed.lines) { + rewritten.push(line) + } + keepEnd = 0 + appended = rewritten } - for (const line of windowed.lines) { - lines.push(line) - } - let truncated = windowed.truncated - if (lines.length > MAX_TAIL_LINES) { - lines = lines.slice(lines.length - MAX_TAIL_LINES) - truncated = true - } - let totalChars = windowed.partialLine.length - for (const line of lines) { - totalChars += line.length - } - let dropCount = 0 - while (dropCount < lines.length && totalChars > MAX_TAIL_CHARS) { - totalChars -= lines[dropCount]!.length - dropCount += 1 - } - if (dropCount > 0) { - lines = lines.slice(dropCount) - truncated = true - } + const built = buildCarriedTailLines( + previousLines, + 0, + keepEnd, + appended, + windowed.partialLine.length + ) return { - lines, + lines: built.lines, partialLine: windowed.partialLine, redrawCursor: windowed.redrawCursor, - truncated, + truncated: windowed.truncated || built.truncated, newCompleteLines: windowed.newCompleteLines, newlyCompletedLines: windowed.newlyCompletedLines } diff --git a/src/main/runtime/terminal-tail-sentinel-index.test.ts b/src/main/runtime/terminal-tail-sentinel-index.test.ts new file mode 100644 index 00000000000..2b33b2770ea --- /dev/null +++ b/src/main/runtime/terminal-tail-sentinel-index.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it, vi } from 'vitest' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { MAX_TAIL_CHARS, MAX_TAIL_LINES } from './terminal-tail-limits' +import { buildPreview } from './terminal-tail-state' +import { + getTerminalTailSentinelFullScanCount, + getTerminalTailSentinelMatches, + tailMayContainBlockedSignal +} from './terminal-tail-sentinel-index' +import { computeTerminalTailWaitState } from './terminal-wait-tail-state' +import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-detection' +import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' + +// The definition the incremental index must reproduce: does ANY retained line (or the +// partial line) match the sentinel? Written out independently of the implementation. +function referenceMayContainBlockedSignal(lines: string[], partialLine: string): boolean { + for (const line of lines) { + if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { + return true + } + } + return TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) +} + +function indexedMayContainBlockedSignal(lines: string[], partialLine: string): boolean { + return tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) +} + +type TailSim = { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + preview: string +} + +function newSim(): TailSim { + return { lines: [], partialLine: '', redrawCursor: null, preview: '' } +} + +function feed(sim: TailSim, chunk: string): void { + const next = appendNormalizedToTailBuffer(sim.lines, sim.partialLine, chunk, sim.redrawCursor) + sim.lines = next.lines + sim.partialLine = next.partialLine + sim.redrawCursor = next.redrawCursor + sim.preview = buildPreview(next.lines, next.partialLine) +} + +/** A structurally identical tail the index has never seen, so it takes the full-scan path. */ +function unindexed(sim: TailSim): string[] { + return [...sim.lines] +} + +function assertMatchesFullScan(sim: TailSim): void { + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe( + referenceMayContainBlockedSignal(sim.lines, sim.partialLine) + ) + expect(computeTerminalTailWaitState(sim.lines, sim.partialLine, sim.preview)).toEqual( + computeTerminalTailWaitState(unindexed(sim), sim.partialLine, sim.preview) + ) +} + +const BLOCKED_LINE = 'Update available! Press Enter to continue.' +const ESC = String.fromCharCode(27) + +/** The exact positions a from-scratch scan would record, written out independently. */ +function referenceSentinelMatches(lines: readonly string[]): number[] { + const matches: number[] = [] + for (let index = 0; index < lines.length; index += 1) { + if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(lines[index]!)) { + matches.push(index) + } + } + return matches +} + +/** + * The whole contract of the carried window, at position resolution: the index the constructor + * registered for this exact array must equal a from-scratch scan of it. A boolean-only assertion + * would pass on an index whose positions are shifted, doubled, or out of bounds. + */ +function assertIndexedPositionsAreExact(lines: readonly string[]): void { + const indexed = [...getTerminalTailSentinelMatches(lines)] + expect(indexed).toEqual(referenceSentinelMatches(lines)) + for (const position of indexed) { + expect(position).toBeGreaterThanOrEqual(0) + expect(position).toBeLessThan(lines.length) + } +} + +function countSentinelTests(run: () => void): number { + const spy = vi.spyOn(TERMINAL_WAIT_BLOCKED_SENTINEL_RE, 'test') + try { + run() + return spy.mock.calls.length + } finally { + spy.mockRestore() + } +} + +function saturatedSim(): TailSim { + const sim = newSim() + for (let index = 0; index < MAX_TAIL_LINES + 400; index += 1) { + feed(sim, `streaming build output line ${index}\n`) + } + expect(sim.lines.length).toBe(MAX_TAIL_LINES) + return sim +} + +describe('terminal tail sentinel index', () => { + it('tests only the lines an append produced, not the whole retained tail', () => { + const sim = saturatedSim() + // Warm the index for the current tail identity. + computeTerminalTailWaitState(sim.lines, sim.partialLine, sim.preview) + + const incrementalTests = countSentinelTests(() => { + for (let index = 0; index < 20; index += 1) { + feed(sim, `fresh line ${index}\n`) + } + computeTerminalTailWaitState(sim.lines, sim.partialLine, sim.preview) + }) + + const fullScanTests = countSentinelTests(() => { + computeTerminalTailWaitState(unindexed(sim), sim.partialLine, sim.preview) + }) + + expect(fullScanTests).toBeGreaterThanOrEqual(MAX_TAIL_LINES) + // 20 appended lines + one partial-line test per compute call. + expect(incrementalTests).toBeLessThanOrEqual(25) + }) + + it('keeps a retained sentinel visible and drops it exactly when it is evicted', () => { + const sim = saturatedSim() + feed(sim, `${BLOCKED_LINE}\n`) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + assertMatchesFullScan(sim) + + // Push the prompt to the very last retained slot. + for (let index = 0; index < MAX_TAIL_LINES - 1; index += 1) { + feed(sim, `after prompt ${index}\n`) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + } + expect(sim.lines[0]).toBe(BLOCKED_LINE) + + // One more line evicts it. + feed(sim, 'evicting line\n') + expect(sim.lines.includes(BLOCKED_LINE)).toBe(false) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + assertMatchesFullScan(sim) + + // And it stays gone many chunks later. + for (let index = 0; index < 200; index += 1) { + feed(sim, `long after eviction ${index}\n`) + } + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + assertMatchesFullScan(sim) + }) + + it('drops a sentinel evicted by the retained-character cap', () => { + const sim = newSim() + feed(sim, `${BLOCKED_LINE}\n`) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + const bulkLine = `${'x'.repeat(4000)}\n` + for (let index = 0; index * 4001 < MAX_TAIL_CHARS + 20000; index += 1) { + feed(sim, bulkLine) + } + expect(sim.lines.includes(BLOCKED_LINE)).toBe(false) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + assertMatchesFullScan(sim) + }) + + it('finds a sentinel split across two chunks once the line completes', () => { + const sim = saturatedSim() + feed(sim, 'Codex asks: press ent') + // Still only a partial line, and no alternative matches the fragment yet. + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + assertMatchesFullScan(sim) + + feed(sim, 'er to confirm') + // Now complete, but still the partial line — the partial is always tested directly. + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + assertMatchesFullScan(sim) + + feed(sim, '\n') + // And once it becomes a retained line the index carries it. + expect(sim.partialLine).toBe('') + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + assertMatchesFullScan(sim) + }) + + it('full-scans a tail array the index has never seen (seed/restore path)', () => { + // primeWaitBlockedBaselineFromSeededTail reads whatever tail the restore seed installed. + const seeded = ['boot log', BLOCKED_LINE, 'trailing'] + expect(tailMayContainBlockedSignal(seeded)).toBe(true) + const state = computeTerminalTailWaitState(seeded, '', '') + expect(state.fromTail).toBe(true) + expect(state.signal?.reason).toBe('codex-update-prompt') + + const clean = ['boot log', 'no prompt here', 'trailing'] + expect(tailMayContainBlockedSignal(clean)).toBe(false) + expect(computeTerminalTailWaitState(clean, '', '').signal).toBeNull() + }) + + it('reports fromTail from a blank tail without consulting the index', () => { + const sim = newSim() + feed(sim, ' \n\t\n') + expect(computeTerminalTailWaitState(sim.lines, sim.partialLine, '').fromTail).toBe(false) + feed(sim, 'now visible\n') + expect(computeTerminalTailWaitState(sim.lines, sim.partialLine, '').fromTail).toBe(true) + }) +}) + +/** + * `buildCarriedTailLines` is the only producer of a tail array, and it derives the carried-match + * window from the same keep bounds it slices the array out of. These guards pin the four ways + * that derivation could still be written wrong, plus the one way a path could escape it. Each was + * confirmed to fail against a deliberately broken constructor (see the PR body). + */ +describe('terminal tail sentinel index carried window', () => { + it('drops a carried match the moment the constructor evicts its row (no stale match)', () => { + const sim = saturatedSim() + feed(sim, `${BLOCKED_LINE}\n`) + // Walk it to the very first retained slot, checking the position every step: each append + // evicts one row at a saturated tail, so the carried match must shift down by exactly one. + for (let index = 0; index < MAX_TAIL_LINES - 1; index += 1) { + feed(sim, `after prompt ${index}\n`) + expect(getTerminalTailSentinelMatches(sim.lines)).toEqual([MAX_TAIL_LINES - 2 - index]) + } + expect(sim.lines[0]).toBe(BLOCKED_LINE) + + feed(sim, 'evicting line\n') + expect(sim.lines.includes(BLOCKED_LINE)).toBe(false) + assertIndexedPositionsAreExact(sim.lines) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + }) + + it('finds a match a redraw writes into rows the carried prefix does not cover', () => { + const sim = saturatedSim() + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + + // A windowed redraw: the prefix carries, the rewritten suffix must still be scanned. + feed(sim, `${ESC}[3A${ESC}[2K${BLOCKED_LINE}\n`) + assertIndexedPositionsAreExact(sim.lines) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + + // And a redraw that overwrites that same row again must drop it. + feed(sim, `${ESC}[1A${ESC}[2Kplain replacement\n`) + assertIndexedPositionsAreExact(sim.lines) + + // A redraw deep enough to outrun the window carries nothing and rescans in full. + feed(sim, `${ESC}[2500A${ESC}[2K${BLOCKED_LINE}\n`) + assertIndexedPositionsAreExact(sim.lines) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + }) + + it('shifts every carried position by exactly the number of rows evicted', () => { + const sim = newSim() + feed(sim, `first\n${BLOCKED_LINE}\nsecond\n${BLOCKED_LINE}\nthird\n`) + expect(getTerminalTailSentinelMatches(sim.lines)).toEqual([1, 3]) + + // Saturate so the line cap evicts exactly one row per single-line append. + for (let index = 0; index < MAX_TAIL_LINES - 5; index += 1) { + feed(sim, `pad ${index}\n`) + } + expect(sim.lines.length).toBe(MAX_TAIL_LINES) + expect(getTerminalTailSentinelMatches(sim.lines)).toEqual([1, 3]) + + feed(sim, 'evict one\n') + expect(getTerminalTailSentinelMatches(sim.lines)).toEqual([0, 2]) + feed(sim, 'evict two\n') + expect(getTerminalTailSentinelMatches(sim.lines)).toEqual([1]) + assertIndexedPositionsAreExact(sim.lines) + }) + + it('stays in bounds when a single chunk evicts the whole carried window and part of itself', () => { + const sim = saturatedSim() + feed(sim, `${BLOCKED_LINE}\n`) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + + // One chunk with more complete lines than the tail retains: every carried row goes, and so + // does the front of the chunk itself, so nothing may survive from before the cut. + const early: string[] = [BLOCKED_LINE] + for (let index = 0; index < MAX_TAIL_LINES + 500; index += 1) { + early.push(`flood ${index}`) + } + feed(sim, `${early.join('\n')}\n`) + expect(sim.lines.length).toBe(MAX_TAIL_LINES) + assertIndexedPositionsAreExact(sim.lines) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + + // Same shape, but the prompt lands inside the surviving suffix of the chunk. + const late: string[] = [] + for (let index = 0; index < MAX_TAIL_LINES + 500; index += 1) { + late.push(`flood ${index}`) + } + late.push(BLOCKED_LINE) + feed(sim, `${late.join('\n')}\n`) + expect(getTerminalTailSentinelMatches(sim.lines)).toEqual([MAX_TAIL_LINES - 1]) + assertIndexedPositionsAreExact(sim.lines) + + // The character cap drops from the same front, past the carried window and into the chunk. + const bulk = `${'x'.repeat(4000)}\n` + for (let index = 0; index * 4001 < MAX_TAIL_CHARS + 20000; index += 1) { + feed(sim, bulk) + } + assertIndexedPositionsAreExact(sim.lines) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + }) + + it('never leaves a produced tail unindexed, on any append path', () => { + const sim = saturatedSim() + computeTerminalTailWaitState(sim.lines, sim.partialLine, sim.preview) + const fullScansBefore = getTerminalTailSentinelFullScanCount() + + feed(sim, `${BLOCKED_LINE}\n`) + for (let index = 0; index < 200; index += 1) { + feed(sim, `after prompt ${index}\n`) + } + feed(sim, 'partial with no newline') + feed(sim, ' and its completion\n') + feed(sim, '\rspinner 40%') + feed(sim, `${ESC}[3A${ESC}[2Kredrawn\n`) + feed(sim, `${ESC}[2500A${ESC}[2Kdeep redraw\n`) + feed(sim, 'trailing spaces here \n') + feed(sim, `${'z'.repeat(5000)}\n`) + feed(sim, `multi\nline\nchunk\n`) + feed(sim, '') + // Reading the verdict must never trigger a scan of an array the constructor produced. + computeTerminalTailWaitState(sim.lines, sim.partialLine, sim.preview) + + expect(getTerminalTailSentinelFullScanCount()).toBe(fullScansBefore) + assertIndexedPositionsAreExact(sim.lines) + }) +}) + +// Deterministic PRNG so a divergence is reproducible from the seed alone. +function mulberry32(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** + * `streaming` saturates and evicts the retained tail; `tui` trades saturation for redraw + * coverage (cursor-up rewrites of retained rows, and reaches past the redraw window). + */ +function randomChunk(random: () => number, profile: 'streaming' | 'tui'): string { + const roll = random() + if (roll < 0.2) { + const lines: string[] = [] + for (let index = 0; index < 30; index += 1) { + lines.push(`burst line ${Math.floor(random() * 1e6)}`) + } + return `${lines.join('\n')}\n` + } + if (roll < 0.42) { + return `plain output ${Math.floor(random() * 1e6)}\n` + } + if (roll < 0.48) { + return `${' '.repeat(Math.floor(random() * 3))}\n` + } + if (roll < 0.54) { + return `${BLOCKED_LINE}\n` + } + if (roll < 0.58) { + return 'do you trust the files in this folder?\n' + } + if (roll < 0.63) { + // Sentinel split across a chunk boundary. + return random() < 0.5 ? 'Codex asks: press ent' : 'er to confirm\n' + } + if (roll < 0.7) { + // TUI redraw: move the cursor up a few rows and rewrite them. + const rows = 1 + Math.floor(random() * 12) + return `${ESC}[${rows}A${ESC}[2Kredrawn row ${Math.floor(random() * 1000)}\n` + } + if (roll < (profile === 'tui' ? 0.76 : 0.7)) { + // Deep redraw that outruns the window and forces the unwindowed path. + return `${ESC}[${1500 + Math.floor(random() * 800)}A${ESC}[2Kdeep redraw\n` + } + if (roll < 0.82) { + return `\rspinner ${Math.floor(random() * 100)}%` + } + if (roll < 0.87) { + return 'trailing spaces here \n' + } + if (roll < 0.91) { + return `${'y'.repeat(3000)}\n` + } + if (roll < 0.95) { + return `multi\nline\nchunk ${Math.floor(random() * 1000)}\n` + } + if (roll < 0.97) { + return '' + } + return `no newline ${Math.floor(random() * 1000)}` +} + +describe('terminal tail sentinel index property', () => { + for (const profile of ['streaming', 'tui'] as const) { + for (const seed of [1, 7, 42, 1337]) { + it(`matches a full scan on every step of a random ${profile} sequence (seed ${seed})`, () => { + const random = mulberry32(seed) + const sim = newSim() + let sawSentinel = false + let sawSaturation = false + for (let step = 0; step < 1200; step += 1) { + feed(sim, randomChunk(random, profile)) + const expected = referenceMayContainBlockedSignal(sim.lines, sim.partialLine) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(expected) + expect(computeTerminalTailWaitState(sim.lines, sim.partialLine, sim.preview)).toEqual( + computeTerminalTailWaitState(unindexed(sim), sim.partialLine, sim.preview) + ) + sawSentinel = sawSentinel || expected + sawSaturation = sawSaturation || sim.lines.length >= MAX_TAIL_LINES + } + // Guard against a vacuous pass. + expect(sawSentinel).toBe(true) + if (profile === 'streaming') { + expect(sawSaturation).toBe(true) + } + }) + } + } +}) diff --git a/src/main/runtime/terminal-tail-sentinel-index.ts b/src/main/runtime/terminal-tail-sentinel-index.ts new file mode 100644 index 00000000000..c99fd31bac7 --- /dev/null +++ b/src/main/runtime/terminal-tail-sentinel-index.ts @@ -0,0 +1,94 @@ +import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-detection' + +/** + * Which retained tail lines match the wait-blocked sentinel, memoized per + * lines-array identity. + * + * Why: `computeTerminalTailWaitState` must prove the ABSENCE of a signal, so it + * cannot early-exit and re-tested all 2000 retained lines on every scan (20/s + * per streaming PTY) even though only ~20 lines were new. Keyed weakly by the + * array so an entry dies with the tail it describes; the tail array is replaced + * on every append and never mutated in place, so at most one entry per PTY + * stays live. + */ +const sentinelMatchesByTailLines = new WeakMap() + +function collectSentinelMatches( + lines: readonly string[], + startIndex: number, + into: number[] +): void { + for (let index = startIndex; index < lines.length; index += 1) { + if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(lines[index]!)) { + into.push(index) + } + } +} + +/** + * How many arrays have been full-scanned because they arrived without an index entry. + * Every array `appendNormalizedToTailBuffer` produces is registered by `buildCarriedTailLines`, + * so this only advances for tails the index has genuinely never seen (a restore seed, a persisted + * record, a hand-built array). Tests assert it stays flat across the real append paths, which is + * what proves no producer path silently bypasses the constructor. + */ +let sentinelFullScanCount = 0 + +export function getTerminalTailSentinelFullScanCount(): number { + return sentinelFullScanCount +} + +/** Ascending indices of sentinel-matching lines; full-scans an unseen array. */ +export function getTerminalTailSentinelMatches(lines: readonly string[]): readonly number[] { + const cached = sentinelMatchesByTailLines.get(lines) + if (cached) { + return cached + } + sentinelFullScanCount += 1 + const matches: number[] = [] + collectSentinelMatches(lines, 0, matches) + sentinelMatchesByTailLines.set(lines, matches) + return matches +} + +export function tailMayContainBlockedSignal(lines: readonly string[]): boolean { + return getTerminalTailSentinelMatches(lines).length > 0 +} + +/** + * Derive `nextLines`' match index from `previousLines`', testing only the lines + * the append actually produced. + * + * `nextLines[0 … carriedCount)` are the very same strings as + * `previousLines[carriedSourceStart … carriedSourceStart + carriedCount)`, and + * every later line is newly produced. That is not an assumption a caller has to + * uphold by hand: `buildCarriedTailLines` in `terminal-tail-buffer.ts` is the + * sole caller, and it derives this window from the same keep bounds it slices + * `nextLines` out of, so the window and the array cannot disagree. Matches + * outside the carried window are dropped because their lines were evicted or + * rewritten, which is exactly what a full scan would conclude. + */ +export function carryTerminalTailSentinelMatches( + previousLines: readonly string[], + nextLines: readonly string[], + carriedSourceStart: number, + carriedCount: number +): void { + if (nextLines === previousLines) { + return + } + const matches: number[] = [] + if (carriedCount > 0) { + const carriedEnd = carriedSourceStart + carriedCount + for (const index of getTerminalTailSentinelMatches(previousLines)) { + if (index >= carriedEnd) { + break + } + if (index >= carriedSourceStart) { + matches.push(index - carriedSourceStart) + } + } + } + collectSentinelMatches(nextLines, carriedCount, matches) + sentinelMatchesByTailLines.set(nextLines, matches) +} diff --git a/src/main/runtime/terminal-wait-tail-state.ts b/src/main/runtime/terminal-wait-tail-state.ts index 2b9cafee27b..712b301a961 100644 --- a/src/main/runtime/terminal-wait-tail-state.ts +++ b/src/main/runtime/terminal-wait-tail-state.ts @@ -1,5 +1,6 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import { buildTailLines } from './terminal-tail-state' +import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' import { findActionableTerminalWaitBlockedSignal, TERMINAL_WAIT_BLOCKED_SENTINEL_RE @@ -60,23 +61,22 @@ function inspectTerminalWaitTail( lines: string[], partialLine: string ): { fromTail: boolean; mayContainBlockedSignal: boolean } { - let fromTail = false - let mayContainBlockedSignal = false + return { + fromTail: hasVisibleTailLine(lines) || partialLine.trim().length > 0, + // Why the index: proving a signal is ABSENT can't early-exit, so a full re-test of the + // 2000-line tail ran per scan; the index tests only the lines each append produced. + mayContainBlockedSignal: + tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + } +} + +function hasVisibleTailLine(lines: string[]): boolean { for (const line of lines) { - if (!fromTail && line.trim().length > 0) { - fromTail = true - } - if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { - mayContainBlockedSignal = true + if (line.trim().length > 0) { + return true } } - if (!fromTail && partialLine.trim().length > 0) { - fromTail = true - } - if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine)) { - mayContainBlockedSignal = true - } - return { fromTail, mayContainBlockedSignal } + return false } // Why: consumes precomputed wait states so full-tail scans aren't repeated per chunk (replaces the former inline double full-tail scan). diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts index 4c85f46b0c8..2869e3c813c 100644 --- a/src/main/runtime/workspace-session-terminal-membership-authority.ts +++ b/src/main/runtime/workspace-session-terminal-membership-authority.ts @@ -5,6 +5,7 @@ import type { } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { layoutContainsLeafId } from '../persistence/restoring-sessions/terminal-layout-normalization' import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement' function collectLeafIds(node: TerminalPaneLayoutNode | null, ids: Set): void { @@ -164,15 +165,22 @@ export function advanceTerminalTopologyRevision( * The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable — * `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the * pane left. Callers fencing on location must resolve it here rather than trust a frozen tabId. + * + * Stateless on purpose: writers graft leaves by assigning into a layout that is already inside the + * layouts record, so any cache here would need a revalidation key that is itself O(tabs) per read — + * the same cost as this walk, with a staleness invariant to keep. `Object.keys` over a guarded + * `for...in` is deliberate too: the key array is cheaper than a `hasOwn` call per tab (measured). */ export function findTerminalTabIdForLeaf( session: WorkspaceSessionState | undefined, leafId: string ): string | undefined { - for (const [tabId, layout] of Object.entries(session?.terminalLayoutsByTabId ?? {})) { - const leafIds = new Set() - collectLeafIds(layout.root, leafIds) - if (leafIds.has(leafId)) { + const layouts = session?.terminalLayoutsByTabId + if (!layouts) { + return undefined + } + for (const tabId of Object.keys(layouts)) { + if (layoutContainsLeafId(layouts[tabId]?.root ?? null, leafId)) { return tabId } } diff --git a/src/main/runtime/worktree-list-host-scope.test.ts b/src/main/runtime/worktree-list-host-scope.test.ts new file mode 100644 index 00000000000..e497028f4c4 --- /dev/null +++ b/src/main/runtime/worktree-list-host-scope.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' +import { selectHostBalancedPage } from '../../shared/host-balanced-listing-page' +import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import type { RuntimeStore } from './runtime-store-contract' + +const LOCAL_REPO: Repo = { + id: 'repo-local', + path: '/workspace/app', + displayName: 'app', + badgeColor: '#000000', + addedAt: 1 +} + +const SSH_REPO: Repo = { + ...LOCAL_REPO, + id: 'repo-ssh', + connectionId: 'box-1', + displayName: 'app (remote)' +} + +const settings = { + workspaceDir: '/worktrees', + nestWorkspaces: true, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' +} + +function worktree(repoId: string, path: string, hostId: string): ResolvedWorktree { + return { + id: `${repoId}::${path}`, + repoId, + path, + branch: 'main', + hostId, + displayName: path, + comment: '', + linkedIssue: null, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: false } + } as unknown as ResolvedWorktree +} + +/** The reproduced shape from #18104: every remote row lands contiguously at the end. */ +function fleet(localCount: number, sshCount: number): ResolvedWorktree[] { + return [ + ...Array.from({ length: localCount }, (_, index) => + worktree(LOCAL_REPO.id, `/worktrees/local-${index}`, 'local') + ), + ...Array.from({ length: sshCount }, (_, index) => + worktree(SSH_REPO.id, `/remote/wt-${index}`, 'ssh:box-1') + ) + ] +} + +function queries( + resolved: ResolvedWorktree[], + knownHostIds: ExecutionHostId[] = ['local', 'ssh:box-1'] +): RuntimeManagedWorktreeQueries { + const store = { + getRepos: () => [LOCAL_REPO, SSH_REPO], + getRepo: () => LOCAL_REPO, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: vi.fn(), + getAllWorktreeLineage: () => ({}), + getSettings: () => settings + } as unknown as RuntimeStore + return new RuntimeManagedWorktreeQueries({ + getStore: () => store, + listResolved: async () => resolved, + resolveRepo: async () => SSH_REPO, + selectRepos: () => [SSH_REPO], + scanRepo: async () => ({ ok: true, worktrees: [] }), + listKnownHostIds: () => knownHostIds + }) +} + +describe('worktree.list host coverage under the row cap', () => { + it('returns remote rows that sit entirely past the cap', async () => { + // Why #18104: 497 local + 24 SSH rows, SSH at indices 496-520, and a 200-row cap returned + // `{local: 200}` — zero of 24 remote worktrees, with nothing saying the gap was a whole host. + const result = await queries(fleet(497, 24)).list(undefined, 200) + + expect(result.totalCount).toBe(521) + expect(result.truncated).toBe(true) + expect(result.worktrees).toHaveLength(200) + const remote = result.worktrees.filter((row) => row.hostId === 'ssh:box-1') + expect(remote).toHaveLength(24) + expect(result.hostScope).toEqual({ hostIds: ['local', 'ssh:box-1'], omittedHostIds: [] }) + }) + + it('keeps the page a subsequence of the unbounded listing', async () => { + // Why: balancing decides which rows survive the cap, never how the survivors are ordered. + const resolved = fleet(497, 24) + const result = await queries(resolved).list(undefined, 200) + + const positions = result.worktrees.map((row) => resolved.findIndex((it) => it.id === row.id)) + expect(positions).toEqual([...positions].sort((left, right) => left - right)) + }) + + it('names a configured host that contributed no rows at all', async () => { + // Why: a repo whose scan failed contributes zero rows exactly like a host with no worktrees. + // docs/reference/ssh-execution-boundary.md forbids the listing from reading as absolute there. + const result = await queries(fleet(3, 0), ['local', 'ssh:box-1', 'runtime:paired']).list( + undefined, + 200 + ) + + expect(result.hostScope).toEqual({ + hostIds: ['local'], + omittedHostIds: ['runtime:paired', 'ssh:box-1'] + }) + }) + + it('does not report configured hosts as omitted from a --repo listing', async () => { + // Why: the caller scoped this themselves, so naming the hosts they excluded is noise. + const result = await queries(fleet(0, 5)).list('id:repo-ssh', 200) + + expect(result.hostScope).toEqual({ hostIds: ['ssh:box-1'], omittedHostIds: [] }) + }) + + it('leaves an uncapped listing byte-identical', async () => { + const resolved = fleet(4, 2) + const result = await queries(resolved).list(undefined, 200) + + expect(result.worktrees.map((row) => row.id)).toEqual(resolved.map((row) => row.id)) + expect(result.truncated).toBe(false) + }) +}) + +describe('selectHostBalancedPage', () => { + it('gives every host a share of the cap rather than filling it from the first', () => { + const rows = [ + ...Array.from({ length: 10 }, (_, index) => ({ host: 'local', index })), + ...Array.from({ length: 10 }, (_, index) => ({ host: 'ssh:box-1', index: index + 10 })) + ] + + const page = selectHostBalancedPage(rows, 4, (row) => row.host) + + expect(page.map((row) => row.host)).toEqual(['local', 'local', 'ssh:box-1', 'ssh:box-1']) + }) + + it('fills the cap from the remaining hosts when one runs out of rows', () => { + const rows = [ + { host: 'local', id: 'a' }, + { host: 'local', id: 'b' }, + { host: 'local', id: 'c' }, + { host: 'ssh:box-1', id: 'd' } + ] + + const page = selectHostBalancedPage(rows, 3, (row) => row.host) + + expect(page.map((row) => row.id)).toEqual(['a', 'b', 'd']) + }) + + it('buckets rows with no host together instead of dropping them', () => { + const rows = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + + expect(selectHostBalancedPage(rows, 2, () => undefined).map((row) => row.id)).toEqual([ + 'a', + 'b' + ]) + }) +}) diff --git a/src/main/runtime/worktree-listing-host-scope.ts b/src/main/runtime/worktree-listing-host-scope.ts new file mode 100644 index 00000000000..99650882670 --- /dev/null +++ b/src/main/runtime/worktree-listing-host-scope.ts @@ -0,0 +1,64 @@ +import type { ExecutionHostId } from '../../shared/execution-host' +import { selectHostBalancedPage } from '../../shared/host-balanced-listing-page' +import type { RuntimeListingHostScope } from '../../shared/runtime-listing-host-scope' + +/** + * Applies a worktree listing's row cap and reports which hosts the resulting page covers. + * + * Rows are resolved repo by repo, so every SSH repo's rows land contiguously at the end of the + * fleet order: 24 remote worktrees sat at indices 496-520 of 521 and a 200-row cap returned zero + * of them (#18104). Balancing the page across hosts fixes the starvation; the scope is what makes + * the remaining gap legible, because a host with no rows in the page is otherwise indistinguishable + * from a host with no worktrees — which `docs/reference/ssh-execution-boundary.md` forbids a + * listing from implying. + */ +export function buildWorktreeListingPage( + rows: readonly TRow[], + limit: number, + knownHostIds: Iterable +): { + worktrees: TRow[] + hostScope: RuntimeListingHostScope + totalCount: number + truncated: boolean +} { + const page = selectHostBalancedPage(rows, limit, (row) => row.hostId) + return { + worktrees: page, + hostScope: buildWorktreeListingHostScope({ + pageHostIds: page.map((row) => row.hostId), + matchedHostIds: rows.map((row) => row.hostId), + knownHostIds + }), + totalCount: rows.length, + truncated: rows.length > limit + } +} + +/** + * The worktree-listing counterpart of `buildTerminalListHostScope`: names the hosts the returned + * page covers, and every host it does not — including a configured repo whose scan failed, which + * contributes zero rows exactly like a host with no worktrees. + */ +export function buildWorktreeListingHostScope(args: { + /** Hosts of the rows actually returned. */ + pageHostIds: Iterable + /** Hosts of every row that matched, including those the cap dropped. */ + matchedHostIds: Iterable + /** Hosts this runtime has configured repos or workspaces on, even if they contributed no rows. */ + knownHostIds: Iterable +}): RuntimeListingHostScope { + const covered = new Set() + for (const hostId of args.pageHostIds) { + if (hostId) { + covered.add(hostId) + } + } + const omitted = new Set() + for (const hostId of [...args.matchedHostIds, ...args.knownHostIds]) { + if (hostId && !covered.has(hostId)) { + omitted.add(hostId) + } + } + return { hostIds: [...covered].sort(), omittedHostIds: [...omitted].sort() } +} diff --git a/src/main/runtime/worktree-ps-host-scope.test.ts b/src/main/runtime/worktree-ps-host-scope.test.ts new file mode 100644 index 00000000000..cf718cd267f --- /dev/null +++ b/src/main/runtime/worktree-ps-host-scope.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + BrowserWindow: { fromId: vi.fn((): unknown => null) }, + webContents: { fromId: vi.fn((): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } + } +}) +vi.mock('electron', () => electronMocks) + +const getSshGitProviderMock = vi.hoisted(() => vi.fn()) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: vi.fn(() => 0), + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', + requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) +})) + +const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) +vi.mock('../git/worktree', async (importOriginal) => ({ + ...(await importOriginal>()), + listWorktreesStrict: listWorktreesStrictMock +})) + +import { OrcaRuntimeService } from './orca-runtime' + +const LOCAL_REPO_ID = 'repo-local' +const LOCAL_REPO_PATH = '/Users/me/dev/app' +const SSH_REPO_ID = 'repo-ssh' +const SSH_REPO_PATH = '/home/user/app' +const SSH_CONNECTION_ID = 'box-1' + +function gitWorktree(path: string, isMain = false) { + return { path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: isMain } +} + +/** Local rows sort ahead of the remote ones, mirroring the fleet order that starves the cap. */ +function makeStore() { + const metaById: Record = {} + return { + getRepo: (id: string) => + makeStore() + .getRepos() + .find((repo) => repo.id === id), + getRepos: () => [ + { + id: LOCAL_REPO_ID, + path: LOCAL_REPO_PATH, + displayName: 'app', + badgeColor: 'blue', + addedAt: 1 + }, + { + id: SSH_REPO_ID, + path: SSH_REPO_PATH, + displayName: 'app remote', + badgeColor: 'blue', + addedAt: 2, + connectionId: SSH_CONNECTION_ID + } + ], + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Record) => { + metaById[id] = { ...(metaById[id] as object), ...meta } + return metaById[id] + }, + removeWorktreeMeta: () => {}, + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn(), + removeWorkspaceLineage: vi.fn(), + getGitHubCache: () => undefined as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }), + getProjects: () => [] + } +} + +describe('worktree.ps host coverage', () => { + beforeEach(() => { + getSshGitProviderMock.mockReset() + listWorktreesStrictMock.mockReset() + listWorktreesStrictMock.mockResolvedValue([ + gitWorktree(LOCAL_REPO_PATH, true), + gitWorktree(`${LOCAL_REPO_PATH}-a`), + gitWorktree(`${LOCAL_REPO_PATH}-b`), + gitWorktree(`${LOCAL_REPO_PATH}-c`) + ]) + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn(async () => [ + gitWorktree(SSH_REPO_PATH, true), + gitWorktree(`${SSH_REPO_PATH}-a`) + ]) + }) + }) + + it('names every host the page covers', async () => { + const runtime = new OrcaRuntimeService(makeStore() as never) + + const result = await runtime.getWorktreePs(10_000) + + expect(result.hostScope?.hostIds).toEqual(['local', `ssh:${SSH_CONNECTION_ID}`]) + expect(result.hostScope?.omittedHostIds).toEqual([]) + }) + + it('keeps a remote row in the page when the cap cannot hold every local row', async () => { + const runtime = new OrcaRuntimeService(makeStore() as never) + + const result = await runtime.getWorktreePs(2) + + expect(result.truncated).toBe(true) + expect(result.worktrees).toHaveLength(2) + expect(result.worktrees.map((worktree) => worktree.hostId)).toContain( + `ssh:${SSH_CONNECTION_ID}` + ) + expect(result.hostScope?.hostIds).toEqual(['local', `ssh:${SSH_CONNECTION_ID}`]) + }) +}) diff --git a/src/main/ssh-expired-lease-pane-readoption.test.ts b/src/main/ssh-expired-lease-pane-readoption.test.ts index 4c2c34b5f62..11bf5fe2083 100644 --- a/src/main/ssh-expired-lease-pane-readoption.test.ts +++ b/src/main/ssh-expired-lease-pane-readoption.test.ts @@ -7,6 +7,7 @@ import { resolvePersistedStablePaneOwner } from './ipc/pty/pane/stable-owner' import { adoptStablePane } from './ipc/pty/pane/adopt-stable' import { sshProviders } from './ipc/pty/provider/registry' import type { IPtyProvider } from './providers/types' +import { SSH_SESSION_EXPIRED_ERROR, SshPtyAbsentFromRelayError } from './providers/ssh-pty-errors' import { testState, createStore, makeTerminalTab } from './persistence-test-harness' import { TEST_LEAF_1 } from './persistence-session-fixtures' @@ -141,11 +142,13 @@ describe('recovery through createTerminal reattaches before it respawns', () => expect(adopted?.owner).toMatchObject({ ptyId: APP_PTY_ID, hasPersistedBinding: true }) }) + // The typed refusal is what the SSH reattach path raises; the raw `PTY "…" not found` wire text + // never reaches a pane untyped, and an untyped one no longer authorises abandoning the binding. it('falls through to a fresh spawn once the host answers that the PTY is absent', async () => { const store = storeWithBoundRemotePane() store.markSshRemotePtyLease(TARGET, APP_PTY_ID, 'expired') const spawn = vi.fn(async () => { - throw new Error('PTY "remote-pty" not found') + throw new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: remote-pty`) }) sshProviders.set(TARGET, { spawn } as unknown as IPtyProvider) diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 5827ec1ee68..d7179703360 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -82,24 +82,38 @@ function relayReattachBinds( } /** - * Both binding writers land before the lease upsert — spawn asserts that ordering directly, and - * the relay's reattach binds the pane before `markSshRemotePtyLeasesAttachedAsync`. Supersession - * therefore sees a session already naming the arriving shell. + * One reconnect's worth of writes, in the order the spawn commits actually issue them: the lease + * row first — so a force-quit in the renderer's debounce window cannot leave a running remote shell + * with no lease to reattach it — then the binding, then the binding-side supersession trigger. + * + * This suite used to bind BEFORE upserting, an order no caller uses. Under that order supersession + * always saw a session already naming the arriving shell and passed; under production's order it + * bailed on the predecessor's binding every time and never re-ran, so the guard could not catch the + * per-reconnect lease growth it exists to pin. * * Goes through `persistPtyBinding` rather than `setWorkspaceSession` because that is the writer * production uses; a raw session write is reconciled back to the attached lease's PTY by binding * recovery, which would make the fixture disagree with the real flow. */ -function paneBindsTo( +function paneSpawnCommits( store: ReturnType, - args: { tabId: string; leafId: string; ptyId: string } + args: { tabId: string; leafId: string; ptyId: string; leaseTabId?: string } ): void { + store.upsertSshRemotePtyLease({ + targetId: TARGET, + ptyId: args.ptyId, + worktreeId: WORKTREE, + tabId: args.leaseTabId ?? args.tabId, + leafId: args.leafId, + state: 'attached' + }) store.persistPtyBinding({ worktreeId: WORKTREE, tabId: args.tabId, leafId: args.leafId, ptyId: args.ptyId }) + store.supersedeSshRemotePtyLeasesForBoundPane(TARGET, args.leafId) } function liveLeasePtyIds(store: ReturnType): string[] { @@ -300,8 +314,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) expect(liveLeasePtyIds(store)).toEqual(['pty-2']) }) @@ -314,8 +327,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor?.state).toBe('expired') @@ -325,11 +337,9 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { it('holds the live lease count flat across ten reconnects of one pane', async () => { const store = await createStore() store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-0' })) - const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } for (let reconnect = 0; reconnect < 10; reconnect++) { - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) - store.upsertSshRemotePtyLease({ ...lease, ptyId: `pty-${reconnect}`, state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) } expect(liveLeasePtyIds(store)).toEqual(['pty-9']) @@ -349,17 +359,14 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { state: 'attached' }) - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) // The successor's lease names the tab the pane sits in NOW; the predecessor's still names the // one it was written in. Only the leaf is common, so keying on the tab would stop the two // competing and leave both live — the cardinality growth. - store.upsertSshRemotePtyLease({ - targetId: TARGET, - ptyId: 'pty-2', - worktreeId: WORKTREE, - tabId: OTHER_TAB, + paneSpawnCommits(store, { + tabId: TAB, leafId: TEST_LEAF_1, - state: 'attached' + ptyId: 'pty-2', + leaseTabId: OTHER_TAB }) expect(liveLeasePtyIds(store)).toEqual(['pty-2']) @@ -394,8 +401,7 @@ describe('STA-3077: one pane keeps at most one live remote lease', () => { const lease = { targetId: TARGET, worktreeId: WORKTREE, tabId: TAB, leafId: TEST_LEAF_1 } store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-1', state: 'attached' }) - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...lease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) expect(liveLeasePtyIds(store).sort()).toEqual(['pty-2', 'sibling-pty']) }) @@ -455,8 +461,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () it('never bulk-reattaches a superseded sibling', async () => { const store = await storeWithPane('pty-1') - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...paneLease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-2' }) @@ -469,8 +474,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () store.setWorkspaceSession(sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-0' })) for (let reconnect = 0; reconnect < 10; reconnect++) { - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) - store.upsertSshRemotePtyLease({ ...paneLease, ptyId: `pty-${reconnect}`, state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: `pty-${reconnect}` }) } expect(bulkReattachPtyIds(store)).toEqual(['pty-9']) @@ -496,8 +500,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () store.markSshRemotePtyLease(TARGET, 'pty-1', 'expired') const orphanUpdatedAt = store.getSshRemotePtyLeases(TARGET)[0].updatedAt - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...paneLease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) const predecessor = store.getSshRemotePtyLeases(TARGET).find((entry) => entry.ptyId === 'pty-1') expect(predecessor).toMatchObject({ state: 'expired', supersededBy: 'pty-2' }) @@ -510,8 +513,7 @@ describe('STA-3077: `expired` separates a superseded sibling from an orphan', () // belongs to the lease that lost, never to whatever claims the id next. it('clears the supersession mark when the id is re-upserted as a live lease', async () => { const store = await storeWithPane('pty-1') - paneBindsTo(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) - store.upsertSshRemotePtyLease({ ...paneLease, ptyId: 'pty-2', state: 'attached' }) + paneSpawnCommits(store, { tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-2' }) // A restarted relay hands `pty-1` to a new shell for a different pane. store.upsertSshRemotePtyLease({ diff --git a/src/main/ssh/relay-daemon-service-children.ts b/src/main/ssh/relay-daemon-service-children.ts new file mode 100644 index 00000000000..35e755ea518 --- /dev/null +++ b/src/main/ssh/relay-daemon-service-children.ts @@ -0,0 +1,62 @@ +/** + * Telling a relay daemon's own service processes apart from the work it holds. + * + * The reap gate used to ask `pgrep -P | grep -c .` and demand zero. But the daemon + * forks service children of its own — `relay-ai-vault-service.js` is spawned lazily and then + * never exits — so that count is permanently non-zero on any relay that has touched the AI + * Vault, whether or not it holds a single PTY. A superseded, disconnected relay holding + * nothing therefore reported `retained-live-work` forever, its version directory stayed + * pinned against GC by its own live socket, and the population grew without bound (#13614). + * + * The asymmetry below is the whole safety argument, and it follows + * docs/reference/ssh-execution-boundary.md: *subtracting a child we can positively identify + * as relay infrastructure is sound; assuming anything about a child we cannot identify is + * not.* An argv that does not match, an argv `ps` would not print, and a host without + * `pgrep` all count against the relay and keep it unreapable. Losing sight of a child is + * never evidence that it holds nothing. + */ +import { RELAY_DAEMON_SERVICE_ENTRY_FILENAMES } from '../../shared/relay-artifacts' +import { shellEscape } from './ssh-connection-utils' + +/** Shell variable set to the daemon's direct-child count, or `unknown`. */ +export const RELAY_CHILD_COUNT_VAR = 'kids' + +/** Shell variable set to the count of children not identified as relay services, or `unknown`. */ +export const RELAY_UNRECOGNIZED_CHILD_COUNT_VAR = 'unrecognized_kids' + +/** + * `case` patterns matching a service child's argv. Suffix-anchored on purpose: both entries + * are forked with no script arguments, so the argv ends at the filename, and the leading `/` + * requires the absolute path the daemon forks rather than a bare mention of the name. A + * future arg would stop matching and the relay would go back to being retained — the safe + * direction to fail in. + */ +function serviceChildArgvPatterns(): string { + return RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.map( + (filename) => `*${shellEscape(`/${filename}`)}` + ).join('|') +} + +/** + * POSIX shell that censuses the direct children of `$pid`, setting `kids` and + * `unrecognized_kids`. Both stay `unknown` when the host cannot enumerate children at all. + */ +export function relayDaemonChildCensusShell(): string[] { + return [ + `${RELAY_CHILD_COUNT_VAR}=unknown`, + `${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}=unknown`, + 'if command -v pgrep >/dev/null 2>&1; then', + ` ${RELAY_CHILD_COUNT_VAR}=0`, + ` ${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}=0`, + ' for kid in $(pgrep -P "$pid" 2>/dev/null); do', + ` ${RELAY_CHILD_COUNT_VAR}=$((${RELAY_CHILD_COUNT_VAR}+1))`, + ' kid_args=$(ps -o args= -p "$kid" 2>/dev/null | tr -d "\\n")', + ' case "$kid_args" in', + ` ${serviceChildArgvPatterns()}) ;;`, + // An unreadable or unrecognised argv lands here, which is what keeps the relay retained. + ` *) ${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}=$((${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}+1)) ;;`, + ' esac', + ' done', + 'fi' + ] +} diff --git a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts index 3201f8709fe..afb365c075c 100644 --- a/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts +++ b/src/main/ssh/ssh-orphan-relay-pty-sweep.test.ts @@ -53,7 +53,8 @@ function createHarness( shutdown } as unknown as IPtyProvider const store = { - getSshRemotePtyLeases: vi.fn().mockReturnValue(leases) + getSshRemotePtyLeases: vi.fn().mockReturnValue(leases), + reconcileSshRemotePtyLeasesForTarget: vi.fn() } as unknown as Store return { provider, store, shutdown } } diff --git a/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts b/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts index ab069240681..560d18b8b62 100644 --- a/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts +++ b/src/main/ssh/ssh-orphan-sweep-pane-state-verdicts.test.ts @@ -68,6 +68,44 @@ const CAPTURES = { ' 3159 3158 3159 3158 T sleep 300', ' 3160 1 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' ] + }, + /** `set +m; sleep 300 &`. With job control OFF the job does not get its own process group — it + * keeps the SHELL's pgid. So the tty carries exactly one process group, and that group is + * running a build. Reproduced independently on a real Ubuntu host through an Orca pane. */ + setMinusMBackground: { + rootPid: 12, + table: [ + ' 1 0 1 -1 Ss /bin/bash /work/run.sh', + ' 11 1 1 -1 S python3 /work/pty-scenario.py setm_background', + ' 12 11 12 12 Ss+ bash -i', + ' 13 12 12 12 S+ sleep 300', + ' 14 11 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' + ] + }, + /** A `set +m` job that drops its controlling terminal (`ioctl(TIOCNOTTY)` with no `setsid`). It + * keeps the shell's pgid, reports `tpgid == -1`, and is absent from `ps -t ` and from every + * tty-keyed index — while `killpg(shellPgid)` still reaches it. */ + nottyGroupMember: { + rootPid: 16, + table: [ + ' 1 0 1 -1 Ss /bin/bash /work/run.sh', + ' 15 1 1 -1 S python3 /work/pty-scenario.py notty_member', + ' 16 15 16 16 Ss+ bash -i', + ' 17 16 16 -1 S python3 -c import fcntl,os,time;fd=os.open("/dev/tty",os.O_RDWR);fcntl.ioctl(fd,0x5422);os.close(fd);time.sleep(300)', + ' 18 15 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' + ] + }, + /** A `set +m` job that double-forks. pid 22 keeps the shell's pgid and tty but reparented to pid + * 1, so the ppid walk from `rootPid` never reaches it and it can never be named. */ + doubleForkedGroupMember: { + rootPid: 20, + table: [ + ' 1 0 1 -1 Ss /bin/bash /work/run.sh', + ' 19 1 1 -1 S python3 /work/pty-scenario.py double_fork', + ' 20 19 20 20 Ss+ bash -i', + ' 22 1 20 20 S+ python3 -c import os,sys,time;p=os.fork() if p: print("GRANDCHILD:%d"%p);sys.stdout.flush();os._exit(0) time.sleep(300)', + ' 23 19 1 -1 R ps -axo pid=,ppid=,pgid=,tpgid=,stat=,command=' + ] } } as const @@ -147,6 +185,15 @@ describe('what the host publishes about a pane, read by the sweep', () => { expect(shellShape(CAPTURES.background)).toBe(shellShape(CAPTURES.idle)) expect(shellShape(CAPTURES.ctrlz)).toBe(shellShape(CAPTURES.idle)) expect(shellShape(CAPTURES.foreground)).not.toBe(shellShape(CAPTURES.idle)) + + // Same premise for the `set +m` captures, minus `ppid`: their harness keeps its parent alive + // rather than reparenting the shell to init, and the ppid is the one field of the shape the + // predicate never reads. + const paneShape = (capture: { rootPid: number; table: readonly string[] }): string => + shellShape(capture).split(' ').slice(1).join(' ') + expect(paneShape(CAPTURES.setMinusMBackground)).toBe(paneShape(CAPTURES.idle)) + expect(paneShape(CAPTURES.nottyGroupMember)).toBe(paneShape(CAPTURES.idle)) + expect(paneShape(CAPTURES.doubleForkedGroupMember)).toBe(paneShape(CAPTURES.idle)) }) it('sweeps an idle shell', async () => { @@ -191,6 +238,58 @@ describe('what the host publishes about a pane, read by the sweep', () => { expect(skipReason(plan)).toBe('host does not attest an idle shell') }) + // The tty is not the unit the stop operates on. `forceKillPosixPtyProcessGroups` collects the + // groups on the tty and then `killpg`s each one, so anything sharing the shell's pgid dies with + // it — including members the tty index cannot see at all. All three captures below reproduce on + // real Linux: before the group-membership half of the predicate they published + // `shellOwnsEveryTtyProcessGroup: true`, planned a SWEEP, and the planted pid was GONE after the + // real `forceKillPosixPtyProcessGroups` call. + it('never sweeps a pane whose background job shares the shell pgid under `set +m`', async () => { + // pid 13 is `sleep 300` — stand in `pnpm build`. Its pgid IS the shell's, so the tty carries + // exactly one process group and the tty half of the predicate reads the pane as idle. + const rows = parseStrictProcessTableRows(CAPTURES.setMinusMBackground.table.join('\n')) + const tty = rows.filter((row) => row.tpgid === CAPTURES.setMinusMBackground.rootPid) + expect(new Set(tty.map((row) => row.pgid))).toEqual(new Set([12])) + expect(tty.map((row) => row.pid)).toEqual([12, 13]) + + const evidence = await publish(CAPTURES.setMinusMBackground) + expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) + + const plan = await planFor(CAPTURES.setMinusMBackground) + expect(plan.sweep).toEqual([]) + expect(skipReason(plan)).toBe('host does not attest an idle shell') + }) + + it('never sweeps a pane whose group member dropped the controlling terminal', async () => { + // pid 17 kept the shell's pgid and called `ioctl(TIOCNOTTY)`, so it reports `tpgid == -1`, + // never appears in `ps -t `, and no tty-shaped index — not process groups, not pids — + // can observe it. `killpg(16)` reaches it regardless. + const rows = parseStrictProcessTableRows(CAPTURES.nottyGroupMember.table.join('\n')) + expect(rows.filter((row) => row.tpgid === 16).map((row) => row.pid)).toEqual([16]) + expect(rows.filter((row) => row.pgid === 16).map((row) => row.pid)).toEqual([16, 17]) + + const evidence = await publish(CAPTURES.nottyGroupMember) + expect(evidence).toMatchObject({ shellOwnsEveryTtyProcessGroup: false }) + + const plan = await planFor(CAPTURES.nottyGroupMember) + expect(plan.sweep).toEqual([]) + expect(skipReason(plan)).toBe('host does not attest an idle shell') + }) + + it('never sweeps a pane whose group member double-forked away from the shell', async () => { + // pid 22 reparented to pid 1, so the ppid walk from rootPid cannot reach it and the named- + // process backstop can never fire. It still holds the shell's pgid. + const rows = parseStrictProcessTableRows(CAPTURES.doubleForkedGroupMember.table.join('\n')) + expect(rows.find((row) => row.pid === 22)).toMatchObject({ ppid: 1, pgid: 20, tpgid: 20 }) + + const evidence = await publish(CAPTURES.doubleForkedGroupMember) + expect(evidence).toMatchObject({ processName: null, shellOwnsEveryTtyProcessGroup: false }) + + const plan = await planFor(CAPTURES.doubleForkedGroupMember) + expect(plan.sweep).toEqual([]) + expect(skipReason(plan)).toBe('host does not attest an idle shell') + }) + it('refuses an observation older than the pass it would authorize', async () => { // Same idle capture that sweeps above; only its age differs. Staleness degrades to "leave it // running", never to "stop it". diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index c5cb2dd552c..257eb984caa 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -52,6 +52,7 @@ import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' +import { isWindowsRelayPlatform } from '../../shared/relay-artifacts' import { probeBuildToolchain, formatMissingToolchainError, @@ -745,27 +746,29 @@ const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent- const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs' const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:' /** - * Whether the tree the patch left behind still leaks the pty master into every later child. - * `fixed` is the only outcome a shared cache entry may be published from. + * Whether the tree the patch left behind still leaks a pty fd -- the master into every later child + * on Linux, a throwaway /dev/ptmx per spawn on macOS. `fixed` is the only outcome a shared cache + * entry may be published from. */ type NodePtyMasterCloexecOutcome = 'fixed' | 'unfixed' /** * The statuses that leave a non-leaking tree. Deliberately an allowlist, not a `failed:` denylist: - * the script's `skipped:` family is mixed. `skipped:not-linux` is a platform that never leaks, but - * `skipped:earlier-attempt-failed`, `skipped:no-compiled-build`, `skipped:unexpected-source` and - * the two `skipped:` forms all mean the patch was refused and the leaky build is still on - * disk -- indistinguishable from `failed:` as far as what gets published. + * the script's `skipped:` family is mixed. `skipped:unsupported-platform` is a platform that never + * leaks, but `skipped:earlier-attempt-failed`, `skipped:no-compiled-build`, `skipped:no-prebuild`, + * `skipped:unexpected-source` and the two `skipped:` forms all mean the patch was refused + * and the leaky build is still on disk -- indistinguishable from `failed:` as far as what gets + * published. */ const NODE_PTY_CLOEXEC_FIXED_STATUSES: ReadonlySet = new Set([ 'patched', - // The rebuild ran from patched source; only the isolation check could not observe the result. - // An unobservable check is not a failed patch, and treating it as one would disable the shared - // cache on every host without `lsof`. + // The rebuild ran from patched source; only the leak check could not observe the result. An + // unobservable check is not a failed patch, and treating it as one would disable the shared + // cache on every host without `/proc` or `lsof`. 'patched-unverified', 'already-patched', - // Unreachable while the platform gate below short-circuits first, but it is the one `skipped:` - // that means "nothing to fix" rather than "would not fix it". - 'skipped:not-linux' + // Unreachable while the platform gate below short-circuits Windows first, but it is the one + // `skipped:` that means "nothing to fix" rather than "would not fix it". + 'skipped:unsupported-platform' ]) // Exported for the relay-native-dependency-coverage test, which asserts every // native addon the relay bundle imports is either installed here or explicitly @@ -1300,7 +1303,7 @@ async function installNativeDeps( } /** - * Re-apply the pty-master FD_CLOEXEC patch the app gets from pnpm to the host's npm copy (#17915). + * Re-apply the pty fd-leak patch the app gets from pnpm to the host's npm copy (#17915). * * Why it is safe to rebuild under a live relay: this only runs from installNativeDeps, so only on a * freshly created directory or a locked repair, and a relay already serving PTYs has pty.node mapped @@ -1324,9 +1327,11 @@ async function applyNodePtyMasterCloexecPatch( nodePath: string, signal?: AbortSignal ): Promise { - // Linux is the only relay platform that takes forkpty()'s no-O_CLOEXEC path; macOS and Windows - // ship prebuilds, so forcing a rebuild there would add a first compile to fix nothing. - if (isWindowsRemoteHost(hostPlatform) || !platform.startsWith('linux')) { + // Both Unix relay platforms leak, by different bugs: Linux inherits the master through forkpty()'s + // no-O_CLOEXEC path, macOS orphans one throwaway /dev/ptmx fd per spawn in pty_posix_spawn. Only + // Windows, which has no fds, is short-circuited -- and answering 'fixed' from a gate that ran + // nothing is exactly how a leaking darwin tree got published to the shared cache. + if (isWindowsRemoteHost(hostPlatform) || isWindowsRelayPlatform(platform)) { return 'fixed' } try { diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts index 7ece0b6532e..a8975d0520b 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts @@ -4,7 +4,7 @@ * generated scripts through /bin/sh against real unix sockets and real processes. */ import { execFile, spawn, type ChildProcess } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' @@ -15,21 +15,32 @@ import { type RelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' import { reapEmptyRelayHuskCommand } from './ssh-relay-endpoint-takeover' +import { RELAY_DAEMON_SERVICE_ENTRY_FILENAMES } from '../../shared/relay-artifacts' const posixOnly = process.platform === 'win32' ? describe.skip : describe const FAKE_RELAY_SOURCE = ` const net = require('net') +const path = require('path') const sock = process.argv[process.argv.indexOf('--sock-path') + 1] +function spawnChild(args) { + require('child_process').spawn(process.execPath, args, { stdio: 'ignore' }) +} if (process.argv.includes('--with-child')) { - require('child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - stdio: 'ignore' - }) + spawnChild(['-e', 'setTimeout(() => {}, 60000)']) +} +// Why forked the same way production does: the exclusion is argv-shaped, so a hand-written +// stand-in would test the test rather than the shell that runs on someone's host. +for (const name of process.argv.filter((arg) => arg.startsWith('--service-child='))) { + spawnChild([path.join(__dirname, name.slice('--service-child='.length))]) } net.createServer(() => {}).listen(sock, () => process.stdout.write('READY\\n')) process.on('SIGTERM', () => process.exit(0)) ` +// Self-limiting: these are orphaned when the relay under test is reaped. +const IDLE_SERVICE_SOURCE = 'setTimeout(() => {}, 60000)\n' + function sh(script: string): Promise { return new Promise((resolve, reject) => { execFile('/bin/sh', ['-c', script], { timeout: 20_000 }, (error, stdout) => { @@ -43,14 +54,21 @@ function sh(script: string): Promise { } let workDir: string +let pgreplessBinDir: string let hasLsof = false const running: ChildProcess[] = [] -function startFakeRelay(sockPath: string, withChild = false): Promise { +function startFakeRelay( + sockPath: string, + options: { withChild?: boolean; serviceChildren?: readonly string[] } = {} +): Promise { const args = [join(workDir, 'relay.js'), '--sock-path', sockPath] - if (withChild) { + if (options.withChild) { args.push('--with-child') } + for (const name of options.serviceChildren ?? []) { + args.push(`--service-child=${name}`) + } const child = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'ignore'] }) running.push(child) return new Promise((resolve, reject) => { @@ -68,9 +86,31 @@ async function probe(sockPath: string): Promise { return parseRelayEndpointIncumbentProbe(sockPath, output) } +/** The relay forks its children after it starts listening, so the probe can race them. */ +async function waitForChildCount( + sockPath: string, + expected: number +): Promise { + let incumbent = await probe(sockPath) + for (let attempt = 0; attempt < 50 && incumbent.holders[0]?.childCount !== expected; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 100)) + incumbent = await probe(sockPath) + } + return incumbent +} + beforeAll(async () => { workDir = mkdtempSync(join(tmpdir(), 'orca-relay-incumbent-')) writeFileSync(join(workDir, 'relay.js'), FAKE_RELAY_SOURCE) + for (const filename of RELAY_DAEMON_SERVICE_ENTRY_FILENAMES) { + writeFileSync(join(workDir, filename), IDLE_SERVICE_SOURCE) + } + writeFileSync(join(workDir, 'looks-like-relay-watcher.js'), IDLE_SERVICE_SOURCE) + pgreplessBinDir = join(workDir, 'pgrepless-bin') + mkdirSync(pgreplessBinDir) + for (const tool of ['ps', 'tr']) { + symlinkSync((await sh(`command -v ${tool}`)).trim(), join(pgreplessBinDir, tool)) + } hasLsof = await sh('command -v lsof >/dev/null 2>&1 && echo yes || echo no').then( (out) => out.trim() === 'yes' ) @@ -105,13 +145,51 @@ posixOnly('relay endpoint probe against a real socket', () => { return } expect(incumbent.holders.map((holder) => holder.pid)).toEqual([relay.pid]) - expect(incumbent.holders[0]).toMatchObject({ matchesRelayArgv: true, childCount: 0 }) + expect(incumbent.holders[0]).toMatchObject({ + matchesRelayArgv: true, + childCount: 0, + unrecognizedChildCount: 0 + }) expect(isReapableRelayHusk(incumbent)).toBe(true) }) + it("counts the daemon's own service children but does not hold them against it", async () => { + const sockPath = join(workDir, 'services.sock') + await startFakeRelay(sockPath, { serviceChildren: RELAY_DAEMON_SERVICE_ENTRY_FILENAMES }) + const incumbent = await waitForChildCount(sockPath, RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.length) + + expect(incumbent.holders[0].childCount).toBe(RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.length) + expect(incumbent.holders[0].unrecognizedChildCount).toBe(0) + expect(isReapableRelayHusk(incumbent)).toBe(true) + }) + + it('still retains a relay holding work alongside its service children', async () => { + const sockPath = join(workDir, 'services-and-work.sock') + await startFakeRelay(sockPath, { + withChild: true, + serviceChildren: RELAY_DAEMON_SERVICE_ENTRY_FILENAMES + }) + const incumbent = await waitForChildCount( + sockPath, + RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.length + 1 + ) + + expect(incumbent.holders[0].unrecognizedChildCount).toBe(1) + expect(isReapableRelayHusk(incumbent)).toBe(false) + }) + + it('does not excuse a child that merely mentions a service entry name', async () => { + const sockPath = join(workDir, 'lookalike.sock') + await startFakeRelay(sockPath, { serviceChildren: ['looks-like-relay-watcher.js'] }) + const incumbent = await waitForChildCount(sockPath, 1) + + expect(incumbent.holders[0].unrecognizedChildCount).toBe(1) + expect(isReapableRelayHusk(incumbent)).toBe(false) + }) + it('refuses to call a relay with a live child an empty husk', async () => { const sockPath = join(workDir, 'busy.sock') - await startFakeRelay(sockPath, true) + await startFakeRelay(sockPath, { withChild: true }) const incumbent = await probe(sockPath) expect(incumbent.verdict).toBe('live') @@ -150,12 +228,34 @@ posixOnly('empty relay husk reap against a real process', () => { it('refuses to signal a relay that acquired a child after it was probed', async () => { const sockPath = join(workDir, 'raced.sock') - const relay = await startFakeRelay(sockPath, true) + const relay = await startFakeRelay(sockPath, { withChild: true }) const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) expect(output.trim()).toBe('BUSY') expect(relay.killed).toBe(false) }) + it('terminates a relay whose only children are its own service processes (#13614)', async () => { + const sockPath = join(workDir, 'service-husk.sock') + const relay = await startFakeRelay(sockPath, { + serviceChildren: RELAY_DAEMON_SERVICE_ENTRY_FILENAMES + }) + await waitForChildCount(sockPath, RELAY_DAEMON_SERVICE_ENTRY_FILENAMES.length) + const output = await sh(reapEmptyRelayHuskCommand(relay.pid!, sockPath)) + expect(output.trim()).toBe('GONE') + }) + + it('refuses to signal when the host cannot enumerate children at all', async () => { + const sockPath = join(workDir, 'no-pgrep.sock') + const relay = await startFakeRelay(sockPath) + // A PATH carrying every tool the script needs except `pgrep`: the census answers + // `unknown`, which must reach BUSY rather than the zero a missing tool would imply. + const output = await sh( + `PATH=${pgreplessBinDir}\n${reapEmptyRelayHuskCommand(relay.pid!, sockPath)}` + ) + expect(output.trim()).toBe('BUSY') + expect(relay.killed).toBe(false) + }) + it('refuses to signal a pid whose argv is not this relay at this socket', async () => { const sockPath = join(workDir, 'mismatch.sock') await startFakeRelay(sockPath) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts index a65cb33fc57..de4cc28d170 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts @@ -32,17 +32,24 @@ describe('parseRelayEndpointIncumbentProbe', () => { it('reports live when the socket accepted a connection', () => { const incumbent = parseRelayEndpointIncumbentProbe( SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=4242 yes 13']) + probeOutput([ + 'PRESENT=yes', + 'LISTEN=accepted', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=4242 yes 13 11' + ]) ) expect(incumbent.verdict).toBe('live') expect(incumbent.evidence).toBe('accepted-connection') - expect(incumbent.holders).toEqual([{ pid: 4242, matchesRelayArgv: true, childCount: 13 }]) + expect(incumbent.holders).toEqual([ + { pid: 4242, matchesRelayArgv: true, childCount: 13, unrecognizedChildCount: 11 } + ]) }) it('reports live when a process still holds an inode that refuses connections', () => { const incumbent = parseRelayEndpointIncumbentProbe( SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=91 yes 2']) + probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=91 yes 2 2']) ) expect(incumbent.verdict).toBe('live') expect(incumbent.evidence).toBe('holder-process') @@ -85,7 +92,12 @@ describe('parseRelayEndpointIncumbentProbe', () => { it('drops holder lines that do not carry a usable pid', () => { const incumbent = parseRelayEndpointIncumbentProbe( SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=refused', 'HOLDERS_SOURCE=lsof', 'HOLDER=- no unknown']) + probeOutput([ + 'PRESENT=yes', + 'LISTEN=refused', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=- no unknown unknown' + ]) ) expect(incumbent.holders).toEqual([]) expect(incumbent.verdict).toBe('exited') @@ -94,9 +106,24 @@ describe('parseRelayEndpointIncumbentProbe', () => { it('keeps an unreadable child count as null rather than zero', () => { const [holder] = parseRelayEndpointIncumbentProbe( SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=7 yes unknown']) + probeOutput([ + 'PRESENT=yes', + 'LISTEN=accepted', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=7 yes unknown unknown' + ]) ).holders expect(holder.childCount).toBeNull() + expect(holder.unrecognizedChildCount).toBeNull() + }) + + it('keeps a holder line with no unrecognized-child field unreapable', () => { + const incumbent = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=7 yes 0']) + ) + expect(incumbent.holders[0].unrecognizedChildCount).toBeNull() + expect(isReapableRelayHusk(incumbent)).toBe(false) }) }) @@ -172,27 +199,36 @@ describe('mayLaunchOverRelayEndpoint', () => { describe('isReapableRelayHusk', () => { const husk = parseRelayEndpointIncumbentProbe( SOCK, - probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 0']) + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 0 0']) ) - it('accepts a single proven relay holder with zero children', () => { + it('accepts a single proven relay holder with no unaccounted-for children', () => { expect(isReapableRelayHusk(husk)).toBe(true) }) - it('refuses a relay that still holds children', () => { + it('accepts a relay whose only children are its own service processes (#13614)', () => { + const withServices = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 2 0']) + ) + expect(withServices.holders[0].childCount).toBe(2) + expect(isReapableRelayHusk(withServices)).toBe(true) + }) + + it('refuses a relay that still holds children it could not account for', () => { expect( isReapableRelayHusk({ ...husk, - holders: [{ pid: 500, matchesRelayArgv: true, childCount: 1 }] + holders: [{ pid: 500, matchesRelayArgv: true, childCount: 3, unrecognizedChildCount: 1 }] }) ).toBe(false) }) - it('refuses a holder whose child count could not be read', () => { + it('refuses a holder whose unrecognized-child count could not be read', () => { expect( isReapableRelayHusk({ ...husk, - holders: [{ pid: 500, matchesRelayArgv: true, childCount: null }] + holders: [{ pid: 500, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: null }] }) ).toBe(false) }) @@ -201,7 +237,7 @@ describe('isReapableRelayHusk', () => { expect( isReapableRelayHusk({ ...husk, - holders: [{ pid: 500, matchesRelayArgv: false, childCount: 0 }] + holders: [{ pid: 500, matchesRelayArgv: false, childCount: 0, unrecognizedChildCount: 0 }] }) ).toBe(false) }) @@ -211,8 +247,8 @@ describe('isReapableRelayHusk', () => { isReapableRelayHusk({ ...husk, holders: [ - { pid: 500, matchesRelayArgv: true, childCount: 0 }, - { pid: 501, matchesRelayArgv: true, childCount: 0 } + { pid: 500, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: 0 }, + { pid: 501, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: 0 } ] }) ).toBe(false) diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.ts index 2688267f4c7..628a9558793 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.ts @@ -20,6 +20,11 @@ */ import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' +import { + RELAY_CHILD_COUNT_VAR, + RELAY_UNRECOGNIZED_CHILD_COUNT_VAR, + relayDaemonChildCensusShell +} from './relay-daemon-service-children' import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' @@ -38,6 +43,12 @@ export type RelayEndpointHolder = { matchesRelayArgv: boolean /** Direct children, or null when `pgrep` could not answer. Never guessed. */ childCount: number | null + /** + * Direct children *not* positively identified as the daemon's own service processes, or + * null when the host could not enumerate them. This — not `childCount` — is what says + * whether the relay holds anything; see relay-daemon-service-children.ts. + */ + unrecognizedChildCount: number | null } export type RelayEndpointIncumbent = { @@ -95,11 +106,9 @@ export function relayEndpointIncumbentProbeCommand(nodePath: string, sockPath: s ' args=$(ps -o args= -p "$pid" 2>/dev/null | tr "\\n" " ")', ' match=no', ' case "$args" in *relay.js*"$sock"*) match=yes ;; esac', - ' kids=unknown', - ' if command -v pgrep >/dev/null 2>&1; then', - ' kids=$(pgrep -P "$pid" 2>/dev/null | grep -c .)', - ' fi', - ' printf \'HOLDER=%s %s %s\\n\' "$pid" "$match" "$kids"', + ...relayDaemonChildCensusShell().map((line) => ` ${line}`), + ' printf \'HOLDER=%s %s %s %s\\n\' "$pid" "$match" ' + + `"$${RELAY_CHILD_COUNT_VAR}" "$${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}"`, ' done', 'else', " printf 'HOLDERS_SOURCE=unavailable\\n'", @@ -159,19 +168,25 @@ export function parseRelayEndpointIncumbentProbe( } function parseHolder(value: string): RelayEndpointHolder | null { - const [rawPid, rawMatch, rawKids] = value.split(/\s+/) + const [rawPid, rawMatch, rawKids, rawUnrecognized] = value.split(/\s+/) const pid = Number.parseInt(rawPid ?? '', 10) if (!Number.isInteger(pid) || pid <= 0) { return null } - const childCount = Number.parseInt(rawKids ?? '', 10) return { pid, matchesRelayArgv: rawMatch === 'yes', - childCount: Number.isInteger(childCount) && childCount >= 0 ? childCount : null + childCount: parseChildCount(rawKids), + unrecognizedChildCount: parseChildCount(rawUnrecognized) } } +/** `unknown`, a missing field, and anything unparseable are all "could not tell" — never 0. */ +function parseChildCount(raw: string | undefined): number | null { + const count = Number.parseInt(raw ?? '', 10) + return Number.isInteger(count) && count >= 0 ? count : null +} + function unverifiableEndpoint(sockPath: string): RelayEndpointIncumbent { return { sockPath, @@ -239,8 +254,12 @@ export function mayLaunchOverRelayEndpoint(incumbent: RelayEndpointIncumbent): b /** * A live relay that provably holds nothing: identity confirmed against its argv, exactly one - * holder, and zero children. Reaping it destroys no user work. Anything less is retained — - * killing the wrong pid on someone's remote host is the worst outcome available here. + * holder, and no child the host could not account for as one of the daemon's own service + * processes. Reaping it destroys no user work. Anything less is retained — killing the wrong + * pid on someone's remote host is the worst outcome available here. + * + * Why not `childCount === 0`: the daemon's AI Vault sidecar never exits once spawned, so that + * gate was unreachable for any relay that had ever served a vault request (#13614). */ export function isReapableRelayHusk(incumbent: RelayEndpointIncumbent): boolean { if (incumbent.verdict !== 'live' || !incumbent.holdersEnumerable) { @@ -250,12 +269,16 @@ export function isReapableRelayHusk(incumbent: RelayEndpointIncumbent): boolean return false } const [holder] = incumbent.holders - return holder.matchesRelayArgv && holder.childCount === 0 + return holder.matchesRelayArgv && holder.unrecognizedChildCount === 0 } export function describeRelayEndpointIncumbent(incumbent: RelayEndpointIncumbent): string { const holders = incumbent.holders - .map((holder) => `${holder.pid}(children=${holder.childCount ?? 'unknown'})`) + .map( + (holder) => + `${holder.pid}(children=${holder.childCount ?? 'unknown'},` + + `unrecognized=${holder.unrecognizedChildCount ?? 'unknown'})` + ) .join(',') return ( `${incumbent.sockPath} verdict=${incumbent.verdict} evidence=${incumbent.evidence} ` + diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.test.ts b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts index 687d633b92b..d23f065f478 100644 --- a/src/main/ssh/ssh-relay-endpoint-takeover.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts @@ -14,6 +14,7 @@ import { resolveRelayEndpointBeforeRelaunch } from './ssh-relay-endpoint-takeover' import { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' +import { RELAY_DAEMON_SERVICE_ENTRY_FILENAMES } from '../../shared/relay-artifacts' import type { SshConnection } from './ssh-connection' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -42,7 +43,7 @@ beforeEach(() => { describe('incumbent alive and refusing', () => { it('refuses to rebind a live relay holding PTYs, and signals nothing', async () => { execCommand.mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) ) await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) // The whole point of #8585: the incumbent's socket must survive so it is not orphaned. @@ -52,9 +53,9 @@ describe('incumbent alive and refusing', () => { it('names the incumbent pid and the Reset Relay escape hatch in the error', async () => { execCommand.mockResolvedValue( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) ) - await expect(resolve()).rejects.toThrow(/3669803\(children=13\)/) + await expect(resolve()).rejects.toThrow(/3669803\(children=13,unrecognized=11\)/) await expect(resolve()).rejects.toThrow(/Reset Relay/) }) @@ -70,7 +71,7 @@ describe('incumbent alive and refusing', () => { it('reaps a live relay only when it provably holds nothing, and confirms it is gone', async () => { execCommand .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) .mockResolvedValueOnce('GONE\n') await expect(resolve()).resolves.toMatchObject({ verdict: 'live' }) @@ -80,7 +81,7 @@ describe('incumbent alive and refusing', () => { it('does not launch over an empty relay whose death could not be confirmed', async () => { execCommand .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) .mockResolvedValueOnce('LIVE\n') await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) @@ -89,7 +90,7 @@ describe('incumbent alive and refusing', () => { it('does not launch over a relay the host refused to signal on its own re-check', async () => { execCommand .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) .mockResolvedValueOnce('BUSY\n') await expect(resolve()).rejects.toSatisfy(isRelayEndpointHeldError) @@ -138,9 +139,19 @@ describe('reapEmptyRelayHuskCommand', () => { }) it('aborts without signalling when the host cannot count children', () => { - expect(reapEmptyRelayHuskCommand(4242, SOCK)).toContain( - "command -v pgrep >/dev/null 2>&1 || { printf 'BUSY\\n'; exit 0; }" - ) + const command = reapEmptyRelayHuskCommand(4242, SOCK) + // The census leaves both counters at `unknown` without pgrep, and the gate demands "0". + expect(command).toContain('unrecognized_kids=unknown') + expect(command).toContain('command -v pgrep >/dev/null 2>&1') + expect(command).toContain('[ "$unrecognized_kids" = "0" ] ||') + }) + + it('subtracts only the daemon service children it can name from the reap gate', () => { + const command = reapEmptyRelayHuskCommand(4242, SOCK) + for (const filename of RELAY_DAEMON_SERVICE_ENTRY_FILENAMES) { + expect(command).toContain(`*'/${filename}'`) + } + expect(command).toContain('unrecognized_kids=$((unrecognized_kids+1))') }) }) diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.ts b/src/main/ssh/ssh-relay-endpoint-takeover.ts index f104aab5256..8f6130620cb 100644 --- a/src/main/ssh/ssh-relay-endpoint-takeover.ts +++ b/src/main/ssh/ssh-relay-endpoint-takeover.ts @@ -2,13 +2,17 @@ * Deciding whether a relay socket path is ours to take, and acting on the answer. * * The only destructive action available here is a SIGTERM to a relay that has been proven — - * by argv, by socket-holder enumeration, and by a zero child count re-checked on the host - * immediately before the signal — to hold nothing at all. Everything else is left running. + * by argv, by socket-holder enumeration, and by a child census re-run on the host immediately + * before the signal — to hold nothing at all. Everything else is left running. * Per docs/reference/ssh-execution-boundary.md, a relay we merely failed to reach is * `unverifiable`, and `unverifiable` never authorizes a kill or a rebind. */ import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' +import { + RELAY_UNRECOGNIZED_CHILD_COUNT_VAR, + relayDaemonChildCensusShell +} from './relay-daemon-service-children' import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' import { describeRelayEndpointIncumbent, @@ -39,9 +43,10 @@ export function reapEmptyRelayHuskCommand(pid: number, sockPath: string): string `sock=${shellEscape(sockPath)}`, 'args=$(ps -o args= -p "$pid" 2>/dev/null | tr "\\n" " ")', 'case "$args" in *relay.js*"$sock"*) ;; *) printf \'MISMATCH\\n\'; exit 0 ;; esac', - "command -v pgrep >/dev/null 2>&1 || { printf 'BUSY\\n'; exit 0; }", - 'kids=$(pgrep -P "$pid" 2>/dev/null | grep -c .)', - '[ "$kids" = "0" ] || { printf \'BUSY\\n\'; exit 0; }', + // Why the same census as the probe: `unknown` (no pgrep) and any child this host could + // not account for as a relay service both land on BUSY, so nothing is signalled. + ...relayDaemonChildCensusShell(), + `[ "$${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}" = "0" ] || { printf 'BUSY\\n'; exit 0; }`, // SIGTERM only: the relay's own handler disposes and unlinks. SIGKILL would leave the // socket inode behind and skip that shutdown path for no gain on an empty daemon. 'kill -TERM "$pid" 2>/dev/null || true', diff --git a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts index 66af01fa43c..b0ca39ec2b4 100644 --- a/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts +++ b/src/main/ssh/ssh-relay-pty-master-cloexec-install.test.ts @@ -88,11 +88,12 @@ import { const PATCH_ASSET = 'node-pty-1.1.0-master-cloexec-patch.cjs' /** - * The relay installs stock node-pty from npm, so the app's pnpm patch never reaches it and every - * later child of the relay inherits a live pty master (#17915). The compile that closes it sits on + * The relay installs stock node-pty from npm, so the app's pnpm patch never reaches it and the + * relay leaks a pty fd per terminal (#17915) -- the master into every later child on Linux, an + * orphaned /dev/ptmx throwaway in `pty_posix_spawn` on macOS. The compile that closes both sits on * the connect path, so what these specs pin is the blast radius, not the patch itself. */ -describe('relay pty-master close-on-exec patch on the install path', () => { +describe('relay pty fd-leak patch on the install path', () => { const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, @@ -211,9 +212,9 @@ describe('relay pty-master close-on-exec patch on the install path', () => { }) it('does not publish a tree the patch refused to touch', async () => { - // `skipped:` is not one verdict. Every form except `skipped:not-linux` means the patch was - // declined and the leaky build is still on disk, which is indistinguishable from `failed:` - // as far as what would get published. + // `skipped:` is not one verdict. Every form except `skipped:unsupported-platform` means the + // patch was declined and the leaky build is still on disk, which is indistinguishable from + // `failed:` as far as what would get published. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const conn = makeMockConnection(sftpCapture) @@ -281,25 +282,39 @@ describe('relay pty-master close-on-exec patch on the install path', () => { expect(patchCommands()).toEqual([]) }) - it('never adds a compile to a macOS relay, which does not leak the master', async () => { + it('runs the patch on a macOS relay, which orphans a /dev/ptmx fd per spawn', async () => { + // macOS takes `pty_posix_spawn`, not forkpty, so the asset's original replacements rewrote + // nothing macOS executes -- and this gate answered 'fixed' without running anything, which is + // exactly what publishes to the shared cache. Every later host on the machine then linked a + // tree that leaks one /dev/ptmx fd per terminal, measured +1 per open/close cycle on + // darwin-arm64. macOS pays a first compile here, unlike Linux's second, and that is the price. vi.mocked(parseUnameToRelayPlatform).mockReturnValue('darwin-arm64') const conn = makeMockConnection(sftpCapture) - feed([ - ...makeStagedFirstInstallExecPrefix(), - '', // npm install native deps - '', // chmod prebuilds - 'ORCA-NPTY-PROBE-OK\n', - '', // rm probe stderr - '', // promote into the shared native-deps cache - '', // clean stage root - 'DEAD', - '', // publish the per-launch credential - 'READY' - ]) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) await deployAndLaunchRelay(conn) - expect(patchCommands()).toEqual([]) + expect(patchCommands()).toHaveLength(1) + expect(promoted()).toBe(true) + }) + + it('does not publish a macOS tree whose compile failed', async () => { + // The darwin rollback restores the shipped prebuild, so the relay still works -- and that is + // precisely why the status, not the exit code, has to decide publishability: a rolled-back + // macOS tree probes loadable and still leaks. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('darwin-arm64') + const conn = makeMockConnection(sftpCapture) + feed(firstInstallReporting('failed:npm rebuild node-pty exited 1: gyp ERR! not ok')) + + await deployAndLaunchRelay(conn) + + expect(patchCommands()).toHaveLength(1) + expect(promoted()).toBe(false) + } finally { + warn.mockRestore() + } }) it('connects anyway when the patch command fails outright', async () => { diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 49fc1e98ccd..76d92e77e9a 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -157,6 +157,7 @@ function createSession(targetId: string): InstanceType { upsertSshPtyConsumerRecovery: vi.fn(), removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + reconcileSshRemotePtyLeasesForTarget: vi.fn(), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), markSshRemotePtyLeasesAsync: vi.fn(), diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts index 9bb14618319..4cfa657e401 100644 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -104,6 +104,7 @@ function createMockDeps(): { upsertSshPtyConsumerRecovery: vi.fn(), removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + reconcileSshRemotePtyLeasesForTarget: vi.fn(), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), markSshRemotePtyLeasesAsync: vi.fn(), diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index efdee31c406..ba0782b3a20 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -21,6 +21,7 @@ export function createMockDeps(): SshRelaySessionTestDeps { upsertSshPtyConsumerRecovery: vi.fn(), removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + reconcileSshRemotePtyLeasesForTarget: vi.fn(), getWorkspaceSession: vi.fn(), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index bca3632b83b..99a2ce9fbf9 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -2322,6 +2322,12 @@ export class SshRelaySession { if (!shouldContinue()) { return } + // Why immediately before the read: a pane's binding is written by several writers, and the + // renderer's debounced layout publish lands long after the spawn commit that leased the pty — + // so a predecessor that was still bound at spawn time never gets marked by a spawn-side + // trigger. Re-deriving from each pane's CURRENT binding here is what actually bounds this set, + // and it repairs stores that already accumulated these rows. + this.store.reconcileSshRemotePtyLeasesForTarget(this.targetId) // Why not `state !== 'expired'`: that state covers both a superseded sibling (re-adopting it is // the 2 -> 19 -> 20 fan-out) and an orphan whose reattach merely lost contact. Only the first // carries a retirement mark, and only it has to be skipped. diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts index 874d9aae3fe..9168f4688bd 100644 --- a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts +++ b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts @@ -67,7 +67,7 @@ describe('classifySupersededRelay', () => { 'PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', - 'HOLDER=3669803 yes 13' + 'HOLDER=3669803 yes 13 11' ]) ) ).toBe('retained-live-work') @@ -76,7 +76,7 @@ describe('classifySupersededRelay', () => { it('nominates only a proven empty relay for reaping', () => { expect( classifySupersededRelay( - incumbent(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + incumbent(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) ).toBe('reap-candidate') }) @@ -101,7 +101,7 @@ describe('sweepSupersededRelayEndpoints', () => { execCommand .mockResolvedValueOnce(`${OLD_SOCK}\n`) .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) ) const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) expect(findings).toHaveLength(1) @@ -114,7 +114,7 @@ describe('sweepSupersededRelayEndpoints', () => { execCommand .mockResolvedValueOnce(`${OLD_SOCK}\n`) .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) .mockResolvedValueOnce('GONE\n') const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) @@ -126,7 +126,7 @@ describe('sweepSupersededRelayEndpoints', () => { execCommand .mockResolvedValueOnce(`${OLD_SOCK}\n`) .mockResolvedValueOnce( - probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 0']) + probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) ) .mockResolvedValueOnce('LIVE\n') const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index fc381f15714..5e4d4cfe428 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -66,7 +66,12 @@ describe('startup ordering', () => { ) expect(desktopStartup).toContain('recordRuntimeRpcStartFailure(') // Why: `void`, not `await` — awaiting the dialog would park the rest of startup behind a modal. - expect(desktopStartup).toMatch(/void showRuntimeRpcStartupFailureDialog\(\s*win,/) + // It chains off the i18n barrier (published before this phase starts) so the translated strings + // it reads are loaded, which is a wait on i18n only, never on the dialog itself. + expect(desktopStartup).toMatch( + /void state\.mainProcessI18nReady\.then\(\(\) =>\s*showRuntimeRpcStartupFailureDialog\(\s*win,/ + ) + expect(desktopStartup).not.toMatch(/await[^\n]*showRuntimeRpcStartupFailureDialog\(/) // Why (#11025): a bare console.error here is exactly what left the CLI dead but the app healthy. expect(desktopStartup).not.toContain( "console.error('[runtime] Failed to start local RPC transport:'" diff --git a/src/main/startup/first-window-deferral.ts b/src/main/startup/first-window-deferral.ts new file mode 100644 index 00000000000..6a144549efe --- /dev/null +++ b/src/main/startup/first-window-deferral.ts @@ -0,0 +1,37 @@ +import { app, type BrowserWindow } from 'electron' + +/** + * Run `task` once the first window can paint, or after `fallbackMs` if it never does. + * + * For startup work nothing on the critical path consumes: a probe or a disk sweep started before the + * window exists competes with window creation for the same main thread and libuv threadpool, and the + * user sees that as the app being slow to open. + * + * Why a fallback as well as the window event: `ready-to-show` can fail to fire at all when the + * GPU/driver cannot present (see main-window-state-lifecycle), and headless serve has no window. + */ +export function runAfterFirstWindowShown(task: () => void, fallbackMs: number): void { + let ran = false + const run = (): void => { + if (ran) { + return + } + ran = true + clearTimeout(fallback) + // Why setImmediate: keep the work off the event handler that reveals the window, so it paints first. + // Why the guard: off whenReady's promise chain a synchronous throw is an uncaughtException, and + // installUncaughtPipeErrorGuard re-throws those fatally — deferred startup chores are never that. + setImmediate(() => { + try { + task() + } catch (error) { + console.warn('[startup] deferred first-window task failed', error) + } + }) + } + const fallback = setTimeout(run, fallbackMs) + fallback.unref?.() + app.once('browser-window-created', (_event: Electron.Event, window: BrowserWindow) => { + window.once('ready-to-show', run) + }) +} diff --git a/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts b/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts new file mode 100644 index 00000000000..e35fe401cdc --- /dev/null +++ b/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts @@ -0,0 +1,442 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +// Hoisted with the vi.mock factory below. 'Keep Running' — the prompt firing at all is the signal. +const { showMessageBox, userData } = vi.hoisted(() => ({ + showMessageBox: vi.fn(async () => ({ response: 1 })), + userData: { path: '' } +})) + +// Why the mocks: gpu-lifecycle's import graph reaches electron and the toolkit's +// electron re-export. Everything below this is the real module under test. +vi.mock('electron', () => ({ + app: { + getPath: () => userData.path, + getVersion: () => '1.4.184', + getGPUFeatureStatus: () => ({}), + setAboutPanelOptions: vi.fn(), + commandLine: { appendSwitch: vi.fn() }, + disableHardwareAcceleration: vi.fn(), + isReady: () => true, + exit: vi.fn(), + on: vi.fn(), + name: 'Orca' + }, + dialog: { showMessageBox } +})) +vi.mock('@electron-toolkit/utils', () => ({ + is: { dev: false }, + optimizer: { watchWindowShortcuts: vi.fn() }, + electronApp: { setAppUserModelId: vi.fn() } +})) + +import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' +import { + DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, + DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + GpuCrashFallbackTracker +} from '../crash-reporting/gpu-crash-fallback-decision' +import { + readGpuFallbackMarker, + writeGpuFallbackMarker, + type GpuFallbackMarker +} from './gpu-fallback-marker' +import { handleGpuChildCrash, presentGpuFallbackRecoveredLaunchPrompt } from './gpu-lifecycle' +import { gpuFallbackEnvironment, mainProcessState as state } from './main-process-state' +import { writeInstallDirAclPoisonMarker } from './windows-install-dir-acl-poison-marker' +import { + isInstallDirAclRepairPending, + noteWindowsInstallDirAclProbePending, + repairKnownPoisonedInstallDirBeforeWindow, + resetWindowsInstallDirAclRecoveryForTest, + startWindowsInstallDirAclRepairIfPoisoned +} from './windows-install-dir-acl-recovery' +import { + resetWindowsInstallDirAclRepairForTest, + WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE, + WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION +} from './windows-install-dir-package-acl-repair' + +const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca' + +function recoveryOptions(userDataPath?: string): { + platform: 'win32' + installDir: string + appVersion: string + userDataPath: string + recordBreadcrumb: () => void +} { + return { + platform: 'win32', + installDir: INSTALL_DIR, + appVersion: '1.4.184', + userDataPath: userDataPath ?? mkdtempSync(join(tmpdir(), 'orca-acl-gpu-guard-')), + recordBreadcrumb: () => undefined + } +} + +/** icacls hangs until `finishRepair` — the in-flight window is when the GPU children die. */ +function reportProbePoisoned(): { finishRepair: () => Promise } { + let release = (): void => undefined + const walkingTheTree = new Promise((resolve) => { + release = resolve + }) + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'ok', matchesPoisonSignature: true, wellKnownNameCheckReliable: true }, + { + ...recoveryOptions(), + runProcessFn: (async () => { + await walkingTheTree + return { + code: 0, + signal: null, + stdout: 'Successfully processed 3200 files; Failed processing 0 files', + stderr: '', + timedOut: false + } + }) as unknown as (spec: ProcessSpec) => Promise + } + ) + return { + finishRepair: async () => { + release() + for (let i = 0; i < 200 && isInstallDirAclRepairPending(); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + } + } +} + +/** A repair that settles, so `poison.stage` leaves 'pending' for a terminal verdict. */ +async function reportProbePoisonedWithSettledRepair( + exitCode: number, + userDataPath?: string +): Promise { + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'ok', matchesPoisonSignature: true, wellKnownNameCheckReliable: true }, + { + ...recoveryOptions(userDataPath), + runProcessFn: (async () => ({ + code: exitCode, + signal: null, + stdout: 'Successfully processed 3200 files; Failed processing 0 files', + stderr: exitCode === 0 ? '' : 'access denied', + timedOut: false + })) as unknown as (spec: ProcessSpec) => Promise + } + ) + for (let i = 0; i < 200 && isInstallDirAclRepairPending(); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +/** + * The pre-window gate meeting a spent repair budget: the tree is still marked poisoned and + * Orca has no repair left to try. icacls must never be reached, so the runner throws. + */ +async function gateFindsRepairBudgetSpent(): Promise { + const options = recoveryOptions() + writeFileSync( + join(options.userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE), + JSON.stringify({ + schemeVersion: WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION, + installDir: INSTALL_DIR, + appVersion: options.appVersion, + attemptedAt: Date.now(), + outcome: 'failed', + attempts: 3 + }) + ) + writeInstallDirAclPoisonMarker(options.userDataPath, INSTALL_DIR, options.appVersion) + const mode = await repairKnownPoisonedInstallDirBeforeWindow({ + ...options, + runProcessFn: (() => { + throw new Error('the spent budget must not spawn icacls') + }) as never + }) + expect(mode).toBe('marker-hit') +} + +function reportProbeClean(): void { + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'ok', matchesPoisonSignature: false }, + recoveryOptions() + ) +} + +/** One short of the fallback threshold, so the caller's next crash is the decisive one. */ +async function crashUpToThreshold(): Promise { + for (let i = 1; i < DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD; i += 1) { + await handleGpuChildCrash('crashed', null, i * 200) + } +} + +/** + * Driven end-to-end against the real tracker rather than asserted against the source: + * a source match is equally happy with the polarity inverted, and the property that + * matters is that a driver burst survives the ACL verdict either way. + */ +describe('handleGpuChildCrash vs the install-dir ACL verdict', () => { + let tracker: GpuCrashFallbackTracker + const realPlatform = process.platform + + beforeAll(() => { + // The whole guard is win32-only, and so is the safe-graphics marker it writes. + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + }) + + afterAll(() => { + Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }) + }) + + beforeEach(() => { + userData.path = mkdtempSync(join(tmpdir(), 'orca-acl-gpu-userdata-')) + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + showMessageBox.mockClear() + state.isQuitting = false + state.isServeMode = false + state.gpuFallbackActiveThisLaunch = false + tracker = new GpuCrashFallbackTracker({ + windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD + }) + state.gpuCrashFallbackTracker = tracker + }) + + it('engages safe graphics on a driver burst when nothing implicates the install DACL', async () => { + await crashUpToThreshold() + await handleGpuChildCrash('crashed', null, 600) + expect(showMessageBox).toHaveBeenCalledTimes(1) + }) + + // The regression this guard must never reintroduce: the probe is armed on every + // win32 launch, so a burst landing inside its window is the common driver case. + it('keeps counting crashes that land while the probe verdict is outstanding', async () => { + noteWindowsInstallDirAclProbePending() + await crashUpToThreshold() + const decisive = handleGpuChildCrash('crashed', null, 600) + expect(showMessageBox).not.toHaveBeenCalled() + reportProbeClean() + await decisive + expect(tracker.windowSnapshot()).toHaveLength(DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD) + expect(showMessageBox).toHaveBeenCalledTimes(1) + }) + + it('withholds safe graphics while the install DACL is the suspect, but keeps the evidence', async () => { + reportProbePoisoned() + await crashUpToThreshold() + await handleGpuChildCrash('crashed', null, 600) + expect(tracker.windowSnapshot()).toHaveLength(DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD) + expect(showMessageBox).not.toHaveBeenCalled() + }) + + it('withholds safe graphics when the outstanding verdict comes back poisoned', async () => { + noteWindowsInstallDirAclProbePending() + await crashUpToThreshold() + const decisive = handleGpuChildCrash('crashed', null, 600) + reportProbePoisoned() + await decisive + expect(showMessageBox).not.toHaveBeenCalled() + }) + + // The gate's 'repaired' is icacls's exit claim, not a reading of the tree, and an icacls + // that silently no-opped exits 0 on a tree it left poisoned. The GPU children die in the + // interval before this launch's probe answers, so a claim that un-suspects the tree there + // engages --in-process-gpu on a tree safe graphics cannot rescue — and a "keep it" answer + // then pins a userConfirmed marker no later repair may clear. + it('withholds safe graphics between a gate repair claim and this launch probe reading', async () => { + writeInstallDirAclPoisonMarker(userData.path, INSTALL_DIR, '1.4.184') + const mode = await repairKnownPoisonedInstallDirBeforeWindow({ + ...recoveryOptions(userData.path), + runProcessFn: (async () => ({ + code: 0, + signal: null, + stdout: 'Successfully processed 3200 files; Failed processing 0 files', + stderr: '', + timedOut: false + })) as unknown as (spec: ProcessSpec) => Promise + }) + expect(mode).toBe('repaired') + noteWindowsInstallDirAclProbePending() + + await crashUpToThreshold() + const decisive = handleGpuChildCrash('crashed', null, 600) + expect(showMessageBox).not.toHaveBeenCalled() + + // The reading lands poisoned: the claim was false, and engagement stays withheld. + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'ok', matchesPoisonSignature: true, wellKnownNameCheckReliable: true }, + recoveryOptions(userData.path) + ) + await decisive + expect(showMessageBox).not.toHaveBeenCalled() + }) + + // Chromium aborts the browser on the 6th GPU crash, sooner than the probe can answer, + // so the wait must not be the reason a machine comes back hardware-accelerated. + it('holds an unconfirmed safe-graphics marker on disk across the wait', async () => { + noteWindowsInstallDirAclProbePending() + await crashUpToThreshold() + const decisive = handleGpuChildCrash('crashed', null, 600) + expect(readGpuFallbackMarker(userData.path)?.userConfirmed).toBe(false) + reportProbePoisoned() + await decisive + // The verdict dispatched a repair, so the marker stays for the launch that repair rescues. + expect(readGpuFallbackMarker(userData.path)?.userConfirmed).toBe(false) + }) + + it('engages immediately once the probe has already reported the install clean', async () => { + noteWindowsInstallDirAclProbePending() + reportProbeClean() + await crashUpToThreshold() + await handleGpuChildCrash('crashed', null, 600) + expect(showMessageBox).toHaveBeenCalledTimes(1) + }) + + // Both from the re-run adversarial round. The gate dispatches a repair without arming the + // probe clock, so `waitForInstallDirAclVerdict` returns immediately and the withdrawal used + // to delete the marker inside Chromium's ~1.3s FATAL window — leaving the machine to + // relaunch hardware accelerated into the same 20s gate, forever. + it('keeps the safe-graphics marker on disk while a repair is still in flight', async () => { + reportProbePoisoned() + await crashUpToThreshold() + await handleGpuChildCrash('crashed', null, 600) + + expect(showMessageBox).not.toHaveBeenCalled() + expect(readGpuFallbackMarker(userData.path)?.userConfirmed).toBe(false) + }) + + it('still withdraws the marker once the verdict is terminal rather than a pending repair', async () => { + await reportProbePoisonedWithSettledRepair(1) + await crashUpToThreshold() + await handleGpuChildCrash('crashed', null, 600) + + expect(showMessageBox).not.toHaveBeenCalled() + // No repair is in flight to rescue a later launch, so the marker is not held. + expect(readGpuFallbackMarker(userData.path)).toBeNull() + }) + + // The verdict wait can span the probe's whole 15s grace window, and the entry guard was + // read before it. A quit that starts inside the wait must not be answered with a modal. + it('does not prompt when the user quits during the verdict wait', async () => { + noteWindowsInstallDirAclProbePending() + await crashUpToThreshold() + const decisive = handleGpuChildCrash('crashed', null, 600) + state.isQuitting = true + reportProbeClean() + await decisive + expect(showMessageBox).not.toHaveBeenCalled() + }) + + // Withholding is a bounded delay, not a permanent suppression. Once the repair budget is + // spent no repair is coming on this launch or any later one, so pinning the tree as the + // suspect forever denied safe graphics on EVERY launch for the life of that version — and + // deleted the marker each time, so the machine also relaunched hardware accelerated. The + // victims are a standard-user install icacls can never fix and, via the probe's flag-blind + // ACE match, healthy installs whose driver genuinely is broken. + it('offers safe graphics on every launch once the ACL repair budget is spent', async () => { + for (let launch = 1; launch <= 3; launch += 1) { + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + showMessageBox.mockClear() + state.gpuCrashFallbackTracker = new GpuCrashFallbackTracker({ + windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD + }) + await gateFindsRepairBudgetSpent() + + await crashUpToThreshold() + await handleGpuChildCrash('crashed', null, 600) + expect(showMessageBox).toHaveBeenCalledTimes(1) + } + }) + + // Still withheld while the budget has an attempt left: the repair is the better answer, + // and this is the launch a next one can be rescued on. + it('still withholds while the repair has an attempt left to spend', async () => { + await reportProbePoisonedWithSettledRepair(1) + await crashUpToThreshold() + await handleGpuChildCrash('crashed', null, 600) + expect(showMessageBox).not.toHaveBeenCalled() + }) + + // recordGpuCrash reports the threshold crossing once and latches. Withholding consumes + // that one report, so without a re-arm the same process could never engage again — a + // machine whose tree is repaired and whose driver is genuinely broken would be stuck + // hardware-accelerated through an unbounded crash loop. + it('can still engage a later burst after a withheld one, once the tree is repaired', async () => { + const repair = reportProbePoisoned() + await crashUpToThreshold() + await handleGpuChildCrash('crashed', null, 600) + expect(showMessageBox).not.toHaveBeenCalled() + + // The repair itself reports 'repaired': the tree is no longer the suspect. + await repair.finishRepair() + expect(isInstallDirAclRepairPending()).toBe(false) + + for (let i = 1; i <= DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD; i += 1) { + await handleGpuChildCrash('crashed', null, 10_000 + i * 200) + } + expect(showMessageBox).toHaveBeenCalledTimes(1) + }) +}) + +// The safe-graphics marker is read before whenReady, and the pre-window ACL gate runs after +// that read. Asking "keep safe graphics?" on a machine Orca has just repaired invites a +// `userConfirmed: true` marker that pins software rendering on healthy hardware. +describe('presentGpuFallbackRecoveredLaunchPrompt vs a marker retired since it was read', () => { + const realPlatform = process.platform + const window = { isDestroyed: () => false } as unknown as Parameters< + typeof presentGpuFallbackRecoveredLaunchPrompt + >[0] + + beforeAll(() => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + }) + + afterAll(() => { + Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }) + }) + + beforeEach(() => { + userData.path = mkdtempSync(join(tmpdir(), 'orca-acl-gpu-recovered-')) + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + showMessageBox.mockClear() + state.isQuitting = false + const info = { engagedAt: Date.now(), crashesInWindow: 3, userConfirmed: false } + writeGpuFallbackMarker(userData.path, info, { + ...gpuFallbackEnvironment(), + platform: 'win32' + }) + state.activeGpuFallbackMarker = readGpuFallbackMarker(userData.path) as GpuFallbackMarker + }) + + it('asks while the marker is still on disk', async () => { + showMessageBox.mockResolvedValueOnce({ response: 0 }) + await presentGpuFallbackRecoveredLaunchPrompt(window) + expect(showMessageBox).toHaveBeenCalledTimes(1) + }) + + it('stays silent once the install-DACL repair has cleared it', async () => { + await reportProbePoisonedWithSettledRepair(0, userData.path) + expect(readGpuFallbackMarker(userData.path)).toBeNull() + + await presentGpuFallbackRecoveredLaunchPrompt(window) + expect(showMessageBox).not.toHaveBeenCalled() + }) + + // The symmetric case to the one above: a FAILED repair leaves the marker on disk and the + // tree a live suspect, the window the prompt lands on is blank, and Keep is both defaultId + // and cancelId — so asking invites a userConfirmed pin no later repair may clear. + it('stays silent while the install DACL is still the suspect', async () => { + await reportProbePoisonedWithSettledRepair(1, userData.path) + expect(readGpuFallbackMarker(userData.path)).not.toBeNull() + + await presentGpuFallbackRecoveredLaunchPrompt(window) + expect(showMessageBox).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/startup/gpu-lifecycle.ts b/src/main/startup/gpu-lifecycle.ts index da852bcb491..89055aa2ba3 100644 --- a/src/main/startup/gpu-lifecycle.ts +++ b/src/main/startup/gpu-lifecycle.ts @@ -16,6 +16,12 @@ import { promptForGpuFallbackRestart } from '../crash-reporting/gpu-fallback-res import { engageGpuFallbackAfterCrashBurst } from '../crash-reporting/gpu-fallback-engagement' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { + isInstallDirAclRepairExhausted, + isInstallDirAclRepairPending, + isInstallDirAclSuspect, + waitForInstallDirAclVerdict +} from './windows-install-dir-acl-recovery' import { mainProcessState as state, gpuFallbackEnvironment } from './main-process-state' import { createGpuAccelerationAboutPanelOptions } from '../menu/gpu-acceleration-about-panel' @@ -85,6 +91,18 @@ export async function presentGpuFallbackRecoveredLaunchPrompt( // One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries. state.activeGpuFallbackMarker = null const userDataPath = app.getPath('userData') + // The marker was read before whenReady; the pre-window ACL gate can have retired it since. + // Asking then would let a "keep it" answer pin software rendering on a machine Orca just fixed. + if (!readActiveGpuFallbackMarker(userDataPath, gpuFallbackEnvironment())) { + return + } + // The symmetric case: while the tree, not the driver, is on trial (a failed gate leaves it + // a live suspect), a "keep it" answer would pin a userConfirmed marker no later repair may + // clear — on the window the poison keeps blank. Staying silent leaves the marker + // unconfirmed, which a successful repair still retires. + if (isInstallDirAclSuspect()) { + return + } await handleGpuFallbackRecoveredLaunch({ isQuitting: () => state.isQuitting, prompt: () => promptForGpuFallbackRecoveredLaunch(window), @@ -114,6 +132,66 @@ export async function presentGpuFallbackRecoveredLaunchPrompt( }) } +/** + * Why withholding ends with the repair budget: withholding only buys the ACL repair the + * chance to land first. Once its attempts are spent no repair is coming on this launch or + * any later one, so holding safe graphics back forever would deny the only recovery left — + * on a genuinely poisoned tree Orca has already told the user the admin commands, and the + * probe's flag-blind ACE match also over-matches healthy installs whose driver really is + * the fault. It is a bounded delay, not a permanent suppression. + */ +function installDirAclWithholdsGpuFallback(): boolean { + return isInstallDirAclSuspect() && !isInstallDirAclRepairExhausted() +} + +/** + * Why: a poisoned install DACL kills the GPU child exactly like a bad driver, but safe + * graphics does not rescue it and --in-process-gpu removes the GPU child, erasing the + * sibling deaths that identify the real cause. + * + * Why the marker is written before the wait rather than after: Chromium aborts the whole + * browser process on the 6th GPU crash, ~1.3s after the 3rd — less than the probe takes + * to answer — so a machine that dies waiting must still come back software-rendered. + * The withdrawal below, and the repair's own clear of an unconfirmed marker, undo it. + */ +async function installDirAclClearsGpuFallback( + userDataPath: string, + crashesInWindow: number +): Promise { + if (!installDirAclWithholdsGpuFallback()) { + return true + } + const persisted = persistGpuFallbackMarker(userDataPath, { + engagedAt: Date.now(), + crashesInWindow, + userConfirmed: false + }) + await waitForInstallDirAclVerdict() + if (!installDirAclWithholdsGpuFallback()) { + return true + } + // Why the marker survives a pending repair: withdrawing it here left a machine that + // Chromium FATALs mid-repair (crash 6 lands ~1.3s after crash 3, well inside the gate) + // relaunching hardware accelerated into the same 20s gate, spawning the same GPU children, + // FATALing again — with no attempt spent, so the loop never advances. Keeping it costs a + // healthy machine nothing: a successful repair clears an unconfirmed marker itself, and a + // clean probe reading means we never reach here. It is still not *engaged* this launch, so + // --in-process-gpu does not erase the sibling-death evidence on the launch that is running. + const repairPending = isInstallDirAclRepairPending() + if (persisted && !repairPending) { + clearGpuFallbackMarker(userDataPath) + } + // Why re-arm: recordGpuCrash reports the threshold crossing once and latches. Withholding + // consumed that one report, so without this a later burst — including one after the repair + // succeeds and the tree is no longer the suspect — could never engage safe graphics again. + state.gpuCrashFallbackTracker.disengage() + recordDurableCrashBreadcrumb('gpu_fallback_withheld_install_dir_acl', { + crashesInWindow, + markerHeldForPendingRepair: repairPending + }) + return false +} + // Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering. export async function handleGpuChildCrash( reason: string, @@ -124,12 +202,23 @@ export async function handleGpuChildCrash( if (state.gpuFallbackActiveThisLaunch || state.isQuitting || state.isServeMode) { return } + // Recorded before any install-DACL consideration: the verdict decides whether safe + // graphics is the right answer, never whether the crash happened. Dropping it here + // would erase a real driver burst from the rolling window on healthy machines too. const result = state.gpuCrashFallbackTracker.recordGpuCrash(crashedAt) if (!result.shouldEngageFallback) { return } const fallbackData = { processReason: reason, exitCode, crashesInWindow: result.crashesInWindow } const userDataPath = app.getPath('userData') + if (!(await installDirAclClearsGpuFallback(userDataPath, result.crashesInWindow))) { + return + } + // Re-read after that wait: it can span the probe's whole grace window, and a quit that + // started inside it must not be answered with a modal and a relaunch. + if (state.isQuitting) { + return + } await engageGpuFallbackAfterCrashBurst( { reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() }, { diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index e122961b0c9..171aaf50421 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -139,7 +139,22 @@ export async function initializeReadyFoundation(): Promise { }) state.store = store // Why: create pending readiness before the guard can observe the default session. - const initialProxyApplication = applyElectronProxySettings(store.getSettings()) + // Why parked on state instead of awaited here: Dock/Launchpad launches don't inherit shell + // proxy env vars, so the persisted proxy must land before any app-owned network fetcher runs — + // but the guard below already holds every default-session request until this settles, so + // awaiting it inline only delayed window creation. Runtime launch awaits it before the first + // fetcher (the desktop relay / headless serve). + state.initialProxyApplicationReady = applyElectronProxySettings(store.getSettings()).then( + (result) => { + if (result.source === 'invalid-settings') { + // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. + console.warn('[proxy] persisted proxy settings are invalid; using direct networking') + } + }, + () => { + console.warn('[proxy] Failed to apply network proxy settings') + } + ) installElectronProxyRequestGuard(session.defaultSession) // Why armed here and not at install time: the report remembers what it last said, and // that state lives beside the profile data file, which does not exist until now. @@ -235,16 +250,6 @@ export async function initializeReadyFoundation(): Promise { if (shouldSuppressDevEducation({ isDev: is.dev })) { suppressDevEducationForStore(store) } - try { - // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. - const proxyApplyResult = await initialProxyApplication - if (proxyApplyResult.source === 'invalid-settings') { - // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. - console.warn('[proxy] persisted proxy settings are invalid; using direct networking') - } - } catch { - console.warn('[proxy] Failed to apply network proxy settings') - } // Why: the partition installer reads the proxy through this resolver, so register it before sessions materialize. setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings()) // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. diff --git a/src/main/startup/main-process-ready-phase-ordering.test.ts b/src/main/startup/main-process-ready-phase-ordering.test.ts new file mode 100644 index 00000000000..749eb56cb0f --- /dev/null +++ b/src/main/startup/main-process-ready-phase-ordering.test.ts @@ -0,0 +1,153 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const phaseEvents: string[] = [] +let releaseI18n: (() => void) | null = null + +vi.mock('./main-process-ready-foundation', () => ({ + initializeReadyFoundation: vi.fn(async () => { + phaseEvents.push('foundation') + }) +})) +vi.mock('./main-process-ready-runtime', () => ({ + initializeReadyRuntimeServices: vi.fn(async () => { + phaseEvents.push('runtime-services') + }) +})) +vi.mock('./main-process-i18n-menu', () => ({ + initializeMainProcessI18nAndMenu: vi.fn( + () => + new Promise((resolve) => { + phaseEvents.push('i18n-start') + releaseI18n = () => { + phaseEvents.push('i18n-done') + resolve() + } + }) + ) +})) +vi.mock('./main-process-runtime-launch', () => ({ + initializeMainProcessRuntimeLaunch: vi.fn(async () => { + phaseEvents.push('launch-start') + await Promise.resolve() + phaseEvents.push('window-created') + }) +})) + +const { initializeMainProcessReady } = await import('./main-process-ready') + +describe('ready-phase concurrency', () => { + beforeEach(() => { + phaseEvents.length = 0 + releaseI18n = null + }) + + it('creates the window without waiting for i18n and the native menu', async () => { + const options = { + openMainWindow: vi.fn(), + handleMacAppActivation: vi.fn() + } as unknown as Parameters[0] + + const ready = initializeMainProcessReady(options) + // Drain the launch phase's microtasks while i18n is still pending. + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } + + expect(phaseEvents).toEqual([ + 'foundation', + 'runtime-services', + 'i18n-start', + 'launch-start', + 'window-created' + ]) + + releaseI18n?.() + await ready + expect(phaseEvents.at(-1)).toBe('i18n-done') + }) + + it('still resolves only once i18n and the menu have settled', async () => { + const options = { + openMainWindow: vi.fn(), + handleMacAppActivation: vi.fn() + } as unknown as Parameters[0] + + const ready = initializeMainProcessReady(options) + let settled = false + void ready.then(() => { + settled = true + }) + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } + + expect(settled).toBe(false) + releaseI18n?.() + await ready + expect(settled).toBe(true) + }) +}) + +describe('initial proxy application ordering', () => { + const readStartupSource = (file: string): string => + readFileSync(join(process.cwd(), 'src/main/startup', file), 'utf8') + + it('parks the default-session proxy apply instead of blocking window creation on it', () => { + const foundation = readStartupSource('main-process-ready-foundation.ts') + + expect(foundation).toContain('state.initialProxyApplicationReady = applyElectronProxySettings(') + // The request guard, not this phase, is what fences fetchers on the proxy; awaiting it here + // only queued openMainWindow behind a ~24 ms setProxy round trip. + expect(foundation).not.toMatch(/await\s+(?:state\.)?initialProxyApplication/) + }) + + it('awaits the proxy after the window opens and before the desktop relay starts', () => { + const launch = readStartupSource('main-process-runtime-launch.ts') + const desktopStart = launch.indexOf('async function launchDesktopMode(') + const desktopEnd = launch.indexOf('\nexport async function initializeMainProcessRuntimeLaunch') + expect(desktopStart).toBeGreaterThanOrEqual(0) + expect(desktopEnd).toBeGreaterThan(desktopStart) + const desktop = launch.slice(desktopStart, desktopEnd) + + const windowIndex = desktop.indexOf('openMainWindow()') + const proxyIndex = desktop.indexOf('await state.initialProxyApplicationReady') + const relayIndex = desktop.indexOf('new DesktopRelayService(') + + expect(windowIndex).toBeGreaterThanOrEqual(0) + expect(proxyIndex).toBeGreaterThan(windowIndex) + expect(relayIndex).toBeGreaterThan(proxyIndex) + }) + + it('waits for i18n before the only launch-phase dialog that reads a translated string', () => { + const ready = readStartupSource('main-process-ready.ts') + const launch = readStartupSource('main-process-runtime-launch.ts') + + // Published before the launch phase starts, or the barrier the dialog awaits is still the + // default resolved promise. + const publishIndex = ready.indexOf('state.mainProcessI18nReady = ') + expect(publishIndex).toBeGreaterThanOrEqual(0) + expect(ready.indexOf('initializeMainProcessRuntimeLaunch(options)')).toBeGreaterThan( + publishIndex + ) + expect(launch).toMatch( + /state\.mainProcessI18nReady\.then\(\(\) =>\s*\n?\s*showRuntimeRpcStartupFailureDialog\(/ + ) + }) + + it('keeps headless serve strictly ordered behind the proxy apply', () => { + const launch = readStartupSource('main-process-runtime-launch.ts') + const serveStart = launch.indexOf('async function launchServeMode(') + const serveEnd = launch.indexOf('\nasync function launchDesktopMode(', serveStart) + expect(serveStart).toBeGreaterThanOrEqual(0) + expect(serveEnd).toBeGreaterThan(serveStart) + const serve = launch.slice(serveStart, serveEnd) + + const proxyIndex = serve.indexOf('await state.initialProxyApplicationReady') + const rpcIndex = serve.indexOf('runtimeRpc.start()') + + expect(proxyIndex).toBeGreaterThanOrEqual(0) + expect(rpcIndex).toBeGreaterThan(proxyIndex) + }) +}) diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index f89f63186a0..26b920652df 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -9,7 +9,7 @@ import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { browserManager } from '../browser/browser-manager' import { configureBrowserClientPageAutomationRuntime } from '../browser/browser-client-page-automation-runtime' import { BrowserClientPageCommandError } from '../browser/browser-client-page-command-failure' -import { startPreGoneProcessMetricsSampling } from '../crash-reporting/process-gone-diagnostics' +import { startPreGoneCrashSampling } from '../crash-reporting/process-gone-diagnostics' import { recordProcessGoneCrash } from './main-window-lifecycle-flags' import { handleGpuChildCrash } from './gpu-lifecycle' import { isGpuFallbackCrashCandidate } from '../crash-reporting/gpu-crash-fallback-decision' @@ -32,8 +32,12 @@ import { import { initializeMainProcessAutomations } from './main-process-automations' import { initializeMainProcessPlugins } from './main-process-plugins' import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from '../worktree-trash' +import { runAfterFirstWindowShown } from './first-window-deferral' import { logStartupMilestone } from './startup-diagnostics' +// Headless serve never opens a window, so the sweep still has to run off a timer there. +const WORKTREE_TRASH_SWEEP_FALLBACK_MS = 15_000 + export async function initializeReadyRuntimeServices(): Promise { const store = state.store if (!store) { @@ -74,12 +78,16 @@ export async function initializeReadyRuntimeServices(): Promise { state.emulatorBridge = new EmulatorBridge() runtime.setEmulatorBridge(state.emulatorBridge) // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or - // crash mid-delete can leave the moved directory on disk. - void sweepStaleWorktreeTrash( - collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) - ).catch((error) => { - console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) - }) + // crash mid-delete can leave the moved directory on disk. Why deferred: the sweep's recursive + // readdir/rm runs on the same libuv threadpool the window's first paint and worktree-catalog + // hydration are reading disk on, and nothing on the startup path consumes its result. + runAfterFirstWindowShown(() => { + void sweepStaleWorktreeTrash( + collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) + ).catch((error) => { + console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) + }) + }, WORKTREE_TRASH_SWEEP_FALLBACK_MS) nativeTheme.themeSource = store.getSettings().theme ?? 'system' // Why (#16441): the real-home grant runs a codex app-server session. It stays // ordered before managed-hook reconciliation — an incapable host must re-arm @@ -122,9 +130,10 @@ export async function initializeReadyRuntimeServices(): Promise { console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) ) } - // Why: process-gone metrics only see survivors; retain a recent whole-app - // snapshot for comparison in crash reports. - startPreGoneProcessMetricsSampling() + // Why: process-gone metrics only see survivors, and the gone-time host memory + // read lands after the corpse released its pages; both need a live pre-gone + // sample to compare against in crash reports. + startPreGoneCrashSampling() app.on('child-process-gone', (_event, details) => { recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { name: details.name, diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts index e6d8d782e6e..835c1f1dd13 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -1,4 +1,5 @@ import { initializeMainProcessI18nAndMenu } from './main-process-i18n-menu' +import { mainProcessState as state } from './main-process-state' import { initializeReadyFoundation } from './main-process-ready-foundation' import { initializeReadyRuntimeServices } from './main-process-ready-runtime' import { @@ -12,6 +13,10 @@ export async function initializeMainProcessReady( ): Promise { await initializeReadyFoundation() await initializeReadyRuntimeServices() - await initializeMainProcessI18nAndMenu() - await initializeMainProcessRuntimeLaunch(options) + // Why concurrent: window creation reads no translated string and no menu item, and both the + // native menu and the tray only become reachable once the window shows — so serializing them + // ahead of openMainWindow only delayed the renderer (8 ms in English, more for a lazy locale). + const i18nAndMenuReady = initializeMainProcessI18nAndMenu() + state.mainProcessI18nReady = i18nAndMenuReady.catch(() => {}) + await Promise.all([i18nAndMenuReady, initializeMainProcessRuntimeLaunch(options)]) } diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 9df28ecea8c..5f2691d6f31 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -24,6 +24,7 @@ import { import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup' +import { repairKnownPoisonedInstallDirBeforeWindow } from './windows-install-dir-acl-recovery' import { registerServeSignalHandlers } from './serve-signal-handlers' import { settleServeDesktopActivation } from './serve-desktop-activation' import { @@ -120,6 +121,9 @@ async function launchServeMode( runtimeRpc: OrcaRuntimeRpcServer, serveOptions: NonNullable> ): Promise { + // Why here: headless serve has no window to unblock, so keep the persisted proxy strictly + // ahead of every fetcher this phase can reach (relay, CLI install, RPC clients). + await state.initialProxyApplicationReady // Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness. logStartupMilestone('wsl-cli-barrier-start') await state.managedWslCliStartupBarrierReady @@ -226,8 +230,17 @@ async function launchDesktopMode( ) ]) if (!runtimeRpcStartResult.ok) { - void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + // Why gated: this dialog is the only launch-phase text read through translateMain, and i18n + // now settles alongside this phase — without the wait a non-English user could get the + // English defaultValue fallback. Still off the renderer's path (it is failure-only). + void state.mainProcessI18nReady.then(() => + showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + ) } + // Why after the window and not before it: the default-session request guard already holds every + // fetcher until the persisted proxy lands, so this only has to keep the launch phase itself + // ordered ahead of the relay — it must not gate the renderer. + await state.initialProxyApplicationReady const cloudAuth = getOrcaCloudAuthConfig() if (cloudAuth.configured) { try { @@ -292,6 +305,17 @@ export async function initializeMainProcessRuntimeLaunch( // Why published: the renderer's git-environment barrier must fence on the same // generation the terminal startup services wait for, not a later re-read. state.shellPathReady = shellPathReady + // Why before any window: the poisoned install DACL kills the renderer at init, and + // the probe that detects it cannot finish before createMainWindow. Bounded, and a + // no-op (one absent-file read) unless a previous launch already recorded the verdict. + const aclGate = await repairKnownPoisonedInstallDirBeforeWindow({ + isServeMode: state.isServeMode || serveOptions !== null, + userDataPath: app.getPath('userData'), + appVersion: app.getVersion() + }) + if (aclGate !== 'not-marked' && aclGate !== 'skipped') { + logStartupMilestone('install-dir-acl-repair-blocking-done', { mode: aclGate }) + } let desktopWindow: BrowserWindow | null = null if (process.platform === 'win32' && app.isPackaged && !serveOptions) { const desktopStartup = startWindowsDesktopBeforeShellPathReady({ diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index d48219b461e..c88d5a66c48 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -100,6 +100,13 @@ export const mainProcessState = { // Electron with no error. Only the renderer's own pull proves the listener is live. markdownFileOpenListenerReady: false, firstWindowStartupServicesReady: Promise.resolve(), + // Why published: the default-session proxy must be applied before the first app-owned fetcher, + // but window creation has no reason to queue behind it (the request guard already fences it). + initialProxyApplicationReady: Promise.resolve(), + // Why published: i18n/menu init no longer precedes the launch phase, so the one launch-phase + // path that reads a translated string (the runtime-RPC startup failure dialog) waits on this. + // Never rejects: the phase's own failure is surfaced by initializeMainProcessReady. + mainProcessI18nReady: Promise.resolve(), managedWslCliReconciliationReady: Promise.resolve(), managedWslCliStartupBarrierReady: Promise.resolve(), // Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op). diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts index 96751d645db..585fa0a754e 100644 --- a/src/main/startup/main-window-actions.ts +++ b/src/main/startup/main-window-actions.ts @@ -12,7 +12,10 @@ import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-servic import { focusExistingMainWindow, safelyRevealWindow } from '../window/focus-existing-window' import { mainProcessState as state } from './main-process-state' import { loadMainWindow } from '../window/createMainWindow' -import { describeInstallDirAclPoison } from './windows-install-dir-acl-recovery' +import { + describeInstallDirAclPoison, + isBlockingInstallDirAclRepairInFlight +} from './windows-install-dir-acl-recovery' import { presentRendererRecoveryPrompt } from '../window/renderer-recovery-prompt' // The window module injects this callback to avoid a cycle between actions and lifecycle code. @@ -28,6 +31,9 @@ export function focusExistingWindow(): void { app, getWindow: () => state.mainWindow, openWindow, + // Why: a 20s blank launch invites a second double-click, and icacls is rewriting + // the per-file DACLs a fresh renderer would read. The gated launch opens the window. + canOpenWindow: () => !isBlockingInstallDirAclRepairInFlight(), warn: console.warn }) } diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 0d935d5f84a..63d84df763c 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -10,7 +10,10 @@ import { resolveConsent } from '../telemetry/consent' import { trackAppOpenedOnce } from '../telemetry/client' import { ensureWindowsUserDataAclGrant } from './windows-user-data-acl' import { probeWindowsInstallDirAcl } from './windows-install-dir-acl-probe' -import { startWindowsInstallDirAclRepairIfPoisoned } from './windows-install-dir-acl-recovery' +import { + noteWindowsInstallDirAclProbePending, + startWindowsInstallDirAclRepairIfPoisoned +} from './windows-install-dir-acl-recovery' import { logStartupMilestone } from './startup-diagnostics' import { notifyMainWindowBecameVisible } from '../window/main-window-visibility' import { setTrayAttention } from '../tray/system-tray' @@ -74,7 +77,7 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} }) // Why here: read-only, and the install DACL is the one thing a 0x80000003 // child death cannot tell us about itself. See electron/electron#51761. - probeWindowsInstallDirAcl({ + const probeDispatched = probeWindowsInstallDirAcl({ isServeMode: state.isServeMode, onDone: (data) => startWindowsInstallDirAclRepairIfPoisoned(data, { @@ -83,6 +86,12 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} appVersion: app.getVersion() }) }) + // Why gated on the dispatch: the probe is once-per-process while openMainWindow + // re-runs on every reopen, so arming this again would wait on a verdict that + // already landed — and drop every GPU crash for the grace window. + if (probeDispatched) { + noteWindowsInstallDirAclProbePending() + } } const window = createMainWindow(store, { getIsQuitting: () => state.isQuitting, diff --git a/src/main/startup/pre-gone-crash-sampling-wiring.test.ts b/src/main/startup/pre-gone-crash-sampling-wiring.test.ts new file mode 100644 index 00000000000..2a8e008c0b3 --- /dev/null +++ b/src/main/startup/pre-gone-crash-sampling-wiring.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guards the one line that arms pre-gone crash sampling. + * + * That branch is pure instrumentation, so this line is the whole of its value in + * the shipped app: deleting it left all 691 tests across `src/main/crash-reporting/` + * and `src/main/startup/` green while every crash report silently lost its only + * host reading taken before the dying process returned its pages. + * + * Source-level because that is the property: the sampler is armed once inside the + * ready-phase composition, which has no runtime seam to assert against. + */ +describe('pre-gone crash sampling startup wiring', () => { + // Why normalize: the indent anchors below are `\n`-prefixed, and nothing pins + // src/**/*.ts to LF, so a CRLF Windows checkout would fail them spuriously. + const readSource = (name: string): string => + readFileSync(join(process.cwd(), 'src/main/startup', name), 'utf8').replace(/\r\n/g, '\n') + + const readyRuntimeSource = readSource('main-process-ready-runtime.ts') + const readySource = readSource('main-process-ready.ts') + + const READY_ENTRY = 'export async function initializeReadyRuntimeServices(' + // Why the entry's body and not the file: the call satisfies a whole-file grep + // just as well from a sibling export nothing calls, which arms nothing. + const readyRuntimeEntryBody = readyRuntimeSource + .slice(readyRuntimeSource.indexOf(READY_ENTRY) + READY_ENTRY.length) + .split('\nexport ')[0] + + it('arms the sampler unconditionally inside the function app readiness runs', () => { + expect(readyRuntimeSource).toContain( + "import { startPreGoneCrashSampling } from '../crash-reporting/process-gone-diagnostics'" + ) + expect(readyRuntimeSource).toContain(READY_ENTRY) + expect(readyRuntimeEntryBody.split('startPreGoneCrashSampling()').length - 1).toBe(1) + // Why pin the indent: the call also matches as the body of an added + // `if (...)` guard, which keeps every other assertion here true while the + // sampler silently stops arming on most startups. + expect(readyRuntimeEntryBody).toContain('\n startPreGoneCrashSampling()') + + // ...and that this really is the function app readiness runs. + expect(readySource).toContain( + "import { initializeReadyRuntimeServices } from './main-process-ready-runtime'" + ) + expect(readySource).toContain('\n await initializeReadyRuntimeServices()') + }) +}) diff --git a/src/main/startup/windows-install-dir-acl-poison-marker.ts b/src/main/startup/windows-install-dir-acl-poison-marker.ts new file mode 100644 index 00000000000..46035e04e74 --- /dev/null +++ b/src/main/startup/windows-install-dir-acl-poison-marker.ts @@ -0,0 +1,77 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * "This install directory was found poisoned and has not been proven healthy since." + * + * Why a separate marker from `windows-install-dir-acl-repair.json`: that one is + * written after an attempt finishes, so a launch the poison kills mid-repair + * leaves no state at all and the next launch repeats the whole late-repair dance. + * This one is written the moment the probe's verdict lands, and it is the only + * thing that lets a later launch know it is poisoned *before* it creates a window + * — the probe itself cannot answer that early. Same tiny synchronous-JSON shape + * as `gpu-fallback-marker.ts`, for the same reason. + */ + +export const WINDOWS_INSTALL_DIR_ACL_POISON_MARKER_FILE = 'windows-install-dir-acl-poison.json' +export const WINDOWS_INSTALL_DIR_ACL_POISON_SCHEME_VERSION = 1 + +type PoisonMarker = { + schemeVersion: number + installDir: string + appVersion: string + detectedAt: number +} + +function markerPath(userDataPath: string): string { + return join(userDataPath, WINDOWS_INSTALL_DIR_ACL_POISON_MARKER_FILE) +} + +/** Keyed on both: a reinstall elsewhere or an update ships files with a fresh DACL. */ +export function hasInstallDirAclPoisonMarker( + userDataPath: string, + installDir: string, + appVersion: string +): boolean { + try { + const parsed = JSON.parse(readFileSync(markerPath(userDataPath), 'utf-8')) as + | Partial + | undefined + return ( + parsed?.schemeVersion === WINDOWS_INSTALL_DIR_ACL_POISON_SCHEME_VERSION && + parsed.installDir === installDir && + parsed.appVersion === appVersion + ) + } catch { + return false // missing or corrupt -> treat the install as healthy + } +} + +export function writeInstallDirAclPoisonMarker( + userDataPath: string, + installDir: string, + appVersion: string +): void { + const marker: PoisonMarker = { + schemeVersion: WINDOWS_INSTALL_DIR_ACL_POISON_SCHEME_VERSION, + installDir, + appVersion, + detectedAt: Date.now() + } + try { + if (!existsSync(userDataPath)) { + mkdirSync(userDataPath, { recursive: true }) + } + writeFileSync(markerPath(userDataPath), JSON.stringify(marker)) + } catch { + // Best effort: without it the next launch just falls back to today's late repair. + } +} + +export function clearInstallDirAclPoisonMarker(userDataPath: string): void { + try { + rmSync(markerPath(userDataPath), { force: true }) + } catch { + // Best effort; a stale marker only costs one redundant icacls pass. + } +} diff --git a/src/main/startup/windows-install-dir-acl-probe.ts b/src/main/startup/windows-install-dir-acl-probe.ts index 42db3ee3d10..25d1e581c9b 100644 --- a/src/main/startup/windows-install-dir-acl-probe.ts +++ b/src/main/startup/windows-install-dir-acl-probe.ts @@ -211,13 +211,16 @@ export function resetWindowsInstallDirAclProbeForTest(): void { * Fire-and-forget; returns before any spawn. win32 only — no spawn and no fs I/O * anywhere else. Called from openMainWindow, which runs after initObservability, * so the durable record also emits a span into the diagnostics bundle. + * + * Returns whether THIS call dispatched the probe: openMainWindow re-runs on every + * reopen, and only a dispatch will ever produce an `onDone`. */ -export function probeWindowsInstallDirAcl(options: WindowsInstallDirAclProbeOptions = {}): void { +export function probeWindowsInstallDirAcl(options: WindowsInstallDirAclProbeOptions = {}): boolean { if ((options.platform ?? process.platform) !== 'win32' || options.isServeMode === true) { - return + return false } if (probeStarted) { - return + return false } probeStarted = true // Why the try: this runs inline in openMainWindow, so anything thrown here @@ -229,4 +232,5 @@ export function probeWindowsInstallDirAcl(options: WindowsInstallDirAclProbeOpti } catch { // Nothing left to report to that would not throw again. } + return true } diff --git a/src/main/startup/windows-install-dir-acl-recovery.test.ts b/src/main/startup/windows-install-dir-acl-recovery.test.ts index 2b5d00ff40a..7641cdc0700 100644 --- a/src/main/startup/windows-install-dir-acl-recovery.test.ts +++ b/src/main/startup/windows-install-dir-acl-recovery.test.ts @@ -1,18 +1,34 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' +import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' +import { readActiveGpuFallbackMarker, writeGpuFallbackMarker } from './gpu-fallback-marker' import { probeWindowsInstallDirAcl, resetWindowsInstallDirAclProbeForTest } from './windows-install-dir-acl-probe' +import { + hasInstallDirAclPoisonMarker, + writeInstallDirAclPoisonMarker +} from './windows-install-dir-acl-poison-marker' import { describeInstallDirAclPoison, + isBlockingInstallDirAclRepairInFlight, + isInstallDirAclRepairExhausted, + isInstallDirAclSuspect, + noteWindowsInstallDirAclProbePending, + repairKnownPoisonedInstallDirBeforeWindow, resetWindowsInstallDirAclRecoveryForTest, - startWindowsInstallDirAclRepairIfPoisoned + startWindowsInstallDirAclRepairIfPoisoned, + type WindowsInstallDirAclRecoveryOptions } from './windows-install-dir-acl-recovery' -import { resetWindowsInstallDirAclRepairForTest } from './windows-install-dir-package-acl-repair' +import { + resetWindowsInstallDirAclRepairForTest, + WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE, + WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION +} from './windows-install-dir-package-acl-repair' import { ALL_PACKAGES_ACE, fakeIcaclsSpawn, @@ -26,6 +42,8 @@ import { const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca' const APP_VERSION = '1.4.184' +type Runner = (spec: ProcessSpec) => Promise + /** * Drives the production path: the real probe hands its verdict to the real gate, * which decides whether icacls ever runs. Only the two process seams are faked. @@ -185,3 +203,778 @@ describe('describeInstallDirAclPoison', () => { expect(describeInstallDirAclPoison()?.detail).toContain('repairing the permissions now') }) }) + +const POISON_VERDICT: CrashReportBreadcrumbData = { + status: 'ok', + matchesPoisonSignature: true, + wellKnownNameCheckReliable: true +} +const GPU_ENV = { appVersion: APP_VERSION, electronVersion: '43.4.1', platform: 'win32' } as const + +function recoveryOptions(userDataPath: string, run: Runner): WindowsInstallDirAclRecoveryOptions { + return { + platform: 'win32', + installDir: INSTALL_DIR, + appVersion: APP_VERSION, + userDataPath, + runProcessFn: run as never, + recordBreadcrumb: () => undefined + } +} + +/** icacls' real success summary, as the repair's parser expects it. */ +const okRun: Runner = async () => ({ + code: 0, + signal: null, + stdout: 'Successfully processed 3200 files; Failed processing 0 files', + stderr: '', + timedOut: false +}) + +describe('install-dir ACL repair vs the GPU safe-graphics marker', () => { + beforeEach(() => { + resetWindowsInstallDirAclProbeForTest() + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + }) + + it('clears the sticky safe-graphics marker once the real cause is repaired', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gpu-')) + // The machine is in the reproduced state: the poisoned install DACL killed the + // GPU child three times, so Orca latched safe graphics for this build. + writeGpuFallbackMarker( + userDataPath, + { engagedAt: Date.now(), crashesInWindow: 3, userConfirmed: false }, + GPU_ENV + ) + expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)).not.toBeNull() + + await new Promise((resolve) => { + startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, { + ...recoveryOptions(userDataPath, okRun), + // Settles after the repair's own setImmediate hop and its two icacls passes. + recordBreadcrumb: () => { + setTimeout(resolve, 0) + return undefined + } + }) + }) + + expect(describeInstallDirAclPoison()?.detail).toContain('repaired the permissions') + // The GPU child deaths were never a driver fault, so safe graphics — and the + // --in-process-gpu launch that hides the next crash's evidence — must not outlive the repair. + expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)).toBeNull() + }) + + // "Keep safe graphics" is a durable user choice with its own reasons; the repair + // only retires the latch Orca engaged on its own. + it('leaves a user-confirmed safe-graphics marker alone', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gpu-')) + writeGpuFallbackMarker( + userDataPath, + { engagedAt: Date.now(), crashesInWindow: 3, userConfirmed: true }, + GPU_ENV + ) + + await new Promise((resolve) => { + startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, { + ...recoveryOptions(userDataPath, okRun), + recordBreadcrumb: () => { + setTimeout(resolve, 0) + return undefined + } + }) + }) + + expect(describeInstallDirAclPoison()?.detail).toContain('repaired the permissions') + expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)?.userConfirmed).toBe(true) + }) +}) + +describe('isInstallDirAclSuspect', () => { + beforeEach(() => { + resetWindowsInstallDirAclProbeForTest() + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + }) + + it('is false when nothing has suggested the install DACL is involved', () => { + expect(isInstallDirAclSuspect()).toBe(false) + }) + + // The GPU child dies ~74ms in and the probe answers 0.9-3.0s later, so "no verdict + // yet" is the entire window in which the misdiagnosis happens. + it('holds while the probe verdict is outstanding, and releases on a clean verdict', () => { + noteWindowsInstallDirAclProbePending() + expect(isInstallDirAclSuspect()).toBe(true) + + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'ok', matchesPoisonSignature: false }, + recoveryOptions(mkdtempSync(join(tmpdir(), 'orca-acl-suspect-')), okRun) + ) + expect(isInstallDirAclSuspect()).toBe(false) + }) + + it('releases once the wait exceeds the grace window, so a silent probe cannot pin it', () => { + noteWindowsInstallDirAclProbePending() + expect(isInstallDirAclSuspect(Date.now() + 14_000)).toBe(true) + expect(isInstallDirAclSuspect(Date.now() + 16_000)).toBe(false) + }) + + it('holds through a repair that failed, and releases once one succeeds', async () => { + const failing: Runner = async () => ({ + code: 5, + signal: null, + stdout: '', + stderr: 'Access is denied.', + timedOut: false + }) + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-suspect-')) + startWindowsInstallDirAclRepairIfPoisoned( + POISON_VERDICT, + recoveryOptions(userDataPath, failing) + ) + expect(isInstallDirAclSuspect()).toBe(true) + await vi.waitFor(() => expect(describeInstallDirAclPoison()?.detail).toContain('could not')) + // Still suspect: the tree is proven poisoned, and safe graphics does not rescue it. + expect(isInstallDirAclSuspect()).toBe(true) + + resetWindowsInstallDirAclRecoveryForTest() + resetWindowsInstallDirAclRepairForTest() + startWindowsInstallDirAclRepairIfPoisoned( + POISON_VERDICT, + recoveryOptions(mkdtempSync(join(tmpdir(), 'orca-acl-suspect-')), okRun) + ) + await vi.waitFor(() => expect(isInstallDirAclSuspect()).toBe(false)) + }) +}) + +describe('repairKnownPoisonedInstallDirBeforeWindow', () => { + beforeEach(() => { + resetWindowsInstallDirAclProbeForTest() + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + }) + + it('costs a healthy machine one absent-file read and no icacls', async () => { + const specs: ProcessSpec[] = [] + const run: Runner = async (spec) => { + specs.push(spec) + return okRun(spec) + } + const mode = await repairKnownPoisonedInstallDirBeforeWindow( + recoveryOptions(mkdtempSync(join(tmpdir(), 'orca-acl-gate-')), run) + ) + expect(mode).toBe('not-marked') + expect(specs).toHaveLength(0) + }) + + // The crash this fixes: launch 1 detects the poison but createMainWindow already + // ran, so the renderer is dead before icacls is spawned. Launch 2 must not repeat it. + it('repairs a launch that a previous one recorded as poisoned, before returning', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-')) + // Launch 1: the probe reports poison and the app dies mid-repair. + startWindowsInstallDirAclRepairIfPoisoned( + POISON_VERDICT, + recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + ) + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + + // Launch 2. + resetWindowsInstallDirAclRecoveryForTest() + resetWindowsInstallDirAclRepairForTest() + writeGpuFallbackMarker( + userDataPath, + { engagedAt: Date.now(), crashesInWindow: 3, userConfirmed: false }, + GPU_ENV + ) + const specs: ProcessSpec[] = [] + const run: Runner = async (spec) => { + specs.push(spec) + return okRun(spec) + } + const mode = await repairKnownPoisonedInstallDirBeforeWindow(recoveryOptions(userDataPath, run)) + expect(mode).toBe('repaired') + // Both passes have already run by the time the window may be created. + expect(specs.map((spec) => spec.args?.[2])).toEqual([ + '*S-1-15-2-2:(OI)(CI)(RX)', + '*S-1-15-2-2:(RX)' + ]) + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(false) + expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)).toBeNull() + }) + + it('gives up on its budget rather than holding the window open forever', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-')) + startWindowsInstallDirAclRepairIfPoisoned( + POISON_VERDICT, + recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + ) + resetWindowsInstallDirAclRecoveryForTest() + resetWindowsInstallDirAclRepairForTest() + + const mode = await repairKnownPoisonedInstallDirBeforeWindow({ + ...recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner), + timeoutMs: 20 + }) + expect(mode).toBe('timeout') + }) + + it('is a no-op off win32 and in serve mode', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-')) + startWindowsInstallDirAclRepairIfPoisoned( + POISON_VERDICT, + recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + ) + resetWindowsInstallDirAclRecoveryForTest() + resetWindowsInstallDirAclRepairForTest() + + expect( + await repairKnownPoisonedInstallDirBeforeWindow({ + ...recoveryOptions(userDataPath, okRun), + platform: 'darwin' + }) + ).toBe('skipped') + expect( + await repairKnownPoisonedInstallDirBeforeWindow({ + ...recoveryOptions(userDataPath, okRun), + isServeMode: true + }) + ).toBe('skipped') + }) + + it('retires the marker when a later probe reports the install clean', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-')) + startWindowsInstallDirAclRepairIfPoisoned( + POISON_VERDICT, + recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + ) + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + + resetWindowsInstallDirAclRecoveryForTest() + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'ok', matchesPoisonSignature: false }, + recoveryOptions(userDataPath, okRun) + ) + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(false) + }) + + // An unreadable DACL is not evidence of health; forgetting the verdict there would + // hand the next launch straight back to the crash it already recorded. + it('keeps the marker when the probe could not read the DACL', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-')) + startWindowsInstallDirAclRepairIfPoisoned( + POISON_VERDICT, + recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + ) + resetWindowsInstallDirAclRecoveryForTest() + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'failed', reason: 'all-targets-unreadable' }, + recoveryOptions(userDataPath, okRun) + ) + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + }) + + // The gate-timed-out ordering. The gate's budget is 20s while the tree grant's own cap is + // 120s, so icacls routinely outlives the gate: the window opens, and this launch's probe + // reads the tree POISONED while that repair is still in flight. When the orphaned icacls + // then claims success -- exit 0, and on a localized Windows no parsable failure summary to + // contradict it -- the claim must not outrank a reading taken after it was dispatched. + // Otherwise this launch deletes the poison marker that arms every later gate, un-suspects + // the tree so --in-process-gpu can engage, clears the safe-graphics marker, and tells the + // user their permissions are fixed. + it('does not let a timed-out gate repair outrank a poison reading taken after it', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-timeout-')) + writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) + writeGpuFallbackMarker( + userDataPath, + { engagedAt: Date.now(), crashesInWindow: 3, userConfirmed: false }, + GPU_ENV + ) + + let releaseIcacls: () => void = () => undefined + const stalled = new Promise((resolve) => { + releaseIcacls = resolve + }) + let repairReported: () => void = () => undefined + const reported = new Promise((resolve) => { + repairReported = resolve + }) + + const mode = await repairKnownPoisonedInstallDirBeforeWindow({ + ...recoveryOptions(userDataPath, async (spec) => { + await stalled + return okRun(spec) + }), + recordBreadcrumb: () => { + setTimeout(repairReported, 0) + return undefined + }, + timeoutMs: 20 + }) + expect(mode).toBe('timeout') + + // The window is open now, and this launch's own probe reads the tree still poisoned. + startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, recoveryOptions(userDataPath, okRun)) + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + + releaseIcacls() + await reported + + expect(isInstallDirAclSuspect()).toBe(true) + expect(describeInstallDirAclPoison()?.detail).toContain('could not repair them') + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)).not.toBeNull() + }) + + // The other ordering of the same two events: the orphaned icacls exits 0 and clears the + // safe-graphics marker BEFORE the probe reads the tree still poisoned. The disproof must + // give the marker back, or the two orderings disagree about the same launch. + it('restores the safe-graphics marker when the probe disproves a timed-out gate repair', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-timeout-restore-')) + writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) + writeGpuFallbackMarker( + userDataPath, + { engagedAt: Date.now(), crashesInWindow: 3, userConfirmed: false }, + GPU_ENV + ) + + let releaseIcacls: () => void = () => undefined + const stalled = new Promise((resolve) => { + releaseIcacls = resolve + }) + let repairReported: () => void = () => undefined + const reported = new Promise((resolve) => { + repairReported = resolve + }) + + const mode = await repairKnownPoisonedInstallDirBeforeWindow({ + ...recoveryOptions(userDataPath, async (spec) => { + await stalled + return okRun(spec) + }), + recordBreadcrumb: () => { + setTimeout(repairReported, 0) + return undefined + }, + timeoutMs: 20 + }) + expect(mode).toBe('timeout') + + // The orphan claims success first; the claim is believed and takes the marker with it. + releaseIcacls() + await reported + expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)).toBeNull() + + // Then this launch's probe reads the tree still poisoned. + startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, recoveryOptions(userDataPath, okRun)) + + expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)?.userConfirmed).toBe(false) + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + expect(isInstallDirAclSuspect()).toBe(true) + }) +}) + +// The repair marker matches whatever the outcome, so on its own 'marker-hit' cannot tell a +// finished tree from one Orca gave up on. Both callers hold outstanding poison evidence — +// this launch's probe reading, or the persisted marker that armed the gate — so a recorded +// success never stands in for the repair, and 'marker-hit' only ever means budget spent. +describe('a repair marker recording a completed repair', () => { + beforeEach(() => { + resetWindowsInstallDirAclProbeForTest() + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + }) + + /** One launch: fresh module latches, then the gate runs against the userData on disk. */ + async function gateLaunch( + userDataPath: string, + run: Runner + ): Promise>> { + resetWindowsInstallDirAclProbeForTest() + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + return repairKnownPoisonedInstallDirBeforeWindow(recoveryOptions(userDataPath, run)) + } + + // The three-launch shape the gate exists for, and the one it used to disarm itself on: + // launch 1 repairs; the tree is re-poisoned (an installer, AV, or an icacls run that + // silently no-opped); launch 2's probe records the poison but Chromium FATALs before the + // repair can write its marker. Launch 3's gate then meets a poison marker and a repair + // marker claiming success. Treating that as 'repaired' ran no icacls, deleted the poison + // marker so no later gate ever fires again, un-suspected the tree so --in-process-gpu + // could engage, and told the user their permissions were fixed. + it('re-runs icacls when a poison marker outlives it', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-repaired-hit-')) + + // Launch 1: the gate repairs the tree and retires the poison marker. + writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) + expect(await gateLaunch(userDataPath, okRun)).toBe('repaired') + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(false) + + // Launch 2: the probe reads the tree as poisoned again; the process dies mid-repair, + // so the repair marker still records launch 1's success. + resetWindowsInstallDirAclRecoveryForTest() + resetWindowsInstallDirAclRepairForTest() + startWindowsInstallDirAclRepairIfPoisoned( + POISON_VERDICT, + recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + ) + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + + // Launch 3: the gate must repair, not congratulate itself on launch 1's work. + const spent: ProcessSpec[] = [] + const mode = await gateLaunch(userDataPath, async (spec) => { + spent.push(spec) + return { code: 5, signal: null, stdout: '', stderr: 'Access is denied.', timedOut: false } + }) + expect(mode).toBe('failed') + expect(spent.map((spec) => spec.args?.[2])).toEqual([ + '*S-1-15-2-2:(OI)(CI)(RX)', + '*S-1-15-2-2:(RX)' + ]) + expect(isInstallDirAclSuspect()).toBe(true) + expect(describeInstallDirAclPoison()?.detail).toContain('could not repair them') + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + }) + + // The budget is what stops the retry above running forever; a spent one must still read + // as "Orca could not fix this", never as a repair it never made. + it('does not let the gate report a spent budget as a repair', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-budget-')) + writeFileSync( + join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE), + JSON.stringify({ + schemeVersion: WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION, + installDir: INSTALL_DIR, + appVersion: APP_VERSION, + attemptedAt: Date.now(), + outcome: 'repaired', + attempts: 3 + }) + ) + writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) + const spent: ProcessSpec[] = [] + const mode = await gateLaunch(userDataPath, async (spec) => { + spent.push(spec) + return okRun(spec) + }) + expect(mode).toBe('marker-hit') + expect(spent).toHaveLength(0) + expect(isInstallDirAclSuspect()).toBe(true) + expect(isInstallDirAclRepairExhausted()).toBe(true) + expect(describeInstallDirAclPoison()?.detail).toContain('could not repair them') + // Still armed: nothing has proven this tree healthy, so a later launch still gates. + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + }) + + // The probe reads the tree AFTER the pre-window gate has finished with it, so a signature + // still matching means the repair never landed however icacls exited. + it('is overruled by a probe that reads the tree poisoned after the gate repaired it', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-noop-icacls-')) + writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) + expect( + await repairKnownPoisonedInstallDirBeforeWindow(recoveryOptions(userDataPath, okRun)) + ).toBe('repaired') + + startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, recoveryOptions(userDataPath, okRun)) + + expect(isInstallDirAclSuspect()).toBe(true) + expect(isInstallDirAclRepairExhausted()).toBe(false) + expect(describeInstallDirAclPoison()?.detail).toContain('could not repair them') + // Re-armed: the next launch gates before it opens a window it cannot render. + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + }) + + // The gate's 'repaired' is icacls's exit claim, not a reading of the tree — and the GPU + // children die 48-1373ms after window creation while the probe answers 0.9-3.0s in. + // Un-suspecting the tree on the claim alone opens exactly that interval to + // --in-process-gpu on a tree safe graphics cannot rescue. + it('keeps a gate-repaired tree suspect until this launch probe has read it', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-provisional-')) + writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) + expect( + await repairKnownPoisonedInstallDirBeforeWindow(recoveryOptions(userDataPath, okRun)) + ).toBe('repaired') + + // openMainWindow dispatches the probe: the reading is outstanding. + noteWindowsInstallDirAclProbePending() + expect(isInstallDirAclSuspect()).toBe(true) + // A probe that never answers releases at the grace window, like any pending verdict. + expect(isInstallDirAclSuspect(Date.now() + 15_000)).toBe(false) + + // A clean reading corroborates the claim and releases immediately. + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'ok', matchesPoisonSignature: false }, + recoveryOptions(userDataPath, okRun) + ) + expect(isInstallDirAclSuspect()).toBe(false) + }) + + // A disproved claim owes back everything it took on the false premise — the poison + // marker (above) and the safe-graphics marker, or the machine relaunches hardware + // accelerated into the re-armed gate and FATALs before that gate can finish. + it('restores the safe-graphics marker a disproved repair claim cleared', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gpu-restore-')) + writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) + writeGpuFallbackMarker( + userDataPath, + { engagedAt: Date.now(), crashesInWindow: 3, userConfirmed: false }, + GPU_ENV + ) + expect( + await repairKnownPoisonedInstallDirBeforeWindow(recoveryOptions(userDataPath, okRun)) + ).toBe('repaired') + // The claim was believed, so the marker went with it. + expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)).toBeNull() + + startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, recoveryOptions(userDataPath, okRun)) + + expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)?.userConfirmed).toBe(false) + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + }) + + // The opposite evidence: the probe has just READ this tree and found it poisoned, so a + // marker claiming success describes a tree that was re-poisoned, or an icacls run that + // silently no-opped. Reporting 'repaired' there runs no icacls, deletes the poison marker + // that arms the next launch's gate, un-suspects the tree so --in-process-gpu can engage, + // and tells the user their permissions are fixed. + it('re-runs icacls when a fresh probe verdict contradicts the repaired marker', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-repoisoned-')) + writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) + expect( + await repairKnownPoisonedInstallDirBeforeWindow(recoveryOptions(userDataPath, okRun)) + ).toBe('repaired') + + // Next launch: the gate is disarmed, and the probe reads the same tree as poisoned. + resetWindowsInstallDirAclRecoveryForTest() + resetWindowsInstallDirAclRepairForTest() + const spent: ProcessSpec[] = [] + const failing: Runner = async (spec) => { + spent.push(spec) + return { code: 5, signal: null, stdout: '', stderr: 'Access is denied.', timedOut: false } + } + await new Promise((resolve) => { + startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, { + ...recoveryOptions(userDataPath, failing), + recordBreadcrumb: () => { + setTimeout(resolve, 0) + return undefined + } + }) + }) + + expect(spent.map((spec) => spec.args?.[2])).toEqual([ + '*S-1-15-2-2:(OI)(CI)(RX)', + '*S-1-15-2-2:(RX)' + ]) + expect(isInstallDirAclSuspect()).toBe(true) + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + expect(describeInstallDirAclPoison()?.detail).toContain('could not repair them') + }) + + // The contradiction re-opens the budget, it does not remove it: a tree that has spent + // every attempt must not re-spawn icacls on every launch forever. + it('still stops at the attempt budget when the probe keeps reporting poison', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-repoisoned-budget-')) + writeFileSync( + join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE), + JSON.stringify({ + schemeVersion: WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION, + installDir: INSTALL_DIR, + appVersion: APP_VERSION, + attemptedAt: Date.now(), + outcome: 'repaired', + attempts: 3 + }) + ) + const spent: ProcessSpec[] = [] + const run: Runner = async (spec) => { + spent.push(spec) + return okRun(spec) + } + await new Promise((resolve) => { + startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, { + ...recoveryOptions(userDataPath, run), + recordBreadcrumb: () => { + setTimeout(resolve, 0) + return undefined + } + }) + }) + + expect(spent).toHaveLength(0) + // Nothing was repaired, so the user still gets the commands and the gate stays armed. + expect(isInstallDirAclSuspect()).toBe(true) + expect(describeInstallDirAclPoison()?.detail).toContain('could not repair them') + expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) + }) +}) + +describe('a clean probe verdict', () => { + beforeEach(() => { + resetWindowsInstallDirAclProbeForTest() + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + }) + + // The launch this covers: the repair budget is spent, so the gate can only report + // 'marker-hit' — and then the probe reads the tree and finds it healthy. + it('retires a verdict the gate could no longer act on', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-clean-')) + writeFileSync( + join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE), + JSON.stringify({ + schemeVersion: WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION, + installDir: INSTALL_DIR, + appVersion: APP_VERSION, + attemptedAt: Date.now(), + outcome: 'failed', + attempts: 3 + }) + ) + startWindowsInstallDirAclRepairIfPoisoned( + POISON_VERDICT, + recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + ) + resetWindowsInstallDirAclRecoveryForTest() + resetWindowsInstallDirAclRepairForTest() + expect( + await repairKnownPoisonedInstallDirBeforeWindow(recoveryOptions(userDataPath, okRun)) + ).toBe('marker-hit') + expect(isInstallDirAclSuspect()).toBe(true) + + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'ok', matchesPoisonSignature: false }, + recoveryOptions(userDataPath, okRun) + ) + // Neither the driver fallback stays suppressed nor does the dialog accuse a healthy folder. + expect(isInstallDirAclSuspect()).toBe(false) + expect(describeInstallDirAclPoison()).toBeNull() + }) + + // The probe answers while the repair is still walking the tree: 'failed' from a + // repair with nothing left to fix must not re-accuse an install just read clean. + it('outranks a repair verdict that lands after it', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-clean-')) + const failing: Runner = async () => ({ + code: 5, + signal: null, + stdout: '', + stderr: 'Access is denied.', + timedOut: false + }) + let repairSettled = false + startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, { + ...recoveryOptions(userDataPath, failing), + recordBreadcrumb: () => { + repairSettled = true + return undefined + } + }) + startWindowsInstallDirAclRepairIfPoisoned( + { status: 'ok', matchesPoisonSignature: false }, + recoveryOptions(userDataPath, okRun) + ) + await vi.waitFor(() => expect(repairSettled).toBe(true)) + expect(isInstallDirAclSuspect()).toBe(false) + expect(describeInstallDirAclPoison()).toBeNull() + }) +}) + +describe('the probe-pending grace window', () => { + beforeEach(() => { + resetWindowsInstallDirAclProbeForTest() + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + }) + + // openMainWindow re-runs on every tray/second-instance reopen while the probe is + // once-per-process, so a re-arm would wait 15s on a verdict that already landed + // and drop every GPU child crash in between. + it('is armed by a dispatched probe only, so a reopen cannot re-arm it', async () => { + const probeArgs = { + platform: 'win32' as const, + installDir: INSTALL_DIR, + fileExists: () => false, + spawnFn: fakeIcaclsSpawn((target) => icaclsDacl(target, [RESTRICTED_PACKAGES_ACE])).spawnFn, + recordBreadcrumb: () => undefined + } + let settleVerdict: () => void = () => undefined + const verdict = new Promise((resolve) => (settleVerdict = resolve)) + // Launch, wired exactly as main-window-controller wires it. + const dispatched = probeWindowsInstallDirAcl({ + ...probeArgs, + onDone: (data) => { + startWindowsInstallDirAclRepairIfPoisoned( + data, + recoveryOptions(mkdtempSync(join(tmpdir(), 'orca-acl-rearm-')), okRun) + ) + settleVerdict() + } + }) + if (dispatched) { + noteWindowsInstallDirAclProbePending() + } + expect(dispatched).toBe(true) + expect(isInstallDirAclSuspect()).toBe(true) + await verdict + expect(isInstallDirAclSuspect()).toBe(false) + + // Reopen: the probe declines, so nothing arms the grace window again. + const reopened = probeWindowsInstallDirAcl({ ...probeArgs, onDone: () => undefined }) + if (reopened) { + noteWindowsInstallDirAclProbePending() + } + expect(reopened).toBe(false) + expect(isInstallDirAclSuspect()).toBe(false) + expect(isInstallDirAclSuspect(Date.now() + 14_000)).toBe(false) + }) +}) + +describe('isBlockingInstallDirAclRepairInFlight', () => { + beforeEach(() => { + resetWindowsInstallDirAclProbeForTest() + resetWindowsInstallDirAclRepairForTest() + resetWindowsInstallDirAclRecoveryForTest() + }) + + it('is false on a healthy machine and clears once the gate returns', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-inflight-')) + expect(isBlockingInstallDirAclRepairInFlight()).toBe(false) + + startWindowsInstallDirAclRepairIfPoisoned( + POISON_VERDICT, + recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + ) + resetWindowsInstallDirAclRecoveryForTest() + resetWindowsInstallDirAclRepairForTest() + + let inFlightDuringRepair = false + const gate = repairKnownPoisonedInstallDirBeforeWindow({ + ...recoveryOptions(userDataPath, async (spec) => { + inFlightDuringRepair = isBlockingInstallDirAclRepairInFlight() + return okRun(spec) + }), + timeoutMs: 5_000 + }) + expect(await gate).toBe('repaired') + expect(inFlightDuringRepair).toBe(true) + expect(isBlockingInstallDirAclRepairInFlight()).toBe(false) + }) + + // A second entry has no `onDone` coming, so waiting out the 20s budget for it + // would hold the window closed for nothing. + it('returns immediately when the once-per-process repair already ran', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-inflight-')) + startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, recoveryOptions(userDataPath, okRun)) + resetWindowsInstallDirAclRecoveryForTest() + + const mode = await repairKnownPoisonedInstallDirBeforeWindow({ + ...recoveryOptions(userDataPath, okRun), + timeoutMs: 30_000 + }) + expect(mode).toBe('skipped') + expect(isBlockingInstallDirAclRepairInFlight()).toBe(false) + }) +}) diff --git a/src/main/startup/windows-install-dir-acl-recovery.ts b/src/main/startup/windows-install-dir-acl-recovery.ts index 0aa6870192e..c251bda9267 100644 --- a/src/main/startup/windows-install-dir-acl-recovery.ts +++ b/src/main/startup/windows-install-dir-acl-recovery.ts @@ -1,6 +1,17 @@ import { dirname } from 'node:path' import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { logStartupMilestone } from './startup-diagnostics' +import { + clearGpuFallbackMarker, + readGpuFallbackMarker, + writeGpuFallbackMarker, + type GpuFallbackMarker +} from './gpu-fallback-marker' +import { + clearInstallDirAclPoisonMarker, + hasInstallDirAclPoisonMarker, + writeInstallDirAclPoisonMarker +} from './windows-install-dir-acl-poison-marker' import { buildInstallDirAclRepairCommands, isInstallDirAclPoisonVerdict, @@ -25,10 +36,165 @@ export type WindowsInstallDirAclRecoveryOptions = Omit void>() + +function settleVerdictWaiters(): void { + // `wake` deletes only itself, which is safe to do on the entry being visited. + for (const wake of verdictWaiters) { + wake() + } + verdictWaiters.clear() +} export function resetWindowsInstallDirAclRecoveryForTest(): void { poison = null + probePendingSince = null + installDirReadClean = false + installDirReadPoisonedMidRepair = false + gpuMarkerClearedByRepairClaim = null + blockingRepairInFlight = false + settleVerdictWaiters() +} + +/** Call when the install-DACL probe is dispatched: its verdict is not in yet. */ +export function noteWindowsInstallDirAclProbePending(): void { + probePendingSince = Date.now() +} + +/** + * Resolves when the probe's verdict lands, or when its grace window runs out. + * For callers that must not act on a suspicion the probe is about to withdraw. + */ +export function waitForInstallDirAclVerdict(now: number = Date.now()): Promise { + const remainingMs = + probePendingSince === null ? 0 : PROBE_VERDICT_GRACE_MS - (now - probePendingSince) + if (remainingMs <= 0) { + return Promise.resolve() + } + return new Promise((resolve) => { + const wake = (): void => { + clearTimeout(timer) + verdictWaiters.delete(wake) + resolve() + } + const timer = setTimeout(wake, remainingMs) + timer.unref?.() + verdictWaiters.add(wake) + }) +} + +/** + * True while a sandboxed-child death could be the install DACL rather than the + * graphics driver. Safe graphics does not rescue a poisoned tree — it still kills + * the renderer — and it removes the GPU child, erasing the sibling-death evidence + * that is the only way to recognise the shape in a crash report. + */ +export function isInstallDirAclSuspect(now: number = Date.now()): boolean { + if (installDirReadClean) { + return false + } + if (poison && poison.stage !== 'repaired') { + return true + } + // A 'repaired' stage is icacls's exit claim, not a reading of the tree — and the GPU + // children die 48-1373ms after window creation while the probe answers 0.9-3.0s in. So + // the claim stays provisional while this launch's probe is still out: the grace check + // below keeps the suspicion until the reading corroborates it or the window lapses. + return probePendingSince !== null && now - probePendingSince < PROBE_VERDICT_GRACE_MS +} + +/** + * True while a repair for this tree is dispatched and has not reported yet. + * + * Why it is not the same question as `isInstallDirAclSuspect`: a suspect tree we are + * actively repairing is one a *future* launch can still be rescued on, so the safe-graphics + * marker earns its keep there — a launch Chromium FATALs mid-repair comes back software + * rendered, stops spawning the GPU children that trigger the FATAL, and lets the next gate + * run to completion. A terminal verdict has no such next step. + */ +export function isInstallDirAclRepairPending(): boolean { + return poison?.stage === 'pending' +} + +/** + * True once the repair has nothing left to try for this install and version: `marker-hit` + * is reachable only through the spent attempt budget. The suspicion itself stands — the + * dialog still names the cause and the admin commands — but a caller that was *withholding* + * a recovery to give the repair first go has nothing left to wait for. + */ +export function isInstallDirAclRepairExhausted(): boolean { + return poison?.stage === 'marker-hit' +} + +/** True while the pre-window gate is rewriting the very files a new renderer would load. */ +export function isBlockingInstallDirAclRepairInFlight(): boolean { + return blockingRepairInFlight +} + +/** False when the once-per-process repair had already been dispatched, so no `onDone` is coming. */ +function startRepair( + installDir: string, + options: WindowsInstallDirAclRecoveryOptions, + onDone?: (result: WindowsInstallDirAclRepairResult) => void +): boolean { + writeInstallDirAclPoisonMarker(options.userDataPath, installDir, options.appVersion) + const started = repairWindowsInstallDirPackageAcl({ + ...options, + installDir, + // Every caller here holds outstanding poison evidence — this launch's probe reading, or + // the persisted marker that armed the gate — so a marker recording a completed repair + // describes a re-poisoned tree, or an icacls run that silently no-opped. It must not + // stand in for a repair. `marker-hit` therefore only ever means the budget is spent. + poisonEvidenceOutstanding: true, + onDone: (result) => { + // A tree read poisoned AFTER this repair was dispatched disproves its success claim, + // whatever icacls exited: the gate's budget can expire while the child runs on under + // its own, so the probe's reading is the later evidence. A clean reading since then + // retires it — there was nothing left to repair. + const claimDisproved = + result.mode === 'repaired' && installDirReadPoisonedMidRepair && !installDirReadClean + // A clean reading of the tree outranks this: there was nothing left to repair. + if (!installDirReadClean) { + poison = { installDir, stage: claimDisproved ? 'failed' : result.mode } + } + logStartupMilestone('install-dir-acl-repair-done', { mode: result.mode }) + if (result.mode === 'repaired' && !claimDisproved) { + clearInstallDirAclPoisonMarker(options.userDataPath) + // The GPU child deaths were never a driver fault, so safe graphics — and the + // --in-process-gpu launch that hides the next crash's evidence — must not outlive the repair. + // Never a user-confirmed marker: "keep safe graphics" is a choice, not Orca's latch. + const gpuMarker = readGpuFallbackMarker(options.userDataPath) + if (gpuMarker?.userConfirmed === false) { + // Kept: a probe reading that later disproves this claim restores the marker, + // or the next launch relaunches hardware accelerated into the re-armed gate. + gpuMarkerClearedByRepairClaim = gpuMarker + clearGpuFallbackMarker(options.userDataPath) + } + } + if (result.mode === 'failed') { + console.warn('[win32-acl] install dir package ACL repair failed:', result.reason) + } + onDone?.(result) + } + }) + if (started) { + poison = { installDir, stage: 'pending' } + } + return started } /** The probe's `onDone`: no-op unless the machine is in the reproduced state. */ @@ -36,22 +202,112 @@ export function startWindowsInstallDirAclRepairIfPoisoned( data: CrashReportBreadcrumbData, options: WindowsInstallDirAclRecoveryOptions ): void { - if (!isInstallDirAclPoisonVerdict(data)) { - return + // Cleared for every verdict, including an unreadable one that proves nothing: that + // releases a provisional 'repaired' claim early, but holding it would only move the + // same release to the grace-window expiry — an unreadable probe can never corroborate. + probePendingSince = null + try { + applyInstallDirAclProbeVerdict(data, options) + } finally { + // Only after the verdict is applied: a waiter wakes to re-read `isInstallDirAclSuspect()`. + settleVerdictWaiters() } - const installDir = options.installDir ?? dirname(process.execPath) - poison = { installDir, stage: 'pending' } - repairWindowsInstallDirPackageAcl({ - ...options, - installDir, - onDone: (result) => { - poison = { installDir, stage: result.mode } - logStartupMilestone('install-dir-acl-repair-done', { mode: result.mode }) - if (result.mode === 'failed') { - console.warn('[win32-acl] install dir package ACL repair failed:', result.reason) +} + +function applyInstallDirAclProbeVerdict( + data: CrashReportBreadcrumbData, + options: WindowsInstallDirAclRecoveryOptions +): void { + if (!isInstallDirAclPoisonVerdict(data)) { + // Only a positive clean reading retires the verdict; an unreadable DACL proves nothing. + if (data.matchesPoisonSignature === false) { + clearInstallDirAclPoisonMarker(options.userDataPath) + installDirReadClean = true + // The reading corroborates any repair claim, so its marker clear stands. + gpuMarkerClearedByRepairClaim = null + // Keeping 'repaired' costs nothing and is what tells the user to reload; anything + // else would go on suppressing the driver fallback and accusing a healthy folder. + if (poison?.stage !== 'repaired') { + poison = null } } - }) + return + } + // The blocking pre-window gate still owns this launch's repair; restarting it would + // reset the verdict to 'pending' against a repair that can no longer report. The reading + // is kept, not dropped: it is later evidence than the repair's own exit code. + if (poison?.stage === 'pending') { + installDirReadPoisonedMidRepair = true + return + } + // This reading was taken after the gate finished, so it outranks the gate's own verdict: + // a tree that still matches the signature was never repaired, whatever icacls exited. + if (poison?.stage === 'repaired') { + poison = { installDir: poison.installDir, stage: 'failed' } + // The claim also cleared the safe-graphics marker; disproved, it owes that back, or + // the next launch relaunches hardware accelerated and FATALs before its gate can win. + const cleared = gpuMarkerClearedByRepairClaim + gpuMarkerClearedByRepairClaim = null + if (cleared) { + try { + writeGpuFallbackMarker(options.userDataPath, cleared, cleared) + } catch { + // Best effort: the re-armed poison marker below still gates the next launch. + } + } + } + // Re-writes the poison marker — re-arming the next launch's gate — even when the + // once-per-process latch means no icacls can run again this launch. + startRepair(options.installDir ?? dirname(process.execPath), options) +} + +/** + * Pre-window gate for a machine a previous launch already found poisoned. + * + * Why blocking, and why only here: the probe is `setImmediate`-deferred and takes + * 0.9-3.0s on the affected hosts, while the renderer it has to save is spawned + * synchronously by `createMainWindow` and dies at init 48-1373ms in. The + * persisted verdict is what buys that knowledge for free — a healthy machine + * reads one absent file and pays nothing. + */ +export async function repairKnownPoisonedInstallDirBeforeWindow( + options: WindowsInstallDirAclRecoveryOptions & { timeoutMs?: number } +): Promise<'not-marked' | 'skipped' | WindowsInstallDirAclRepairResult['mode'] | 'timeout'> { + if ((options.platform ?? process.platform) !== 'win32' || options.isServeMode === true) { + return 'skipped' + } + const installDir = options.installDir ?? dirname(process.execPath) + if (!hasInstallDirAclPoisonMarker(options.userDataPath, installDir, options.appVersion)) { + return 'not-marked' + } + logStartupMilestone('install-dir-acl-repair-blocking-start') + blockingRepairInFlight = true + try { + return await new Promise((resolve) => { + const timer = setTimeout( + () => resolve('timeout'), + options.timeoutMs ?? BLOCKING_REPAIR_BUDGET_MS + ) + timer.unref?.() + // The marker is an earlier launch's DACL reading that nothing has retired, so a + // repair marker claiming success cannot stand in for the repair this launch owes. + const started = startRepair(installDir, options, (result) => { + clearTimeout(timer) + resolve(result.mode) + }) + // No dispatch means no `onDone`, so waiting out the whole budget would buy nothing. + if (!started) { + clearTimeout(timer) + resolve('skipped') + } + }) + } catch (error) { + // This sits in the critical path ahead of window creation; it must never throw into it. + console.warn('[win32-acl] blocking install dir ACL repair faulted:', error) + return 'skipped' + } finally { + blockingRepairInFlight = false + } } const CAUSE = diff --git a/src/main/startup/windows-install-dir-acl-repair.win32.test.ts b/src/main/startup/windows-install-dir-acl-repair.win32.test.ts new file mode 100644 index 00000000000..9a04aa1edef --- /dev/null +++ b/src/main/startup/windows-install-dir-acl-repair.win32.test.ts @@ -0,0 +1,124 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { getIcaclsExePath } from '../win32-utils' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { + probeWindowsInstallDirAcl, + resetWindowsInstallDirAclProbeForTest +} from './windows-install-dir-acl-probe' +import { writeInstallDirAclPoisonMarker } from './windows-install-dir-acl-poison-marker' +import { + repairKnownPoisonedInstallDirBeforeWindow, + resetWindowsInstallDirAclRecoveryForTest +} from './windows-install-dir-acl-recovery' +import { resetWindowsInstallDirAclRepairForTest } from './windows-install-dir-package-acl-repair' + +/** + * The other half of the ACL proof: the unit tests fake icacls, and this one runs + * the real binary against a real poisoned tree on a real Windows box. + * + * Both are needed. `icacls /grant "*S-1-15-2-2:(OI)(CI)(RX)"` exits 0 and + * prints "Failed processing 0 files" while writing no ACE at all — a model of + * icacls cannot catch that, and it is the exact mistake that leaves the app dead. + * + * Runs only on win32; skipped elsewhere. + */ +const describeOnWindows = process.platform === 'win32' ? describe : describe.skip + +/** An unresolvable AppContainer SID, the shape the field hosts carry. */ +const ORPHAN_SID = + '*S-1-15-2-1111111111-2222222222-3333333333-4444444444-5555555555-6666666666-7777777777' +const RESTRICTED_PACKAGES_NAME = /ALL RESTRICTED APPLICATION PACKAGES/i + +async function icacls(...args: string[]): Promise<{ code: number | null; out: string }> { + const result = await runProcess({ program: getIcaclsExePath(), args, timeoutMs: 30_000 }) + return { code: result.code, out: `${result.stdout}\n${result.stderr}` } +} + +/** Explicit DACL, inheritance off: what a shipped module carries, and why a root grant alone is not enough. */ +async function createProtectedFile(path: string): Promise { + writeFileSync(path, 'binary') + await icacls(path, '/inheritance:d') +} + +describeOnWindows('install-dir package ACL repair against the real icacls', () => { + let installDir: string + let userDataPath: string + let moduleFile: string + let trapFile: string + + beforeAll(async () => { + installDir = mkdtempSync(join(tmpdir(), 'orca-acl-live-')) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-live-ud-')) + mkdirSync(join(installDir, 'resources'), { recursive: true }) + moduleFile = join(installDir, 'ffmpeg.dll') + trapFile = join(installDir, 'resources', 'trap.dll') + await createProtectedFile(moduleFile) + await createProtectedFile(trapFile) + // Poison: an orphan package ACE on the tree and on the module, no well-known grant. + await icacls(installDir, '/grant', `${ORPHAN_SID}:(OI)(CI)(RX)`) + await icacls(moduleFile, '/grant', `${ORPHAN_SID}:(RX)`) + await icacls(trapFile, '/grant', `${ORPHAN_SID}:(RX)`) + }) + + afterAll(() => { + // Why removeTreeSync: two icacls.exe children just rewrote DACLs on this tree, so a + // raw rmSync races handles Windows has not released and throws EPERM after the + // assertions already passed. + removeTreeSync(installDir) + removeTreeSync(userDataPath) + }) + + function probeVerdict(): Promise> { + resetWindowsInstallDirAclProbeForTest() + return new Promise((resolve) => { + probeWindowsInstallDirAcl({ + installDir, + recordBreadcrumb: () => undefined, + onDone: (data) => resolve(data as Record) + }) + }) + } + + // The trap, pinned against the real binary: this is the form that looks like it worked. + it('confirms an inheritance-flagged grant silently writes nothing to a file', async () => { + const flagged = await icacls(trapFile, '/grant', '*S-1-15-2-2:(OI)(CI)(RX)') + expect(flagged.code).toBe(0) + expect(flagged.out).toMatch(/Failed processing 0 files?/i) + const after = await icacls(trapFile) + expect(after.out).not.toMatch(RESTRICTED_PACKAGES_NAME) + }) + + it('repairs the tree before the window, and the grant lands on the module file', async () => { + expect((await probeVerdict()).matchesPoisonSignature).toBe(true) + + resetWindowsInstallDirAclRecoveryForTest() + resetWindowsInstallDirAclRepairForTest() + // The state a launch that died mid-repair leaves behind. + writeInstallDirAclPoisonMarker(userDataPath, installDir, '1.4.196') + + const startedAt = Date.now() + const mode = await repairKnownPoisonedInstallDirBeforeWindow({ + installDir, + userDataPath, + appVersion: '1.4.196', + recordBreadcrumb: () => undefined + }) + console.log(`[live-acl] blocking repair ${mode} in ${Date.now() - startedAt}ms`) + expect(mode).toBe('repaired') + + // A directory grant is not enough: the file carries its own DACL. + expect((await icacls(moduleFile)).out).toMatch(RESTRICTED_PACKAGES_NAME) + // The /T pass must also reach a NESTED protected file — the shape app.asar.unpacked + // and node_modules actually have. + expect((await icacls(trapFile)).out).toMatch(RESTRICTED_PACKAGES_NAME) + // And the (OI)(CI) root grant exists so files a later update writes inherit it. + const updateFile = join(installDir, 'resources', 'added-by-update.dll') + writeFileSync(updateFile, 'binary') + expect((await icacls(updateFile)).out).toMatch(RESTRICTED_PACKAGES_NAME) + expect((await probeVerdict()).matchesPoisonSignature).toBe(false) + }) +}) diff --git a/src/main/startup/windows-install-dir-acl-startup-wiring.test.ts b/src/main/startup/windows-install-dir-acl-startup-wiring.test.ts new file mode 100644 index 00000000000..c4175e87a06 --- /dev/null +++ b/src/main/startup/windows-install-dir-acl-startup-wiring.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * The three call sites that make the repair real. Each is one line of wiring in a + * module whose import graph makes it untestable in-process; the behaviour each + * line depends on is driven for real in `windows-install-dir-acl-recovery.test.ts`, + * `gpu-lifecycle-install-dir-acl-guard.test.ts` and `focus-existing-window.test.ts`. + */ + +function readSource(relativePath: string): string { + return readFileSync(join(process.cwd(), relativePath), 'utf8') +} + +describe('install-dir ACL repair startup wiring', () => { + // The entire premise: a renderer must never be spawned onto a tree a previous + // launch recorded as poisoned before icacls has had its bounded chance at it. + it('awaits the pre-window gate before any window creation', () => { + const source = readSource('src/main/startup/main-process-runtime-launch.ts') + const launchStart = source.indexOf('export async function initializeMainProcessRuntimeLaunch(') + expect(launchStart).toBeGreaterThanOrEqual(0) + const launch = source.slice(launchStart) + + const gateIndex = launch.indexOf('await repairKnownPoisonedInstallDirBeforeWindow(') + const winEarlyWindowIndex = launch.indexOf('startWindowsDesktopBeforeShellPathReady(') + const desktopLaunchIndex = launch.indexOf('await launchDesktopMode(') + expect(gateIndex).toBeGreaterThanOrEqual(0) + expect(winEarlyWindowIndex).toBeGreaterThan(gateIndex) + expect(desktopLaunchIndex).toBeGreaterThan(gateIndex) + }) + + // A 20s blank launch invites a second double-click, and `focusExistingMainWindow` + // opens a window whenever there is none and the app is ready. + it('holds the second-instance reopen while the gate owns the launch', () => { + const source = readSource('src/main/startup/main-window-actions.ts') + const start = source.indexOf('export function focusExistingWindow(') + const end = source.indexOf('\nexport function showMainWindowFromTray(', start) + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + expect(source.slice(start, end)).toContain( + 'canOpenWindow: () => !isBlockingInstallDirAclRepairInFlight()' + ) + }) + + // openMainWindow re-runs on every reopen while the probe is once-per-process, so + // arming the grace window unconditionally would drop GPU crashes on a healthy machine. + it('arms the probe grace window only for a dispatched probe', () => { + const source = readSource('src/main/startup/main-window-controller.ts') + const dispatchIndex = source.indexOf('const probeDispatched = probeWindowsInstallDirAcl(') + const armIndex = source.indexOf('noteWindowsInstallDirAclProbePending()') + expect(dispatchIndex).toBeGreaterThanOrEqual(0) + expect(armIndex).toBeGreaterThan(dispatchIndex) + expect(source.slice(dispatchIndex, armIndex)).toContain('if (probeDispatched) {') + }) +}) diff --git a/src/main/startup/windows-install-dir-package-acl-repair.test.ts b/src/main/startup/windows-install-dir-package-acl-repair.test.ts index 65d11e9fcde..cc41388b811 100644 --- a/src/main/startup/windows-install-dir-package-acl-repair.test.ts +++ b/src/main/startup/windows-install-dir-package-acl-repair.test.ts @@ -135,7 +135,7 @@ describe('repairWindowsInstallDirPackageAcl', () => { const second = fakeRunner() const { result, data } = await repair({ userDataPath, run: second.run }) expect(second.specs).toHaveLength(0) - expect(result).toEqual({ mode: 'marker-hit' }) + expect(result).toEqual({ mode: 'marker-hit', alreadyRepaired: true }) expect(data.reason).toBe('marker-hit') }) @@ -233,6 +233,42 @@ describe('repairWindowsInstallDirPackageAcl', () => { expect(marker.outcome).toBe('failed') }) + // The bricking mechanism: a marker was written on failure and matched regardless of + // outcome, so one Defender-locked file or one timeout pinned the machine to + // 'marker-hit' — repair permanently skipped — for the life of that version. + it('retries a failed repair on later launches, then stops once the budget is spent', async () => { + const userDataPath = userDataDir() + const failing = fakeRunner(() => ({ code: 5, stderr: 'Access is denied.' })) + for (let attempt = 0; attempt < 3; attempt++) { + resetWindowsInstallDirAclRepairForTest() + expect((await repair({ userDataPath, run: failing.run })).result.mode).toBe('failed') + } + expect(failing.specs).toHaveLength(6) + + resetWindowsInstallDirAclRepairForTest() + const spent = fakeRunner() + const { result } = await repair({ userDataPath, run: spent.run }) + // Not alreadyRepaired: the budget ran out, so the tree is still poisoned. + expect(result).toEqual({ mode: 'marker-hit', alreadyRepaired: false }) + expect(spent.specs).toHaveLength(0) + }) + + it('stops retrying immediately once a repair has succeeded', async () => { + const userDataPath = userDataDir() + resetWindowsInstallDirAclRepairForTest() + await repair({ userDataPath, run: fakeRunner(() => ({ code: 5 })).run }) + resetWindowsInstallDirAclRepairForTest() + expect((await repair({ userDataPath })).result).toEqual({ mode: 'repaired' }) + + resetWindowsInstallDirAclRepairForTest() + const after = fakeRunner() + expect((await repair({ userDataPath, run: after.run })).result).toEqual({ + mode: 'marker-hit', + alreadyRepaired: true + }) + expect(after.specs).toHaveLength(0) + }) + it('is a no-op off win32 and in serve mode', async () => { const off = fakeRunner() repairWindowsInstallDirPackageAcl({ diff --git a/src/main/startup/windows-install-dir-package-acl-repair.ts b/src/main/startup/windows-install-dir-package-acl-repair.ts index 606edae417c..6bd6505a2cb 100644 --- a/src/main/startup/windows-install-dir-package-acl-repair.ts +++ b/src/main/startup/windows-install-dir-package-acl-repair.ts @@ -54,7 +54,8 @@ const TREE_GRANT_TIMEOUT_MS = 120_000 const FAILED_PROCESSING = /Failed processing (\d+) files?/i export type WindowsInstallDirAclRepairResult = - | { mode: 'marker-hit' } + /** `alreadyRepaired`: the marker records a completed repair, not an exhausted retry budget. */ + | { mode: 'marker-hit'; alreadyRepaired: boolean } | { mode: 'repaired' } | { mode: 'failed'; reason: string; failedFileCount: number | null } @@ -62,6 +63,14 @@ export type WindowsInstallDirAclRepairOptions = { installDir?: string platform?: NodeJS.Platform isServeMode?: boolean + /** + * A DACL reading found this tree poisoned and nothing has read it clean since — this + * launch's probe, or a persisted poison marker from an earlier one. A marker claiming a + * completed repair therefore describes a tree that has since been re-poisoned, or an + * icacls run that silently no-opped: it stops outranking the reading. The attempt + * budget still bounds retries. + */ + poisonEvidenceOutstanding?: boolean /** Test seams. */ runProcessFn?: typeof runProcess recordBreadcrumb?: typeof recordDurableCrashBreadcrumb @@ -81,8 +90,17 @@ type RepairMarker = { appVersion: string attemptedAt: number outcome: string + /** Absent on schemeVersion-1 markers written before the retry budget existed. */ + attempts?: number } +// Why bounded rather than one-and-done: the failure modes are not all permanent. +// A Defender-locked file, a timeout or a contended volume fails one launch and +// succeeds the next, and pinning on the first failure leaves the machine blank +// forever for that version. Three is enough to stop a standard-user Program Files +// install — which can never win — from re-spawning icacls on every launch. +const MAX_REPAIR_ATTEMPTS = 3 + /** * The probe's verdict is the only trigger: an orphan package ACE with no * well-known package grant to satisfy it. A localized icacls prints those grants @@ -106,31 +124,46 @@ function markerPath(userDataPath: string): string { return join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE) } -function hasMarkerFor(args: WindowsInstallDirAclRepairArgs): boolean { +/** The marker for this exact install and version, or null. */ +function readMarkerFor(args: WindowsInstallDirAclRepairArgs): Partial | null { try { const parsed = JSON.parse(readFileSync(markerPath(args.userDataPath), 'utf-8')) as | Partial | undefined - return ( - parsed?.schemeVersion === WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION && - parsed.installDir === args.installDir && - parsed.appVersion === args.appVersion - ) + if ( + parsed?.schemeVersion !== WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION || + parsed.installDir !== args.installDir || + parsed.appVersion !== args.appVersion + ) { + return null + } + return parsed } catch { - return false // missing or corrupt -> attempt again + return null // missing or corrupt -> attempt again } } -// Why write it on failure too: a standard-user Program Files install can never -// win, and re-spawning icacls on every launch forever buys nothing. Reinstall or -// update changes the key and retries. +function markerHitFor(args: WindowsInstallDirAclRepairArgs): { alreadyRepaired: boolean } | null { + const marker = readMarkerFor(args) + if (!marker) { + return null + } + if (marker.outcome === 'repaired' && args.poisonEvidenceOutstanding !== true) { + return { alreadyRepaired: true } + } + return (marker.attempts ?? 0) >= MAX_REPAIR_ATTEMPTS ? { alreadyRepaired: false } : null +} + +// Why write it on failure too: re-spawning icacls on every launch forever buys +// nothing, so failures spend the retry budget. Reinstall or update changes the key. function writeMarker(args: WindowsInstallDirAclRepairArgs, outcome: string): void { const marker: RepairMarker = { schemeVersion: WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION, installDir: args.installDir ?? '', appVersion: args.appVersion, attemptedAt: Date.now(), - outcome + outcome, + attempts: (readMarkerFor(args)?.attempts ?? 0) + 1 } if (!existsSync(args.userDataPath)) { mkdirSync(args.userDataPath, { recursive: true }) @@ -185,9 +218,10 @@ async function runRepair(args: WindowsInstallDirAclRepairArgs): Promise { let result: WindowsInstallDirAclRepairResult let data: CrashReportBreadcrumbData try { - if (hasMarkerFor(resolved)) { - result = { mode: 'marker-hit' } - data = { status: 'skipped', reason: 'marker-hit' } + const markerHit = markerHitFor(resolved) + if (markerHit) { + result = { mode: 'marker-hit', alreadyRepaired: markerHit.alreadyRepaired } + data = { status: 'skipped', reason: 'marker-hit', alreadyRepaired: markerHit.alreadyRepaired } } else { const runner = args.runProcessFn ?? runProcess const root = await runGrant( @@ -257,13 +291,16 @@ export function resetWindowsInstallDirAclRepairForTest(): void { * Fire-and-forget; returns before any spawn. Call only when the probe reported * `matchesPoisonSignature`. win32 only, exempt in serve mode, and it must never * throw into window creation. + * + * Returns whether THIS call dispatched the repair. A caller that waits on `onDone` + * would otherwise wait forever on the once-per-process latch. */ -export function repairWindowsInstallDirPackageAcl(args: WindowsInstallDirAclRepairArgs): void { +export function repairWindowsInstallDirPackageAcl(args: WindowsInstallDirAclRepairArgs): boolean { if ((args.platform ?? process.platform) !== 'win32' || args.isServeMode === true) { - return + return false } if (repairStarted) { - return + return false } repairStarted = true try { @@ -273,4 +310,5 @@ export function repairWindowsInstallDirPackageAcl(args: WindowsInstallDirAclRepa } catch { // Nothing left to report to that would not throw again. } + return true } diff --git a/src/main/window/focus-existing-window.test.ts b/src/main/window/focus-existing-window.test.ts index 85b422e02a4..babb9a15490 100644 --- a/src/main/window/focus-existing-window.test.ts +++ b/src/main/window/focus-existing-window.test.ts @@ -127,6 +127,41 @@ describe('focusExistingMainWindow', () => { expect(timer.scheduledMs()).toEqual([]) }) + // The blocking install-DACL repair holds the first window for up to 20s of blank + // screen, which is exactly when a user double-clicks the shortcut again. That + // second instance must not spawn a renderer onto a tree icacls is rewriting. + it('drops a reopen while another path must own the first window', () => { + const openWindow = vi.fn() + + const result = focusExistingMainWindow({ + app: makeFakeApp(), + getWindow: () => null, + openWindow, + canOpenWindow: () => false + }) + + expect(result).toBe('pending') + expect(openWindow).not.toHaveBeenCalled() + }) + + it('still focuses a window that already exists while reopening is held', () => { + const window = makeFakeWindow() + const openWindow = vi.fn() + + const result = focusExistingMainWindow({ + app: makeFakeApp(), + getWindow: () => window, + openWindow, + canOpenWindow: () => false, + platform: 'darwin', + setTimeout: makeTimer().setTimeout + }) + + expect(result).toBe('focused') + expect(openWindow).not.toHaveBeenCalled() + expect(window.calls.focus).toHaveBeenCalledTimes(1) + }) + it('waits for normal startup when no window exists before app readiness', () => { const openWindow = vi.fn() diff --git a/src/main/window/focus-existing-window.ts b/src/main/window/focus-existing-window.ts index 8c8ac85ca2e..4cadb49a743 100644 --- a/src/main/window/focus-existing-window.ts +++ b/src/main/window/focus-existing-window.ts @@ -9,6 +9,8 @@ export type FocusExistingMainWindowOptions = { app: Pick getWindow: () => BrowserWindow | null openWindow: () => BrowserWindow + /** False while some other path must own the first window; the reopen is dropped, not queued. */ + canOpenWindow?: () => boolean platform?: NodeJS.Platform setTimeout?: FocusTimer warn?: (message: string, error?: unknown) => void @@ -143,7 +145,7 @@ export function focusExistingMainWindow( let openedWindow = false if (!window || window.isDestroyed()) { - if (!opts.app.isReady()) { + if (!opts.app.isReady() || opts.canOpenWindow?.() === false) { return 'pending' } window = openWindowWithRetry(opts, platform, setTimer, 1) diff --git a/src/main/worktree-create-execution-host-route.test.ts b/src/main/worktree-create-execution-host-route.test.ts new file mode 100644 index 00000000000..ec4ecd2899e --- /dev/null +++ b/src/main/worktree-create-execution-host-route.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch' +import { ExecutionHostNotDispatchableError } from './providers/execution-host-provider-dispatch' +import type { Repo } from '../shared/repo-types' +import { + requireWorktreeCreateRoute, + resolveWorktreeCreateRoute +} from './worktree-create-execution-host-route' + +const HOST_A = 'target-a' +const HOST_B = 'target-b' + +function repoRow(fields: Partial): Repo { + return { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + ...fields + } as Repo +} + +afterEach(() => { + unregisterSshGitProvider(HOST_A) + unregisterSshGitProvider(HOST_B) +}) + +describe('resolveWorktreeCreateRoute', () => { + it('routes a row that names its host only as executionHostId to that SSH target', () => { + registerSshGitProvider(HOST_A, { name: 'git-a' } as never) + + expect(resolveWorktreeCreateRoute(repoRow({ executionHostId: 'ssh:target-a' }))).toMatchObject({ + kind: 'ssh', + hostId: 'ssh:target-a', + connectionId: HOST_A, + repo: { connectionId: HOST_A } + }) + }) + + it('normalizes the row for a legacy connectionId-only repo without changing its answer', () => { + expect(resolveWorktreeCreateRoute(repoRow({ connectionId: HOST_A }))).toMatchObject({ + kind: 'ssh', + connectionId: HOST_A, + repo: { connectionId: HOST_A } + }) + }) + + it('keeps two simultaneously registered SSH hosts on their own connections', () => { + registerSshGitProvider(HOST_A, { name: 'git-a' } as never) + registerSshGitProvider(HOST_B, { name: 'git-b' } as never) + + expect(resolveWorktreeCreateRoute(repoRow({ executionHostId: 'ssh:target-a' }))).toMatchObject({ + connectionId: HOST_A, + repo: { connectionId: HOST_A } + }) + expect(resolveWorktreeCreateRoute(repoRow({ executionHostId: 'ssh:target-b' }))).toMatchObject({ + connectionId: HOST_B, + repo: { connectionId: HOST_B } + }) + }) + + it('lets an explicit local host win over a surviving connectionId', () => { + // A contradictory row. `getRepoExecutionHostId` answers `local`, which is what the runtime + // create sibling has always done; the raw read sent it remote. + expect( + resolveWorktreeCreateRoute(repoRow({ executionHostId: 'local', connectionId: HOST_A })) + ).toEqual({ kind: 'local', hostId: 'local' }) + }) + + it('answers runtime for a runtime row with no nested SSH target', () => { + expect(resolveWorktreeCreateRoute(repoRow({ executionHostId: 'runtime:env-1' }))).toEqual({ + kind: 'runtime', + hostId: 'runtime:env-1', + environmentId: 'env-1' + }) + }) + + it('answers runtime for a runtime row whose nested target is dialable here', () => { + registerSshGitProvider(HOST_A, { name: 'git-a' } as never) + + expect( + resolveWorktreeCreateRoute( + repoRow({ executionHostId: 'runtime:env-1', connectionId: HOST_A }) + ) + ).toMatchObject({ kind: 'runtime', environmentId: 'env-1' }) + }) +}) + +describe('requireWorktreeCreateRoute', () => { + it('refuses a runtime host rather than creating through this client', () => { + expect(() => requireWorktreeCreateRoute(repoRow({ executionHostId: 'runtime:env-1' }))).toThrow( + ExecutionHostNotDispatchableError + ) + }) + + it('passes local and SSH hosts through unchanged', () => { + registerSshGitProvider(HOST_A, { name: 'git-a' } as never) + + expect(requireWorktreeCreateRoute(repoRow({}))).toEqual({ kind: 'local', hostId: 'local' }) + expect(requireWorktreeCreateRoute(repoRow({ executionHostId: 'ssh:target-a' }))).toMatchObject({ + kind: 'ssh', + connectionId: HOST_A + }) + }) +}) diff --git a/src/main/worktree-create-execution-host-route.ts b/src/main/worktree-create-execution-host-route.ts new file mode 100644 index 00000000000..2831a2fcbeb --- /dev/null +++ b/src/main/worktree-create-execution-host-route.ts @@ -0,0 +1,69 @@ +/** + * Which execution host a worktree create runs on. + * + * Two entry points create the same workspace and disagreed about how to read its host. The runtime + * path resolved (`orca-runtime-create-managed-worktree.ts`) and then normalized the row; the IPC + * handler branched on raw `repo.connectionId`, so a row naming its owner only as + * `executionHostId: 'ssh:'` ran `git worktree add` on the client against a remote path + * (#11163). Same repo, two entry points, two answers. + * + * Both now take this one route. + * + * The `repo` on the `ssh` variant is a normalization, and it is a workaround rather than the + * pattern: `createRemoteWorktree` and its callees re-read `repo.connectionId!` at five depths + * (`ipc/worktree-remote.ts`), so the resolved connection has to be handed to them through the field + * they already read. It travels only as far as this object does — anything downstream that re-reads + * the row from the store still sees the unnormalized one. The real fix is to give that pipeline an + * explicit connection parameter and delete `repo.connectionId!` from it, which is a separate change. + */ + +import { getRepoExecutionHostId, type LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import type { Repo } from '../shared/repo-types' +import { + ExecutionHostNotDispatchableError, + resolveGitRouteForHost +} from './providers/execution-host-provider-dispatch' + +export type WorktreeCreateRoute = + | { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID } + | { + kind: 'ssh' + hostId: `ssh:${string}` + connectionId: string + /** The row with `connectionId` set to the resolved target; see the workaround note above. */ + repo: Repo + } + | { kind: 'runtime'; hostId: `runtime:${string}`; environmentId: string } + +export function resolveWorktreeCreateRoute(repo: Repo): WorktreeCreateRoute { + const route = resolveGitRouteForHost(getRepoExecutionHostId(repo)) + switch (route.kind) { + case 'local': + return { kind: 'local', hostId: route.hostId } + case 'ssh': + return { + kind: 'ssh', + hostId: route.hostId, + connectionId: route.connectionId, + repo: { ...repo, connectionId: route.connectionId } + } + case 'runtime': + return { kind: 'runtime', hostId: route.hostId, environmentId: route.environmentId } + } +} + +/** + * For the two create forks that put files on a host. `runtime:` is not one of them: the + * environment's own server creates the worktree, and the SSH target on its repo row is that + * server's nested one, addressable only as (environmentId, targetId). Creating through this + * client's SSH table would `git worktree add` on a same-named target on the wrong machine. + */ +export function requireWorktreeCreateRoute( + repo: Repo +): Exclude { + const route = resolveWorktreeCreateRoute(repo) + if (route.kind === 'runtime') { + throw new ExecutionHostNotDispatchableError(route.hostId) + } + return route +} diff --git a/src/main/worktree-removal-execution-host-route.test.ts b/src/main/worktree-removal-execution-host-route.test.ts new file mode 100644 index 00000000000..5fbbfbf9540 --- /dev/null +++ b/src/main/worktree-removal-execution-host-route.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + registerSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE, + unregisterSshGitProvider +} from './providers/ssh-git-dispatch' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from './providers/ssh-filesystem-dispatch' +import { ExecutionHostNotDispatchableError } from './providers/execution-host-provider-dispatch' +import { + getWorktreeRemovalConnectionId, + resolveWorktreeRemovalRoute +} from './worktree-removal-execution-host-route' + +const HOST_A = 'target-a' +const HOST_B = 'target-b' + +function gitProvider(name: string): never { + return { name } as never +} + +function fsProvider(name: string): never { + return { name } as never +} + +afterEach(() => { + unregisterSshGitProvider(HOST_A) + unregisterSshGitProvider(HOST_B) + unregisterSshFilesystemProvider(HOST_A) + unregisterSshFilesystemProvider(HOST_B) +}) + +describe('resolveWorktreeRemovalRoute', () => { + it('routes a local host to this machine with no connection', () => { + const route = resolveWorktreeRemovalRoute('local') + + expect(route).toEqual({ kind: 'local', hostId: 'local' }) + expect(getWorktreeRemovalConnectionId(route)).toBeUndefined() + }) + + it('keeps two simultaneously registered SSH hosts on their own providers', () => { + registerSshGitProvider(HOST_A, gitProvider('git-a')) + registerSshGitProvider(HOST_B, gitProvider('git-b')) + registerSshFilesystemProvider(HOST_A, fsProvider('fs-a')) + registerSshFilesystemProvider(HOST_B, fsProvider('fs-b')) + + const routeA = resolveWorktreeRemovalRoute('ssh:target-a') + const routeB = resolveWorktreeRemovalRoute('ssh:target-b') + + expect(routeA).toMatchObject({ + kind: 'ssh', + hostId: 'ssh:target-a', + connectionId: HOST_A, + provider: { name: 'git-a' }, + fsProvider: { name: 'fs-a' } + }) + expect(routeB).toMatchObject({ + kind: 'ssh', + hostId: 'ssh:target-b', + connectionId: HOST_B, + provider: { name: 'git-b' }, + fsProvider: { name: 'fs-b' } + }) + expect(getWorktreeRemovalConnectionId(routeA)).toBe(HOST_A) + expect(getWorktreeRemovalConnectionId(routeB)).toBe(HOST_B) + }) + + it('carries a null filesystem provider without falling back to the local one', () => { + registerSshGitProvider(HOST_A, gitProvider('git-a')) + + expect(resolveWorktreeRemovalRoute('ssh:target-a')).toMatchObject({ + kind: 'ssh', + fsProvider: null + }) + }) + + it('refuses an unreachable SSH host instead of answering local', () => { + expect(() => resolveWorktreeRemovalRoute('ssh:target-a')).toThrow( + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + ) + }) + + it('refuses a runtime host with no nested SSH target', () => { + expect(() => resolveWorktreeRemovalRoute('runtime:env-1')).toThrow( + ExecutionHostNotDispatchableError + ) + }) + + it('refuses a runtime host even when a same-named target is dialable here', () => { + // The nested target lives in the environment's namespace; a same-named local one is a + // different machine, and removing a worktree through it deletes the wrong checkout. + registerSshGitProvider(HOST_A, gitProvider('git-a')) + + expect(() => resolveWorktreeRemovalRoute('runtime:target-a')).toThrow( + ExecutionHostNotDispatchableError + ) + }) +}) diff --git a/src/main/worktree-removal-execution-host-route.ts b/src/main/worktree-removal-execution-host-route.ts new file mode 100644 index 00000000000..529eed71af5 --- /dev/null +++ b/src/main/worktree-removal-execution-host-route.ts @@ -0,0 +1,81 @@ +/** + * Which execution host a destructive worktree removal runs against. + * + * `removeManagedWorktree` resolved its host once, for metadata pruning + * (`cleanupHostId ?? getRepoExecutionHostId(repo)`), and then read raw `repo.connectionId` for + * every step that actually touches the filesystem: the `git worktree list` that decides whether the + * path is registered, the provider handed to the unregistered-removal branch, the + * registered-remote-vs-local fork, and the PTY/history teardown. One function, two spellings — + * so a row naming its owner only as `executionHostId: 'ssh:'` listed a *remote* checkout on + * this client, entered the unregistered branch with `provider: null`, and deleted a same-named + * local directory while metadata was pruned under `ssh:` (#11163). #18358 made that + * reachable by migrating the cleanup scan, so those rows now surface as removable candidates. + * + * Routing is now one answer for the whole removal, taken from the host the prune already used, so + * list, remove and prune cannot disagree. The ambiguous `provider: SshGitProvider | null` carrier + * is deleted from the callees rather than supplemented, which makes every remaining reader a + * compile error in the typed modules that do the destructive work. + * + * `runtime:` is not a variant. Its files live on that environment's own server and the SSH + * target on its repo row is that server's nested one, addressable only as the pair + * (environmentId, targetId); handing it to this client's SSH table would `git worktree remove` a + * same-named path on the wrong machine. It throws, matching `workspace-cleanup-git-route` and + * `runtime-git-command-target`. + * + * An `ssh:` host with no registered provider also throws. Loss of contact is never evidence that + * the checkout is local (docs/reference/ssh-execution-boundary.md); refusing leaves a remote + * worktree in place, while the incumbent fallback deleted a client-side path. + */ + +import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { + ExecutionHostNotDispatchableError, + resolveFilesystemRouteForHost, + resolveGitRouteForHost +} from './providers/execution-host-provider-dispatch' +import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from './providers/ssh-git-dispatch' +import type { SshGitProvider } from './providers/ssh-git-provider' +import type { IFilesystemProvider } from './providers/types' + +export type WorktreeRemovalRoute = + | { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID } + | { + kind: 'ssh' + hostId: `ssh:${string}` + connectionId: string + provider: SshGitProvider + /** + * Still nullable: the incumbent read `getSshFilesystemProvider` (not `require…`) and the + * directory branches raise their own message when they need it. Narrowing it here would + * refuse removals that never touch the filesystem provider. + */ + fsProvider: IFilesystemProvider | null + } + +export function resolveWorktreeRemovalRoute(hostId: ExecutionHostId): WorktreeRemovalRoute { + const route = resolveGitRouteForHost(hostId) + switch (route.kind) { + case 'local': + return { kind: 'local', hostId: route.hostId } + case 'runtime': + throw new ExecutionHostNotDispatchableError(route.hostId) + case 'ssh': { + if (!route.provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + const fsRoute = resolveFilesystemRouteForHost(hostId) + return { + kind: 'ssh', + hostId: route.hostId, + connectionId: route.connectionId, + provider: route.provider, + fsProvider: fsRoute.kind === 'ssh' ? fsRoute.provider : null + } + } + } +} + +/** The connection to teardown PTYs, watchers and history against — `undefined` on a local host. */ +export function getWorktreeRemovalConnectionId(route: WorktreeRemovalRoute): string | undefined { + return route.kind === 'ssh' ? route.connectionId : undefined +} diff --git a/src/relay/git-branch-delete-refusal-parity.test.ts b/src/relay/git-branch-delete-refusal-parity.test.ts new file mode 100644 index 00000000000..71344bca940 --- /dev/null +++ b/src/relay/git-branch-delete-refusal-parity.test.ts @@ -0,0 +1,205 @@ +/** + * The relay and the desktop each carried their own `getErrorText`, and they had + * drifted: the relay read `message` + `stderr` + `stdout`, the desktop only + * `message` + `stderr`. So a `git branch -d` refusal that arrived on `stdout` + * routed the SSH removal through prune-and-retry while the local removal gave up + * and preserved the branch. + * + * These tests push the same failure through both published removal entry points — + * `removeWorktreeOp` (what `git.removeWorktree` runs on the host) and `removeWorktree` + * (the local runner) — and require the same branch-deletion commands and the same + * `RemoveWorktreeResult`. A second error-text reader on either side fails here. + */ +import type * as FsPromises from 'node:fs/promises' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, resolveGitDirMock, moveWorktreeDirectoryToTrashMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + resolveGitDirMock: vi.fn(), + moveWorktreeDirectoryToTrashMock: vi.fn() + }) +) + +vi.mock('../main/worktree-trash', () => ({ + moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, + restoreWorktreeDirectoryFromTrash: vi.fn(async () => true), + scheduleWorktreeTrashDeletion: vi.fn() +})) + +vi.mock('../main/git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn(), + translateWslOutputPaths: (output: string) => output +})) + +vi.mock('../main/git/status', () => ({ + resolveGitDir: resolveGitDirMock, + runWithGitReadCacheInvalidation: (run: () => Promise) => run() +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { + ...actual, + stat: vi.fn(async () => { + throw enoent() + }), + readFile: vi.fn() + } +}) + +import { GitCapabilityCache } from '../shared/git-capability-cache' +import type { RemoveWorktreeResult } from '../shared/worktree/create-types' +import { clearGitCapabilityStateForTests } from '../main/git/git-capability-state' +import { _resetWorktreeScanCacheForTests, removeWorktree } from '../main/git/worktree' +import { __resetSparseCheckoutStateCacheForTests } from '../main/git/worktree-sparse-checkout-cache' +import type { GitExec } from './git-handler-ops' +import { removeWorktreeOp } from './git-handler-worktree-ops' + +const REPO_PATH = '/repo' +const WORKTREE_PATH = '/repo-feature' +const BRANCH = 'feature/test' + +function enoent(): Error { + return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +} + +/** Only the branch-deletion phase; the two entry points legitimately reach it by different routes. */ +function branchDeletionCalls(calls: string[][]): string[] { + return calls + .map((args) => args.join(' ')) + .filter((call) => call.startsWith('branch ') || call === 'worktree prune') +} + +function worktreeListPorcelain(withFeature: boolean): string { + const blocks = [[`worktree ${REPO_PATH}`, 'HEAD abc123', 'branch refs/heads/main']] + if (withFeature) { + blocks.push([`worktree ${WORKTREE_PATH}`, 'HEAD def456', `branch refs/heads/${BRANCH}`]) + } + return `${blocks.map((block) => block.join('\n')).join('\n\n')}\n` +} + +type RefusalStream = 'stdout' | 'stderr' + +const REFUSAL_TEXT = `error: cannot delete branch '${BRANCH}' used by worktree at '/repo-stale'` + +/** + * A `branch -d` rejection carrying the refusal on exactly one stream. `message` stays + * generic so the assertion is about the stream, not about Node's stderr echo. + */ +function branchDeleteRefusal(stream: RefusalStream): Error { + return Object.assign(new Error('Command failed: git branch -d'), { + code: 1, + stdout: stream === 'stdout' ? REFUSAL_TEXT : '', + stderr: stream === 'stderr' ? REFUSAL_TEXT : '' + }) +} + +/** Refuses the first `branch -d`, accepts the retry that follows `worktree prune`. */ +function scriptRelayGit(stream: RefusalStream): { + git: GitExec + calls: string[][] +} { + const calls: string[][] = [] + let branchDeleteCount = 0 + const git = vi.fn(async (args) => { + calls.push(args) + if (args[0] === 'rev-parse') { + return { stdout: `${REPO_PATH}/.git\n`, stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { stdout: worktreeListPorcelain(true), stderr: '' } + } + if (args[0] === 'branch' && args[1] === '-d') { + branchDeleteCount += 1 + if (branchDeleteCount === 1) { + throw branchDeleteRefusal(stream) + } + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + return { git, calls } +} + +function scriptDesktopGit(stream: RefusalStream): string[][] { + const calls: string[][] = [] + let branchDeleteCount = 0 + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + calls.push(args) + if (args[0] === 'worktree' && args[1] === 'list') { + return { stdout: worktreeListPorcelain(branchDeleteCount === 0), stderr: '' } + } + if (args[0] === 'branch' && args[1] === '-d') { + branchDeleteCount += 1 + if (branchDeleteCount === 1) { + throw branchDeleteRefusal(stream) + } + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + return calls +} + +async function removeOverRelay( + stream: RefusalStream +): Promise<{ result: RemoveWorktreeResult; branchCalls: string[] }> { + const { git, calls } = scriptRelayGit(stream) + const result = await removeWorktreeOp( + git, + { worktreePath: WORKTREE_PATH }, + new GitCapabilityCache() + ) + return { result, branchCalls: branchDeletionCalls(calls) } +} + +async function removeLocally( + stream: RefusalStream +): Promise<{ result: RemoveWorktreeResult; branchCalls: string[] }> { + const calls = scriptDesktopGit(stream) + const result = await removeWorktree(REPO_PATH, WORKTREE_PATH) + return { result, branchCalls: branchDeletionCalls(calls) } +} + +beforeEach(() => { + clearGitCapabilityStateForTests() + _resetWorktreeScanCacheForTests() + __resetSparseCheckoutStateCacheForTests() + gitExecFileAsyncMock.mockReset() + resolveGitDirMock.mockReset() + resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) + moveWorktreeDirectoryToTrashMock.mockReset() + // Default: the checkout cannot be renamed aside, so removal runs `worktree remove` in place. + moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) +}) + +describe('relay/desktop branch-delete refusal parity', () => { + it('prunes and retries on both paths when the refusal arrives on stdout', async () => { + const relay = await removeOverRelay('stdout') + const local = await removeLocally('stdout') + + expect(relay.branchCalls).toEqual(local.branchCalls) + expect(relay.result).toEqual(local.result) + expect(local.branchCalls).toEqual([ + `branch -d -- ${BRANCH}`, + 'worktree prune', + `branch -d -- ${BRANCH}` + ]) + expect(local.result).toEqual({}) + }) + + it('prunes and retries on both paths when the refusal arrives on stderr, as real Git sends it', async () => { + const relay = await removeOverRelay('stderr') + const local = await removeLocally('stderr') + + expect(relay.branchCalls).toEqual(local.branchCalls) + expect(relay.result).toEqual(local.result) + expect(local.branchCalls).toEqual([ + `branch -d -- ${BRANCH}`, + 'worktree prune', + `branch -d -- ${BRANCH}` + ]) + }) +}) diff --git a/src/relay/git-handler-push-target.ts b/src/relay/git-handler-push-target.ts index d81111d45d3..6663b5b3ad3 100644 --- a/src/relay/git-handler-push-target.ts +++ b/src/relay/git-handler-push-target.ts @@ -1,168 +1,24 @@ import { assertGitPushTargetShape } from '../shared/git-push-target-validation' -import { gitRefTargetsBranchOnRemote } from '../shared/git-remote-branch-name' -import { findGitRemoteNameByFetchUrl } from '../shared/git-remote-url-index' +import { + resolveConfiguredGitPushTarget, + type ResolvedGitPushTarget +} from '../shared/git-push-target-resolution' import type { GitPushTarget } from '../shared/worktree/types' type RelayGit = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> -export type ResolvedPushTarget = { - remote: string - refspec: string -} - -async function getConfiguredPushTarget( - git: RelayGit, - worktreePath: string -): Promise { - try { - const { stdout: branchStdout } = await git( - ['symbolic-ref', '--quiet', '--short', 'HEAD'], - worktreePath - ) - const branch = branchStdout.trim() - if (!branch) { - return null - } - const [pushRemote, { stdout: mergeStdout }] = await Promise.all([ - getConfiguredPushRemote(git, worktreePath, branch), - git(['config', '--get', `branch.${branch}.merge`], worktreePath) - ]) - const remote = pushRemote?.remote - const mergeRef = mergeStdout.trim() - const branchRef = mergeRef.replace(/^refs\/heads\//, '') - if (!remote || !branchRef || remote === '.' || branchRef === mergeRef) { - return null - } - if (await branchMergeTargetsConfiguredBase(git, worktreePath, branch, remote, branchRef)) { - return null - } - if (!canPushConfiguredMergeBranch(pushRemote, branch, branchRef)) { - return null - } - return { remote, refspec: `HEAD:${branchRef}` } - } catch { - return null - } -} - -async function getConfigValue( - git: RelayGit, - worktreePath: string, - key: string -): Promise { - try { - const { stdout } = await git(['config', '--get', key], worktreePath) - const value = stdout.trim() - return value || null - } catch { - return null - } -} - -function isUrlValuedRemote(remote: string): boolean { - return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(remote) || /^[^@/:]+@[^:]+:.+/.test(remote) -} - -type ConfiguredPushRemote = { - remote: string - branchRemote: string | null -} - -// Host-side twin of `src/main/git/remote.ts`: one `git remote -v` instead of -// `git remote` plus a serial `git remote get-url` per remote. -async function findRemoteNameForUrl( - git: RelayGit, - worktreePath: string, - remoteUrl: string -): Promise { - try { - const { stdout } = await git(['remote', '-v'], worktreePath) - return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) - } catch { - return null - } -} - -async function normalizePushRemote( - git: RelayGit, - worktreePath: string, - remote: string -): Promise { - if (!isUrlValuedRemote(remote)) { - return remote - } - return (await findRemoteNameForUrl(git, worktreePath, remote)) ?? remote -} - -async function getConfiguredPushRemote( - git: RelayGit, - worktreePath: string, - branch: string -): Promise { - // Why: mirror the local gitPush resolver so SSH worktrees do not drift to a - // different target when branch.pushRemote or remote.pushDefault is present. - const branchRemote = await getConfigValue(git, worktreePath, `branch.${branch}.remote`) - const remote = - (await getConfigValue(git, worktreePath, `branch.${branch}.pushRemote`)) ?? - (await getConfigValue(git, worktreePath, 'remote.pushDefault')) ?? - branchRemote - if (!remote) { - return null - } - const normalizedRemote = await normalizePushRemote(git, worktreePath, remote) - // The two usually name the same URL; resolving it twice reads the remote table twice. - if (!branchRemote) { - return { remote: normalizedRemote, branchRemote: null } - } - return { - remote: normalizedRemote, - branchRemote: - branchRemote === remote - ? normalizedRemote - : await normalizePushRemote(git, worktreePath, branchRemote) - } -} - -async function branchMergeTargetsConfiguredBase( - git: RelayGit, - worktreePath: string, - branch: string, - remote: string, - branchRef: string -): Promise { - return gitRefTargetsBranchOnRemote( - await getConfigValue(git, worktreePath, `branch.${branch}.base`), - remote, - branchRef - ) -} - -function canPushConfiguredMergeBranch( - pushRemote: ConfiguredPushRemote | null, - branch: string, - branchRef: string -): boolean { - if (!pushRemote) { - return false - } - if (branchRef === branch) { - return true - } - // Why: branch.merge belongs to branch.remote. A pushDefault fork must not - // inherit origin/main as its destination branch. - return pushRemote.remote !== 'origin' && pushRemote.branchRemote === pushRemote.remote -} - export async function resolveRelayPushTarget( git: RelayGit, worktreePath: string, pushTarget: unknown -): Promise { +): Promise { if (pushTarget === undefined) { - return getConfiguredPushTarget(git, worktreePath) + return resolveConfiguredGitPushTarget((args) => git(args, worktreePath)) } assertGitPushTargetShape(pushTarget) const explicitTarget: GitPushTarget = pushTarget + // Why here and not in the shared resolver: an explicit target arrives over the wire, + // so the host re-validates its shape and asks Git to vet the branch name itself. await git(['check-ref-format', '--branch', explicitTarget.branchName], worktreePath) return { remote: explicitTarget.remoteName, diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts index 474e03bab88..bf8e65a066e 100644 --- a/src/relay/git-handler-worktree-remove.ts +++ b/src/relay/git-handler-worktree-remove.ts @@ -1,5 +1,6 @@ import * as path from 'node:path' import type { RemoveWorktreeResult } from '../shared/worktree/create-types' +import { isBranchCheckedOutInWorktreeError } from '../shared/git-branch-delete-refusal' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' import { isSubmoduleWorktreeRemovalRefusal } from '../shared/worktree/submodule-removal' import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' @@ -7,29 +8,6 @@ import type { GitExec } from './git-handler-ops' import type { GitCapabilityCache } from '../shared/git-capability-cache' import { readRelayWorktreeList } from './git-handler-worktree-list' -function getErrorText(error: unknown): string { - if (typeof error === 'object' && error !== null) { - const parts: string[] = [] - if ('message' in error && typeof error.message === 'string') { - parts.push(error.message) - } - if ('stderr' in error && typeof error.stderr === 'string') { - parts.push(error.stderr) - } - if ('stdout' in error && typeof error.stdout === 'string') { - parts.push(error.stdout) - } - return parts.join('\n') - } - return String(error) -} - -function isBranchCheckedOutInWorktreeError(error: unknown): boolean { - return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test( - getErrorText(error) - ) -} - function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } diff --git a/src/relay/git-porcelain-local-parity.test.ts b/src/relay/git-porcelain-local-parity.test.ts index d4969f6fd0e..9fccc85e7b3 100644 --- a/src/relay/git-porcelain-local-parity.test.ts +++ b/src/relay/git-porcelain-local-parity.test.ts @@ -160,7 +160,8 @@ describe('relay/desktop unmerged-entry porcelain parity', () => { const unmergedLines = [ 'u UU N... 100644 100644 100644 100644 aa bb cc plain.ts', 'u UD N... 100644 100644 000000 100644 aa bb cc "present \\303\\251.ts"', - 'u UD N... 100644 100644 000000 100644 aa bb cc "missing \\303\\251.ts"', + // mW=000000: real Git reports an absent working-tree path this way, and the file is not created below. + 'u UD N... 100644 100644 000000 000000 aa bb cc "missing \\303\\251.ts"', 'u DD N... 100644 100644 000000 000000 aa bb cc both-gone.ts' ] const git = vi.fn(async (args) => { diff --git a/src/relay/git-push-target-local-parity.test.ts b/src/relay/git-push-target-local-parity.test.ts new file mode 100644 index 00000000000..152b062d88e --- /dev/null +++ b/src/relay/git-push-target-local-parity.test.ts @@ -0,0 +1,209 @@ +/** + * Push-target resolution decides which remote a plain `git push` hits, and a wrong + * answer is not recoverable by retrying. The relay and the desktop used to carry + * identical ~160-line copies of it; they now share one implementation. + * + * These tests script one repository's Git config and require `git.push` over the real + * relay dispatcher and the desktop's `gitPush` to emit the *same push argv*, plus the + * argv each case is supposed to produce — so a second implementation on either side + * fails here even if it is wrong in the same direction on both. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('../main/git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { gitPush } from '../main/git/remote' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { createMockDispatcher, type RelayDispatcher } from './git-handler-test-setup' + +const WORKTREE_PATH = '/worktree' + +type GitConfigFixture = { + /** Empty means detached HEAD: `symbolic-ref --quiet --short HEAD` prints nothing. */ + branch: string + merge?: string + branchRemote?: string + pushRemote?: string + pushDefault?: string + base?: string + /** remote name -> fetch URL, as `git remote -v` prints it. */ + remotes?: Record +} + +type GitSpyTarget = { + git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> +} + +/** One scripted repository, driven identically by both hosts. */ +function scriptGit(fixture: GitConfigFixture) { + const configValues = new Map() + const put = (key: string, value: string | undefined): void => { + if (value !== undefined) { + configValues.set(key, value) + } + } + put(`branch.${fixture.branch}.merge`, fixture.merge) + put(`branch.${fixture.branch}.remote`, fixture.branchRemote) + put(`branch.${fixture.branch}.pushRemote`, fixture.pushRemote) + put(`branch.${fixture.branch}.base`, fixture.base) + put('remote.pushDefault', fixture.pushDefault) + + const calls: string[][] = [] + return { + calls, + run: async (args: string[]): Promise<{ stdout: string; stderr: string }> => { + calls.push(args) + if (args[0] === 'symbolic-ref') { + return { stdout: `${fixture.branch}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get') { + const value = configValues.get(args[2] ?? '') + // Why throw: `git config --get` exits 1 for a missing key, and the resolver's + // fallback chain reads that rejection, not an empty string. + if (value === undefined) { + throw Object.assign(new Error('missing config key'), { code: 1 }) + } + return { stdout: `${value}\n`, stderr: '' } + } + if (args[0] === 'remote' && args[1] === '-v') { + const lines = Object.entries(fixture.remotes ?? {}).flatMap(([name, url]) => [ + `${name}\t${url} (fetch)`, + `${name}\t${url} (push)` + ]) + return { stdout: `${lines.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'push') { + return { stdout: '', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + } + } +} + +function pushArgv(calls: string[][]): string[] { + const push = calls.find((args) => args[0] === 'push') + if (!push) { + throw new Error('no push command was issued') + } + return push +} + +async function pushOverRelay(fixture: GitConfigFixture): Promise { + const dispatcher = createMockDispatcher() + const handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + const script = scriptGit(fixture) + vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args) => script.run(args)) + await dispatcher.callRequest('git.push', { worktreePath: WORKTREE_PATH }) + return pushArgv(script.calls) +} + +async function pushLocally(fixture: GitConfigFixture): Promise { + const script = scriptGit(fixture) + gitExecFileAsyncMock.mockImplementation((args: string[]) => script.run(args)) + await gitPush(WORKTREE_PATH) + return pushArgv(script.calls) +} + +async function expectSamePushArgv(fixture: GitConfigFixture, expected: string[]): Promise { + const relayArgv = await pushOverRelay(fixture) + const localArgv = await pushLocally(fixture) + expect(relayArgv).toEqual(localArgv) + expect(localArgv).toEqual(expected) +} + +const FIRST_PUBLISH = ['push', '--set-upstream', 'origin', 'HEAD'] + +beforeEach(() => { + gitExecFileAsyncMock.mockReset() +}) + +describe('relay/desktop push-target parity', () => { + it('sends a review branch to the fork its pushDefault names', async () => { + await expectSamePushArgv( + { + branch: 'review/pr-1738', + merge: 'refs/heads/contributor/fix', + branchRemote: 'fork', + pushDefault: 'fork' + }, + ['push', '--set-upstream', 'fork', 'HEAD:contributor/fix'] + ) + }) + + it('refuses to inherit origin/main as a destination for a differently named branch', async () => { + // branch.merge belongs to branch.remote; a branch tracking origin/main must + // first-publish under its own name rather than push onto main. + await expectSamePushArgv( + { + branch: 'feature/fix', + merge: 'refs/heads/main', + branchRemote: 'origin' + }, + FIRST_PUBLISH + ) + }) + + it('refuses a pushDefault fork whose branch.remote names a different remote', async () => { + await expectSamePushArgv( + { + branch: 'review/pr-1738', + merge: 'refs/heads/contributor/fix', + branchRemote: 'origin', + pushDefault: 'fork' + }, + FIRST_PUBLISH + ) + }) + + it('refuses when branch.base names the same remote branch as branch.merge', async () => { + await expectSamePushArgv( + { + branch: 'feature/fix', + merge: 'refs/heads/release', + branchRemote: 'fork', + pushRemote: 'fork', + base: 'fork/release' + }, + FIRST_PUBLISH + ) + }) + + it('resolves a URL-valued pushRemote back to its remote name', async () => { + await expectSamePushArgv( + { + branch: 'review/pr-1738', + merge: 'refs/heads/contributor/fix', + branchRemote: 'git@example.invalid:contributor/repo.git', + pushRemote: 'git@example.invalid:contributor/repo.git', + remotes: { + origin: 'git@example.invalid:upstream/repo.git', + fork: 'git@example.invalid:contributor/repo.git' + } + }, + ['push', '--set-upstream', 'fork', 'HEAD:contributor/fix'] + ) + }) + + it('treats a local-repository remote as no configured target', async () => { + await expectSamePushArgv( + { + branch: 'feature/fix', + merge: 'refs/heads/feature/fix', + branchRemote: '.' + }, + FIRST_PUBLISH + ) + }) + + it('first-publishes a branch with no configured remote at all', async () => { + await expectSamePushArgv( + { branch: 'feature/fix', merge: 'refs/heads/feature/fix' }, + FIRST_PUBLISH + ) + }) +}) diff --git a/src/relay/pty-handler-attach-replay.test.ts b/src/relay/pty-handler-attach-replay.test.ts index 6af9faec047..e289547bf0c 100644 --- a/src/relay/pty-handler-attach-replay.test.ts +++ b/src/relay/pty-handler-attach-replay.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import * as ptyShellUtils from './pty-shell-utils' +import { + PTY_ATTACH_PROVEN_EXITED_MARKER, + isProvenExitedPtyAttachRefusal +} from '../shared/pty-attach-absence-evidence' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -87,7 +91,7 @@ describe('PtyHandler', () => { try { await expect( dispatcher.callRequest('pty.attach', { id: PTY_1, suppressReplayNotification: true }) - ).rejects.toThrow(`PTY "${PTY_1}" not found`) + ).rejects.toThrow(`PTY "${PTY_1}" not found (${PTY_ATTACH_PROVEN_EXITED_MARKER})`) } finally { aliveSpy.mockRestore() } @@ -96,9 +100,18 @@ describe('PtyHandler', () => { // is freed so a later attach also cleanly reports not-found. expect(exits).toEqual([{ id: PTY_1, paneKey: 'tab-dead:0' }]) expect(handler.activePtyCount).toBe(0) - await expect( - dispatcher.callRequest('pty.attach', { id: PTY_1, suppressReplayNotification: true }) - ).rejects.toThrow(`PTY "${PTY_1}" not found`) + const unknownId = await dispatcher + .callRequest('pty.attach', { id: PTY_1, suppressReplayNotification: true }) + .then( + () => new Error('expected the attach to be refused'), + (error: Error) => error + ) + + // The second refusal is the shape a restarted relay gives for every id the previous one minted: + // same words, no liveness check behind them. Only the probed one may be read as a death + // (docs/reference/ssh-execution-boundary.md). + expect(unknownId.message).toContain(`PTY "${PTY_1}" not found`) + expect(isProvenExitedPtyAttachRefusal(unknownId)).toBe(false) }) it('settles concurrent immediate shutdown when attach proves the shell exited', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 1c213325bb8..42f8bdc0a77 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -35,6 +35,7 @@ import { type RelaySpawnCwdResolution } from './pty-spawn-cwd' import { PhysicalExitTracker } from '../shared/physical-exit-tracker' +import { PTY_ATTACH_PROVEN_EXITED_MARKER } from '../shared/pty-attach-absence-evidence' import { SHELL_READY_MARKER_PREFIX } from '../main/shell-ready-marker-scanner' import { createShellStartupOutputScanState, @@ -2055,7 +2056,11 @@ export class PtyHandler { // Why: verify liveness because shells can exit without node-pty onExit. if (this.reapPtyProvenExited(managed)) { - throw new Error(`PTY "${id}" not found`) + // Why the marker: this is the ONLY not-found answer backed by a liveness check. The unmarked + // one above is also thrown for an id this session map never had — every id minted before a + // relay restart — so a client that cannot tell them apart certifies deaths it never observed + // (docs/reference/ssh-execution-boundary.md). + throw new Error(`PTY "${id}" not found (${PTY_ATTACH_PROVEN_EXITED_MARKER})`) } // Why: legacy `pty-N` ids repeated across relay generations; reject conflicting identities. diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 91db232081c..77da19ffd20 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -274,9 +274,11 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) - await timeRendererStartupStep('project-structured-session-tabs', () => - restoreLocalStructuredSessionTabsOnce() - ) + if (useAppStore.getState().settings?.experimentalStructuredNativeChat === true) { + await timeRendererStartupStep('project-structured-session-tabs', () => + restoreLocalStructuredSessionTabsOnce() + ) + } if (cancelled) { return } diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index d1aad35829a..fead2f6c7bb 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -357,6 +357,16 @@ describe('renderer startup runtime routing', () => { expect(reconnectIndex).toBeGreaterThan(capabilityIndex) }) + it('skips startup structured tab projection while the host setting is off', () => { + const source = readSource(STARTUP_HYDRATION_PATH) + const projectIndex = source.indexOf("timeRendererStartupStep('project-structured-session-tabs'") + + expect(projectIndex).toBeGreaterThanOrEqual(0) + expect(source.slice(projectIndex - 180, projectIndex)).toContain( + 'settings?.experimentalStructuredNativeChat === true' + ) + }) + it('orders packaged restoration before adoption, projection, and default creation', () => { // Why this file: the startup sequence moved out of App.tsx into the hydration hook; // the ordering it asserts is unchanged, only the module that now spells it out. diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx new file mode 100644 index 00000000000..fd1a16f7f0a --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom + +import React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { hostOptions, renderCard } from './NewWorkspaceComposerCard.test-fixture' +import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' + +// Counts evaluations of the set-location chunk. A dynamic import evaluates a module once, +// so this only moves when the composer actually reaches for the chunk. +const chunk = vi.hoisted(() => ({ loads: 0 })) + +// Renders a marker unconditionally so the "warming did not mount it" assertion below can +// actually fail; a `() => null` stub would make that check vacuous. +vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => { + chunk.loads += 1 + return { + SetProjectLocationDialog: () =>
+ } +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ + closeModal: vi.fn(), + openModal: vi.fn(), + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn(), + setRuntimeEnvironmentStatus: vi.fn(), + setupProjectExistingFolder: vi.fn(), + setupProjectClone: vi.fn(), + activeModal: 'new-workspace-composer', + settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, + updateSettings: vi.fn(), + projects: [], + repos: [] + }), + { getState: () => ({}) } + ) +})) + +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: vi.fn() +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/agent/AgentCombobox', () => ({ + default: () => +})) + +vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({ + AddRemoteHostDialog: () => null +})) + +vi.mock('@/components/sparse/SparseCheckoutPresetSelect', () => ({ + default: () => null +})) + +vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({ + default: () => +})) + +vi.mock('@/components/new-workspace/ProjectCombobox', () => ({ + default: () =>
+})) + +const readyOnlyHostOptions = hostOptions.filter((option) => option.kind === 'ready') +// A disconnected host is a needs-setup row with no "Set location" action, so it must not warm. +const unavailableHostOptions: ProjectHostSetupOption[] = [ + ...readyOnlyHostOptions, + { + kind: 'needs-setup', + id: 'needs-setup:ssh:offline', + projectId: 'project-group:platform', + hostId: 'ssh:offline', + label: 'Offline box', + detail: 'Not connected', + isAvailable: false, + attention: false, + canSetLocation: false + } +] + +// Declaration order matters here and nowhere else: a module evaluates once, so the +// no-warm cases have to observe the counter before anything warms it. +describe('NewWorkspaceComposerCard set-location chunk warm', () => { + let container: HTMLDivElement | null = null + + afterEach(() => { + container?.remove() + container = null + }) + + it('does not warm the chunk when no host needs its location set', async () => { + container = await renderCard({ projectHostSetupOptions: readyOnlyHostOptions }) + + expect( + [...container.querySelectorAll('button')].some((button) => + button.textContent?.includes('Set project location') + ) + ).toBe(false) + expect(chunk.loads).toBe(0) + }) + + it('does not warm the chunk when the needs-setup host cannot take a location', async () => { + container = await renderCard({ projectHostSetupOptions: unavailableHostOptions }) + + expect(chunk.loads).toBe(0) + }) + + it('warms the chunk on mount for a needs-setup host, before Set project location is clicked', async () => { + container = await renderCard() + + expect(chunk.loads).toBe(1) + // Warming must not mount the dialog; it still waits on an explicit click. + expect(document.body.querySelector('[data-testid="set-project-location-dialog"]')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx index 9cdea1d7d54..06718fab91b 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx @@ -1,11 +1,8 @@ // @vitest-environment happy-dom import React, { act } from 'react' -import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import NewWorkspaceComposerCard from './NewWorkspaceComposerCard' -import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' -import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' +import { renderCard } from './NewWorkspaceComposerCard.test-fixture' const storeMocks = vi.hoisted(() => ({ closeModal: vi.fn(), @@ -99,118 +96,6 @@ vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({ ) : null })) -const projectOptions: NewWorkspaceProjectOption[] = [ - { - kind: 'project-group', - id: 'project-group:platform', - projectGroupId: 'platform', - displayName: 'Platform', - badgeColor: 'var(--muted-foreground)', - detail: '/workspace/platform', - parentPath: '/workspace/platform', - connectionId: null - } -] - -const hostOptions: ProjectHostSetupOption[] = [ - { - kind: 'ready', - id: 'setup-local', - projectId: 'project-group:platform', - hostId: 'local', - repoId: 'repo-a', - label: 'Local Mac', - detail: 'Orca', - path: '/Users/alice/orca' - }, - { - kind: 'needs-setup', - id: 'needs-setup:ssh:devbox', - projectId: 'project-group:platform', - hostId: 'ssh:devbox', - label: 'Devbox', - detail: 'Project location not set', - isAvailable: true, - attention: false, - canSetLocation: true - } -] - -function renderCard( - overrides: Partial> = {} -): HTMLDivElement { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - act(() => { - root.render( - {}} - eligibleRepos={[]} - repoId="repo-a" - projectOptions={projectOptions} - selectedProjectId="project-group:platform" - selectedRepoIsGit - onRepoChange={() => {}} - onProjectChange={() => {}} - primaryActionLabel="Create workspace" - name="" - onNameValueChange={() => {}} - onSmartGitHubItemSelect={() => {}} - onSmartGitLabItemSelect={() => {}} - onSmartBranchSelect={() => {}} - onSmartLinearIssueSelect={() => {}} - smartNameSelection={null} - onClearSmartNameSelection={() => {}} - canReuseSelectedBranch={false} - reuseSelectedBranch={false} - onReuseSelectedBranchChange={() => {}} - forkPushWarning={null} - detectedAgentIds={null} - onOpenAgentSettings={() => {}} - advancedOpen={false} - onToggleAdvanced={() => {}} - parentWorktreeId={null} - onParentWorktreeIdChange={() => {}} - createDisabled={false} - projectError={null} - creating={false} - onCreate={() => {}} - note="" - onNoteChange={() => {}} - setupConfig={null} - requiresExplicitSetupChoice={false} - setupDecision={null} - onSetupDecisionChange={() => {}} - setupAgentStartupPolicy="start-immediately" - onSetupAgentStartupPolicyChange={() => {}} - shouldWaitForSetupCheck={false} - resolvedSetupDecision={null} - createError={null} - selectedRepoConnectionId={null} - selectedRepoSshStatus={null} - selectedRepoRequiresConnection={false} - selectedRepoConnectInProgress={false} - onConnectSelectedRepo={async () => {}} - canUseSparseCheckout={false} - sparsePresets={[]} - sparseSelectedPresetId={null} - onSparseSelectPreset={() => {}} - branchNameOverride={undefined} - onBranchNameOverrideChange={() => {}} - branchesEnabled={false} - setupControlsEnabled={false} - sparseControlsEnabled={false} - projectHostSetupOptions={hostOptions} - selectedProjectHostSetupId="setup-local" - {...overrides} - /> - ) - }) - return container -} - describe('NewWorkspaceComposerCard set location', () => { let container: HTMLDivElement | null = null @@ -225,9 +110,10 @@ describe('NewWorkspaceComposerCard set location', () => { container = null }) - it('opens set-location over the composer without leaving the create dialog', () => { + // Async because the dialog is a lazy chunk: the click mounts Suspense, the chunk resolves next tick. + it('opens set-location over the composer without leaving the create dialog', async () => { const nestedOpenChanges: boolean[] = [] - container = renderCard({ + container = await renderCard({ onNestedDialogOpenChange: (open) => nestedOpenChanges.push(open) }) @@ -239,6 +125,7 @@ describe('NewWorkspaceComposerCard set location', () => { ) expect(setLocation).toBeTruthy() act(() => setLocation?.click()) + await act(async () => {}) const dialog = document.body.querySelector('[data-testid="set-project-location-dialog"]') expect(dialog?.getAttribute('data-host')).toBe('Devbox') @@ -249,10 +136,12 @@ describe('NewWorkspaceComposerCard set location', () => { expect(storeMocks.openSettingsPage).not.toHaveBeenCalled() }) - it('closes the nested dialog before publishing the ready run target', () => { + // Async for the same reason: without the flush this only passes when an earlier + // test in this file already resolved the shared lazy chunk. + it('closes the nested dialog before publishing the ready run target', async () => { const nestedOpenChanges: boolean[] = [] const setupChanges: string[] = [] - container = renderCard({ + container = await renderCard({ onNestedDialogOpenChange: (open) => nestedOpenChanges.push(open), onProjectHostSetupChange: (setupId) => setupChanges.push(setupId) }) @@ -264,6 +153,7 @@ describe('NewWorkspaceComposerCard set location', () => { (button) => button.textContent?.includes('Set project location') ) act(() => setLocation?.click()) + await act(async () => {}) const complete = [...document.body.querySelectorAll('button')].find( (button) => button.textContent === 'Complete location' ) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test-fixture.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test-fixture.tsx new file mode 100644 index 00000000000..c8eb2b56f43 --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test-fixture.tsx @@ -0,0 +1,120 @@ +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import NewWorkspaceComposerCard from './NewWorkspaceComposerCard' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' + +export const projectOptions: NewWorkspaceProjectOption[] = [ + { + kind: 'project-group', + id: 'project-group:platform', + projectGroupId: 'platform', + displayName: 'Platform', + badgeColor: 'var(--muted-foreground)', + detail: '/workspace/platform', + parentPath: '/workspace/platform', + connectionId: null + } +] + +export const hostOptions: ProjectHostSetupOption[] = [ + { + kind: 'ready', + id: 'setup-local', + projectId: 'project-group:platform', + hostId: 'local', + repoId: 'repo-a', + label: 'Local Mac', + detail: 'Orca', + path: '/Users/alice/orca' + }, + { + kind: 'needs-setup', + id: 'needs-setup:ssh:devbox', + projectId: 'project-group:platform', + hostId: 'ssh:devbox', + label: 'Devbox', + detail: 'Project location not set', + isAvailable: true, + attention: false, + canSetLocation: true + } +] + +export async function renderCard( + overrides: Partial> = {} +): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + {}} + eligibleRepos={[]} + repoId="repo-a" + projectOptions={projectOptions} + selectedProjectId="project-group:platform" + selectedRepoIsGit + onRepoChange={() => {}} + onProjectChange={() => {}} + primaryActionLabel="Create workspace" + name="" + onNameValueChange={() => {}} + onSmartGitHubItemSelect={() => {}} + onSmartGitLabItemSelect={() => {}} + onSmartBranchSelect={() => {}} + onSmartLinearIssueSelect={() => {}} + smartNameSelection={null} + onClearSmartNameSelection={() => {}} + canReuseSelectedBranch={false} + reuseSelectedBranch={false} + onReuseSelectedBranchChange={() => {}} + forkPushWarning={null} + detectedAgentIds={null} + onOpenAgentSettings={() => {}} + advancedOpen={false} + onToggleAdvanced={() => {}} + parentWorktreeId={null} + onParentWorktreeIdChange={() => {}} + createDisabled={false} + projectError={null} + creating={false} + onCreate={() => {}} + note="" + onNoteChange={() => {}} + setupConfig={null} + requiresExplicitSetupChoice={false} + setupDecision={null} + onSetupDecisionChange={() => {}} + setupAgentStartupPolicy="start-immediately" + onSetupAgentStartupPolicyChange={() => {}} + shouldWaitForSetupCheck={false} + resolvedSetupDecision={null} + createError={null} + selectedRepoConnectionId={null} + selectedRepoSshStatus={null} + selectedRepoRequiresConnection={false} + selectedRepoConnectInProgress={false} + onConnectSelectedRepo={async () => {}} + canUseSparseCheckout={false} + sparsePresets={[]} + sparseSelectedPresetId={null} + onSparseSelectPreset={() => {}} + branchNameOverride={undefined} + onBranchNameOverrideChange={() => {}} + branchesEnabled={false} + setupControlsEnabled={false} + sparseControlsEnabled={false} + projectHostSetupOptions={hostOptions} + selectedProjectHostSetupId="setup-local" + {...overrides} + /> + ) + }) + // Settle the mount-time chunk warm before the click, so the click's import() is not + // overlapping an in-flight one (vitest's module runner serialises those; a browser does not). + await act(async () => {}) + return container +} diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 8206354f916..1456f7e30ad 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -104,7 +104,7 @@ vi.mock('@/components/new-workspace/ProjectCombobox', () => ({ value: string | null onValueChange: (value: string) => void }) => ( -
+
{options.map((option) => (
) } diff --git a/src/renderer/src/components/TerminalWorkspaceDialogs.tsx b/src/renderer/src/components/TerminalWorkspaceDialogs.tsx index 52ddbf1ba5d..bb3ffbfd621 100644 --- a/src/renderer/src/components/TerminalWorkspaceDialogs.tsx +++ b/src/renderer/src/components/TerminalWorkspaceDialogs.tsx @@ -24,6 +24,7 @@ export function TerminalWorkspaceDialogs({ saveDialogFile, saveDialogFileId, setWindowCloseDialogOpen, + windowCloseDialogKind, windowCloseDialogOpen } = controller return ( @@ -82,10 +83,15 @@ export function TerminalWorkspaceDialogs({ {translate('auto.components.Terminal.2fa9c69ff3', 'Close Window?')} - {translate( - 'auto.components.Terminal.7958465754', - 'There are local terminals with running processes. Close the window anyway?' - )} + {windowCloseDialogKind === 'unverifiable' + ? translate( + 'auto.components.Terminal.b7c1f0a934', + 'A remote host could not be reached, so Orca cannot tell whether work is still running there. Close the window anyway?' + ) + : translate( + 'auto.components.Terminal.7958465754', + 'There are terminals with running processes. Close the window anyway?' + )} diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index ce2de01fe02..91375ec974b 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -49,12 +49,23 @@ describe('activity thread host routing', () => { const setActiveWorktree = vi.fn() const acknowledgeAgents = vi.fn() const setSelectedPaneKey = vi.fn() + let state: Record + + function makeActions(): ReturnType { + return createActivityThreadActions({ + getMarkAllReadThreads: () => [thread], + acknowledgeAgents, + unacknowledgeAgents: vi.fn(), + setSelectedPaneKey + }) + } beforeEach(() => { vi.clearAllMocks() mocks.activateStructuredAgentSessionTab.mockReturnValue(false) + mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) getKnownWorktreeById.mockReturnValue(thread.worktree) - mocks.getState.mockReturnValue({ + state = { getKnownWorktreeById, worktreesByRepo: { [thread.worktree.repoId]: [thread.worktree] }, detectedWorktreesByRepo: {}, @@ -77,21 +88,19 @@ describe('activity thread host routing', () => { setActiveRepo: vi.fn(), setActiveWorktree, setActiveTabType: vi.fn() - }) + } + mocks.getState.mockImplementation(() => state) }) - it('selects the matching host when the same workspace id is active elsewhere', () => { - const actions = createActivityThreadActions({ - getMarkAllReadThreads: () => [thread], - acknowledgeAgents, - unacknowledgeAgents: vi.fn(), - setSelectedPaneKey + it('routes the row click through the full activation sequence for the matching host', () => { + makeActions().selectThread(thread) + + // Bare setActiveWorktree skips setActiveView('terminal'), initial-terminal seeding and + // sleeping-session resume — the workspace dispatcher is the only path that runs them. + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { + executionHostId: REMOTE_HOST }) - - actions.selectThread(thread) - - expect(getKnownWorktreeById).toHaveBeenCalledWith(thread.worktree.id, REMOTE_HOST) - expect(setActiveWorktree).toHaveBeenCalledWith(thread.worktree.id, REMOTE_HOST) + expect(setActiveWorktree).not.toHaveBeenCalled() expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith( thread.tab.id, '11111111-1111-4111-8111-111111111111', @@ -99,23 +108,56 @@ describe('activity thread host routing', () => { ) }) - it('activates a structured agent session instead of looking for a terminal pane', () => { - mocks.activateStructuredAgentSessionTab.mockReturnValue(true) - mocks.getState.mockReturnValue({ - ...mocks.getState(), - tabsByWorktree: { [thread.worktree.id]: [] }, - unifiedTabsByWorktree: { - [thread.worktree.id]: [{ id: thread.tab.id, contentType: 'agent-session' }] - } - }) - const actions = createActivityThreadActions({ - getMarkAllReadThreads: () => [thread], - acknowledgeAgents, - unacknowledgeAgents: vi.fn(), - setSelectedPaneKey + it('opens a cold-parked remote thread whose tab activation revives', () => { + // The reported SSH symptom: the tab is not resident because the session was never + // revived, so a residency probe before activation made the click a silent no-op. + state.tabsByWorktree = {} + mocks.activateAndRevealWorkspace.mockImplementation(() => { + state.tabsByWorktree = { [thread.worktree.id]: [thread.tab] } + return { primaryTabId: thread.tab.id } }) - actions.selectThread(thread) + makeActions().selectThread(thread) + + expect(setSelectedPaneKey).toHaveBeenCalledWith(thread.paneKey) + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { + executionHostId: REMOTE_HOST + }) + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith( + thread.tab.id, + '11111111-1111-4111-8111-111111111111', + { flashFocusedPane: true, scrollToBottomIfOutputSinceLastView: true } + ) + }) + + it('still activates the workspace when a retained thread has no tab to focus', () => { + state.tabsByWorktree = {} + + makeActions().selectThread(thread) + + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { + executionHostId: REMOTE_HOST + }) + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + }) + + it('focuses nothing when the workspace itself is gone', () => { + mocks.activateAndRevealWorkspace.mockReturnValue(false) + + makeActions().selectThread(thread) + + expect(mocks.activateStructuredAgentSessionTab).not.toHaveBeenCalled() + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + }) + + it('activates a structured agent session instead of looking for a terminal pane', () => { + mocks.activateStructuredAgentSessionTab.mockReturnValue(true) + state.tabsByWorktree = { [thread.worktree.id]: [] } + state.unifiedTabsByWorktree = { + [thread.worktree.id]: [{ id: thread.tab.id, contentType: 'agent-session' }] + } + + makeActions().selectThread(thread) expect(mocks.activateStructuredAgentSessionTab).toHaveBeenCalledWith({ worktreeId: thread.worktree.id, @@ -126,14 +168,8 @@ describe('activity thread host routing', () => { it('jumps to and probes the matching host-qualified workspace', () => { expect(hasActivityThreadWorkspace(thread)).toBe(true) - const actions = createActivityThreadActions({ - getMarkAllReadThreads: () => [thread], - acknowledgeAgents, - unacknowledgeAgents: vi.fn(), - setSelectedPaneKey - }) - actions.jumpToWorkspace(thread) + makeActions().jumpToWorkspace(thread) expect(acknowledgeAgents).toHaveBeenCalledWith([thread.paneKey]) expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith(thread.worktree.id, { diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index b0971da7ea7..f9f77587a1e 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -1,5 +1,6 @@ import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation' import { useAppStore } from '@/store' import { @@ -74,38 +75,31 @@ export function createActivityThreadActions({ } const activateThreadTarget = (thread: AgentPaneThread): void => { - const state = useAppStore.getState() const executionHostId = getActivityThreadExecutionHostId( thread, - getSettingsFocusedExecutionHostId(state.settings) + getSettingsFocusedExecutionHostId(useAppStore.getState().settings) ) - const worktree = state.getKnownWorktreeById(thread.worktree.id, executionHostId) - if (!worktree) { + // Why the full sequence (not bare setActiveWorktree): a cold-parked thread — the normal + // state of an SSH session that was never revived — has no resident tab until + // resumeSleepingAgentSessionsForWorktree/ensureWorktreeHasInitialTerminal run inside here. + // Probing tab residency first is what made a remote row click a silent no-op (#16731). + if (activateAndRevealWorkspace(thread.worktree.id, { executionHostId }) === false) { return } - const liveTabs = state.tabsByWorktree[worktree.id] ?? [] - const hasLiveTerminal = liveTabs.some((tab) => tab.id === thread.tab.id) - const hasLiveAgentSession = (state.unifiedTabsByWorktree?.[worktree.id] ?? []).some( - (tab) => tab.id === thread.tab.id && tab.contentType === 'agent-session' - ) - // Why: retained threads can outlive their target; reorienting the workspace for a - // dead terminal or structured session would just confuse the user. - if (!hasLiveTerminal && !hasLiveAgentSession) { - return - } - if (state.activeRepoId !== worktree.repoId) { - state.setActiveRepo(worktree.repoId) - } if ( - state.activeWorktreeId !== worktree.id || - state.activeWorkspaceExecutionHostId !== executionHostId + activateStructuredAgentSessionTab({ worktreeId: thread.worktree.id, tabId: thread.tab.id }) ) { - state.setActiveWorktree(worktree.id, executionHostId) - } - if (activateStructuredAgentSessionTab({ worktreeId: worktree.id, tabId: thread.tab.id })) { return } - state.setActiveTabType('terminal') + // Read post-activation: the tab this thread points at may have only just been revived. + const activated = useAppStore.getState() + const liveTabs = activated.tabsByWorktree[thread.worktree.id] ?? [] + if (!liveTabs.some((tab) => tab.id === thread.tab.id)) { + // Retained threads outlive their tab; the workspace is still activated, but there is + // no pane to focus and focusing a sibling would be worse than focusing nothing. + return + } + activated.setActiveTabType('terminal') const parsed = parsePaneKey(thread.paneKey) activateTabAndFocusPane( thread.tab.id, diff --git a/src/renderer/src/components/automations/AutomationListTableHeader.test.tsx b/src/renderer/src/components/automations/AutomationListTableHeader.test.tsx new file mode 100644 index 00000000000..5c5bbe8e568 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListTableHeader.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { AutomationListTableHeader } from './AutomationListTableHeader' +import { + LIST_TABLE_HEADER_CLASS, + LIST_TABLE_STICKY_HEADER_CELL_CLASS +} from '@/lib/list-table-layout' + +describe('AutomationListTableHeader', () => { + afterEach(cleanup) + + it('renders all expected columns', () => { + render() + + expect(screen.getByText('Name')).toBeDefined() + expect(screen.getByText('Schedule')).toBeDefined() + expect(screen.getByText('Project')).toBeDefined() + expect(screen.getByText('Host')).toBeDefined() + expect(screen.getByText('Next run')).toBeDefined() + expect(screen.getByText('Last run')).toBeDefined() + expect(screen.getByText('Status')).toBeDefined() + expect(screen.getByText('Agent')).toBeDefined() + expect(screen.getByText('Actions')).toBeDefined() + }) + + it('uses opaque background and sticky positioning on the header row', () => { + const { container } = render() + const header = container.firstElementChild as HTMLElement + + expect(header.className).toContain(LIST_TABLE_HEADER_CLASS) + expect(header.className).toContain('sticky') + expect(header.className).toContain('top-0') + expect(header.className).toContain('bg-[color-mix(in_srgb,var(--muted)_40%,var(--background))]') + expect(header.className).not.toContain('bg-muted/25') + }) + + it('applies sticky cell styling to the first column', () => { + render() + const nameCell = screen.getByText('Name') + + expect(nameCell.className).toBe(LIST_TABLE_STICKY_HEADER_CELL_CLASS) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index 7022d7e5731..770e1974625 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -91,6 +91,32 @@ describe('AgentTerminalDialog', () => { expect(screen.getByTestId('preview')).toHaveAttribute('data-terminal-input', 'null') }) + it('does not claim a remote pane closed when the card carries no live pty', () => { + render( + {}} + onReveal={() => {}} + /> + ) + + // Loss of contact with an SSH host is `unverifiable`, never `exited`. + expect(screen.getByText(/remote session/)).toBeInTheDocument() + expect(screen.queryByText(/pane has closed/)).not.toBeInTheDocument() + }) + + it('still reports a closed pane for a local card with no live pty', () => { + render( + {}} + onReveal={() => {}} + /> + ) + + expect(screen.getByText(/pane has closed/)).toBeInTheDocument() + }) + it('labels acknowledged completions idle without review or pin controls', () => { render( ) : (
- {translate( - 'dashboardPopout.terminal.closed', - "No live terminal — this agent's pane has closed." - )} + {terminalPreviewUnavailableMessage({ hostKind: card.hostKind })}
)}
diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 4c91a22a3b8..c4856a23834 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -612,6 +612,14 @@ describe('AgentTerminalPreview', () => { expect(unsubscribe).toHaveBeenCalledWith('pty-1') }) + it('does not claim a remote pane closed when no snapshot can exist for it', async () => { + connect.mockResolvedValueOnce({ snapshot: null, replay: [] }) + const view = render() + + await waitFor(() => expect(view.getByText(/remote session/)).toBeInTheDocument()) + expect(view.queryByText(/pane has closed/)).not.toBeInTheDocument() + }) + it('connects a replacement pty after the previous pty was gone', async () => { connect.mockResolvedValueOnce({ snapshot: null, replay: [] }).mockResolvedValueOnce({ snapshot: { data: 'replacement', cols: 80, rows: 24, seq: 1 }, diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index f2a702782e9..ec05a7105a5 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -17,7 +17,7 @@ import { installPreviewTerminalCompatibility } from './preview-terminal-compatib import { createPreviewClipboardPaster } from './preview-terminal-paste' import { installPreviewImeBridge, type PreviewImeBridge } from './preview-terminal-ime-bridge' import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' -import { translate } from '@/i18n/i18n' +import { terminalPreviewUnavailableMessage } from './terminal-preview-unavailable-message' import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' @@ -430,10 +430,7 @@ export function AgentTerminalPreview({ > {ptyGone ? (
- {translate( - 'dashboardPopout.terminal.closed', - "No live terminal — this agent's pane has closed." - )} + {terminalPreviewUnavailableMessage({ ptyId })}
) : null}
{ + it('claims the pane closed only for a pty the client could have observed', () => { + expect(terminalPreviewUnavailableMessage({ ptyId: 'pty-1' })).toMatch(/pane has closed/) + expect(terminalPreviewUnavailableMessage({ hostKind: 'local' })).toMatch(/pane has closed/) + }) + + it('reports an unobservable remote preview instead of asserting the pane exited', () => { + // SshPtyProvider provides no authoritative buffer snapshot and the relay has no snapshot + // RPC, so a null snapshot is loss of contact. See docs/reference/ssh-execution-boundary.md. + const fromPtyId = terminalPreviewUnavailableMessage({ ptyId: 'ssh:devbox@@pty-3' }) + expect(fromPtyId).toMatch(/remote session/) + expect(fromPtyId).not.toMatch(/pane has closed/) + expect(terminalPreviewUnavailableMessage({ hostKind: 'ssh' })).toBe(fromPtyId) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts new file mode 100644 index 00000000000..07080084bfc --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/terminal-preview-unavailable-message.ts @@ -0,0 +1,27 @@ +import { translate } from '@/i18n/i18n' +import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' + +/** + * A missing buffer snapshot only proves the pane exited when the client could have + * observed it. `SshPtyProvider` reports no authoritative buffer snapshot and the relay + * exposes no snapshot RPC, so for a remote pty the absence is loss of contact — + * `unverifiable`, never `exited`. See docs/reference/ssh-execution-boundary.md. + */ +export function terminalPreviewUnavailableMessage(source: { + ptyId?: string | null + hostKind?: DashboardCardHostKind +}): string { + const isRemote = + source.hostKind === 'ssh' || + (typeof source.ptyId === 'string' && parseAppSshPtyId(source.ptyId) !== null) + return isRemote + ? translate( + 'dashboardPopout.terminal.remotePreviewUnavailable', + 'No preview for this remote session — open the workspace to view the terminal.' + ) + : translate( + 'dashboardPopout.terminal.closed', + "No live terminal — this agent's pane has closed." + ) +} diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx index 75b76e396b4..97c31c4747a 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx @@ -8,13 +8,18 @@ const mocks = vi.hoisted(() => ({ useLiveDashboardSnapshot: vi.fn(() => ({ generatedAt: 1, cards: [] })), blockingOverlay: false, boardProps: null as Record | null, - activateTabAndFocusPane: vi.fn() + activateTabAndFocusPane: vi.fn(), + activateAndRevealWorkspace: vi.fn(() => ({ primaryTabId: null }) as unknown) })) vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ activateTabAndFocusPane: mocks.activateTabAndFocusPane })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: mocks.activateAndRevealWorkspace +})) + vi.mock('./useLiveDashboardSnapshot', () => ({ useLiveDashboardSnapshot: mocks.useLiveDashboardSnapshot })) @@ -49,6 +54,9 @@ beforeEach(() => { false ) mocks.useLiveDashboardSnapshot.mockClear() + mocks.activateTabAndFocusPane.mockClear() + mocks.activateAndRevealWorkspace.mockClear() + mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) mocks.blockingOverlay = false mocks.boardProps = null ;(window as unknown as { api: unknown }).api = { @@ -95,34 +103,63 @@ describe('AgentDashboardDrawer', () => { expect(mocks.boardProps?.initialView).toBeUndefined() }) - it('reveals a colliding worktree on the card execution host', () => { - const setActiveWorktree = vi.spyOn(useAppStore.getState(), 'setActiveWorktree') + type RevealAgent = (args: { + repoId: string + worktreeId: string + executionHostId?: string + tabId: string + leafId: string | null + }) => void + + function revealFromBoard(executionHostId: string): void { render() act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) const onRevealAgent = mocks.boardProps?.onRevealAgent expect(onRevealAgent).toBeTypeOf('function') - act(() => { - ;( - onRevealAgent as (args: { - repoId: string - worktreeId: string - executionHostId?: string - tabId: string - leafId: string | null - }) => void - )({ + ;(onRevealAgent as RevealAgent)({ repoId: 'repo-1', worktreeId: 'shared-worktree', - executionHostId: 'runtime:env-1', + executionHostId, tabId: 'tab-1', leafId: 'leaf-1' }) }) + } - expect(setActiveWorktree).toHaveBeenCalledWith('shared-worktree', 'runtime:env-1') + it('reveals a colliding worktree on the card execution host', () => { + const setActiveWorktree = vi.spyOn(useAppStore.getState(), 'setActiveWorktree') + + revealFromBoard('runtime:env-1') + + // Bare setActiveWorktree skips the terminal view switch, initial-terminal seeding and + // sleeping-session resume the shared dispatcher runs. + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('shared-worktree', { + executionHostId: 'runtime:env-1' + }) + expect(setActiveWorktree).not.toHaveBeenCalled() expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith('tab-1', 'leaf-1', { flashFocusedPane: true }) }) + + it('activates a parked SSH workspace before reaching for its pane', () => { + revealFromBoard('ssh:devbox') + + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('shared-worktree', { + executionHostId: 'ssh:devbox' + }) + // Ordering is the fix: a parked remote tab only exists after activation revives it. + expect(mocks.activateAndRevealWorkspace.mock.invocationCallOrder[0]).toBeLessThan( + mocks.activateTabAndFocusPane.mock.invocationCallOrder[0] as number + ) + }) + + it('skips pane focus when the revealed workspace is gone', () => { + mocks.activateAndRevealWorkspace.mockReturnValue(false) + + revealFromBoard('ssh:devbox') + + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx index a5df13c2395..347255324fd 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet' -import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { revealDashboardAgent } from './reveal-dashboard-agent' import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard' import type { AgentRevealArgs } from '../dashboard-popout/AgentTerminalDialog' import { @@ -47,8 +47,7 @@ function AgentDashboardDrawerBody({ }, []) const handleRevealAgent = useCallback( (args: AgentRevealArgs) => { - useAppStore.getState().setActiveWorktree(args.worktreeId, args.executionHostId) - activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) + revealDashboardAgent(args) onClose() }, [onClose] diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index bae9892736d..698f759d7ab 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -10,6 +10,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' import { selectRuntimeAgentOrchestrationBatch } from '../sidebar/worktree-agent-orchestration-batch' +import { selectRuntimeAgentOrchestrationForWorktree } from '../sidebar/worktree-agent-row-selectors' import type * as DashboardSnapshotWorkspacesModule from './dashboard-snapshot-workspaces' import type * as AgentRowLineageModule from './agent-row-lineage' @@ -753,7 +754,10 @@ describe('buildDashboardSnapshot', () => { expect(snapshot.cards[0].task).toBe('Batched orchestration task') }) - it('releases stale batch references when production moves from multi to singleton to zero', () => { + // Why identity, not release: the batch is a view of the shared orchestration index, which + // mounted sidebar cards read through. A dashboard that drops below two worktrees must not + // invalidate it, and nothing the index reads changed across these transitions. + it('keeps batch records live and correct when production moves from multi to singleton to zero', () => { const secondLeafId = '77777777-7777-4777-8777-777777777777' const firstPaneKey = makePaneKey('tab-w1', LEAF_ID) const secondPaneKey = makePaneKey('tab-w2', secondLeafId) @@ -788,13 +792,17 @@ describe('buildDashboardSnapshot', () => { NOW ) const afterSingleton = selectRuntimeAgentOrchestrationBatch(multiState, requested) - expect(afterSingleton).not.toBe(firstBatch) - expect(afterSingleton.get('w1')).not.toBe(firstW1) + expect(afterSingleton).toBe(firstBatch) + expect(afterSingleton.get('w1')).toBe(firstW1) buildDashboardSnapshot(baseState({ repos: [], worktreesByRepo: {} }), NOW) const afterZero = selectRuntimeAgentOrchestrationBatch(multiState, requested) - expect(afterZero).not.toBe(afterSingleton) - expect(afterZero.get('w1')).not.toBe(afterSingleton.get('w1')) + expect(afterZero).toBe(firstBatch) + for (const worktreeId of requested) { + expect(afterZero.get(worktreeId)).toBe( + selectRuntimeAgentOrchestrationForWorktree(multiState, worktreeId) + ) + } }) it('scans orchestration runtime once for a dashboard snapshot', () => { diff --git a/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts b/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts new file mode 100644 index 00000000000..9da364c72ef --- /dev/null +++ b/src/renderer/src/components/dashboard/reveal-dashboard-agent.ts @@ -0,0 +1,23 @@ +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import type { DashboardRevealAgentArgs } from '../../../../shared/dashboard-snapshot' + +/** + * Click-to-focus from either Agent Dashboard surface (pop-out relay or in-window drawer). + * + * Why the workspace dispatcher rather than a bare `setActiveWorktree`: only the shared + * sequence switches the view back to terminal, resumes sleeping agent sessions, and seeds a + * terminal surface. A parked SSH workspace has no resident tab until those run, so the bare + * call revealed a workspace with nothing in it (#16731). + */ +export function revealDashboardAgent(args: DashboardRevealAgentArgs): boolean { + const activated = activateAndRevealWorkspace( + args.worktreeId, + args.executionHostId ? { executionHostId: args.executionHostId } : undefined + ) + if (activated === false) { + return false + } + activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) + return true +} diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index aa427ed55c5..e80bf56d778 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -21,7 +21,9 @@ const mocks = vi.hoisted(() => ({ offRevealAgent: vi.fn(), offAckAgent: vi.fn(), offPopoutOpenChanged: vi.fn(), - offSnapshotRequested: vi.fn() + offSnapshotRequested: vi.fn(), + activateTabAndFocusPane: vi.fn(), + activateAndRevealWorkspace: vi.fn() })) vi.mock('@/store', () => ({ @@ -35,7 +37,11 @@ vi.mock('@/store', () => ({ })) vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ - activateTabAndFocusPane: vi.fn() + activateTabAndFocusPane: mocks.activateTabAndFocusPane +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: mocks.activateAndRevealWorkspace })) vi.mock('./build-dashboard-snapshot', () => ({ @@ -159,7 +165,8 @@ describe('useDashboardPopoutBridge', () => { expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) }) - it('reveals the agent on its exact execution host', async () => { + it('reveals the agent on its exact execution host through the full activation', async () => { + mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) await act(async () => root.render()) await act(async () => @@ -172,7 +179,53 @@ describe('useDashboardPopoutBridge', () => { }) ) - expect(mocks.setActiveWorktree).toHaveBeenCalledWith('shared-worktree', 'runtime:env-1') + // Bare setActiveWorktree skips the terminal view switch, initial-terminal seeding and + // sleeping-session resume, so a parked pane is never revived (#16731). + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('shared-worktree', { + executionHostId: 'runtime:env-1' + }) + expect(mocks.setActiveWorktree).not.toHaveBeenCalled() + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith('tab-1', 'leaf-1', { + flashFocusedPane: true + }) + }) + + it('activates a parked SSH workspace before reaching for its pane', async () => { + mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: 'tab-1' }) + await act(async () => root.render()) + + await act(async () => + mocks.onRevealAgent.mock.calls[0][0]({ + repoId: 'repo-1', + worktreeId: 'remote-worktree', + executionHostId: 'ssh:devbox', + tabId: 'tab-1', + leafId: 'leaf-1' + }) + ) + + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('remote-worktree', { + executionHostId: 'ssh:devbox' + }) + expect(mocks.activateAndRevealWorkspace.mock.invocationCallOrder[0]).toBeLessThan( + mocks.activateTabAndFocusPane.mock.invocationCallOrder[0] as number + ) + }) + + it('skips pane focus when the revealed workspace is gone', async () => { + mocks.activateAndRevealWorkspace.mockReturnValue(false) + await act(async () => root.render()) + + await act(async () => + mocks.onRevealAgent.mock.calls[0][0]({ + repoId: 'repo-1', + worktreeId: 'deleted-worktree', + tabId: 'tab-1', + leafId: 'leaf-1' + }) + ) + + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() }) it('ignores unrelated store writes while retaining every snapshot input', () => { diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index e6163a70773..f80de74a748 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { useAppStore, type AppState } from '@/store' -import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { revealDashboardAgent } from './reveal-dashboard-agent' import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' import type { RepoIcon } from '../../../../shared/repo-icon' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' @@ -133,8 +133,7 @@ export function useDashboardPopoutBridge(enabled: boolean): void { return } return window.api.dashboard.onRevealAgent((args) => { - useAppStore.getState().setActiveWorktree(args.worktreeId, args.executionHostId) - activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) + revealDashboardAgent(args) }) }, [enabled]) diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index bc170a98a2b..dc152df4c70 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -130,6 +130,7 @@ export function NativeChatResolvedView({ }) const contextMenu = useNativeChatContextMenu({ rootRef, + onSwitchToTerminal, actions: { onPaste: pasteClipboardIntoComposer, ...(contextMenuActions ?? emptyNativeChatContextMenuActions) diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index a08bc476d2f..409f8c7329f 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { canToggleNativeChat } from './native-chat-availability' +import { canSwitchNativeChatView, canToggleNativeChat } from './native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' describe('canToggleNativeChat', () => { @@ -225,3 +225,37 @@ describe('canToggleNativeChat', () => { ).toBe(false) }) }) + +describe('canSwitchNativeChatView', () => { + it('allows bridge chat to expose a terminal/chat switcher', () => { + expect( + canSwitchNativeChatView({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'claude' + }) + ).toBe(true) + }) + + it('keeps structured sessions free of terminal/chat switchers', () => { + expect( + canSwitchNativeChatView({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'codex', + structuredSessionId: 'thread-1' + }) + ).toBe(false) + }) + + it('keeps structured sessions hidden even when toggling back', () => { + expect( + canSwitchNativeChatView({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + isChatViewMode: true, + structuredSessionId: 'thread-1' + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 3cad7fc157f..1f883630f13 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -58,3 +58,16 @@ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean } return isNativeChatSupportedAgent(agent) } + +/** Whether a user-facing terminal⇄chat switcher may be offered. A structured + * session IS the conversation — it owns the surface with no live TUI beneath + * it — so only terminal-backed (bridge) chat, which renders a terminal we can + * return to, gets the switch. */ +export function canSwitchNativeChatView( + input: NativeChatAvailabilityInput & { structuredSessionId?: string | null } +): boolean { + if (input.structuredSessionId) { + return false + } + return canToggleNativeChat(input) +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts index aebc51c90e0..15c92d2efb7 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -1,36 +1 @@ -import type { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../../shared/agent-session-journal-types' -import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { - reconcileStructuredAgentSessionOutbox, - type StructuredAgentSessionOutboxEntry -} from '../../../../shared/structured-agent-session-outbox' -import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' - -export function projectStructuredAgentSessionMessages( - items: readonly AgentJournalRenderItem[], - outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] -): NativeChatMessage[] { - const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions) - // Why: the host renders its own bubble off the submission WAL row, which lands - // while the dispatch is still `pending`. Reconciliation only retires the echo on - // `accepted`, so keying visibility on that alone double-rendered the bubble for - // the whole provider round trip. The entry itself stays for retry/unconfirmed. - const journalled = new Set(items.map((item) => item.itemId)) - return [ - ...projectStructuredItemsToNativeChat(items), - ...optimistic - .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) - .map((entry): NativeChatMessage => ({ - id: agentJournalSubmissionKey(entry.clientMessageId), - role: 'user', - source: 'transcript', - timestamp: entry.queuedAt, - blocks: entry.body.blocks - })) - ] -} +export { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx new file mode 100644 index 00000000000..f4e8b3efc72 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx @@ -0,0 +1,110 @@ +/** + * @vitest-environment happy-dom + */ +import React, { createRef, type ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + emptyNativeChatContextMenuActions, + useNativeChatContextMenu, + type NativeChatContextMenuActions +} from './use-native-chat-context-menu' + +type ItemProps = { onSelect?: () => void; children?: ReactNode } + +const items = vi.hoisted(() => ({ list: [] as ItemProps[] })) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children?: ReactNode }) => children, + DropdownMenuContent: ({ children }: { children?: ReactNode }) => children, + DropdownMenuItem: (props: ItemProps) => { + items.list.push(props) + return props.children + }, + DropdownMenuLabel: ({ children }: { children?: ReactNode }) => children, + DropdownMenuSeparator: () => null, + DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => children, + DropdownMenuSub: ({ children }: { children?: ReactNode }) => children, + DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => children, + DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => children, + DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => children +})) + +vi.mock('lucide-react', () => { + const Icon = () => null + return { + Clipboard: Icon, + Copy: Icon, + GitFork: Icon, + Maximize2: Icon, + MessageSquarePlus: Icon, + Minimize2: Icon, + PanelBottomClose: Icon, + PanelsTopLeft: Icon, + PanelRightClose: Icon, + Pencil: Icon, + SquareTerminal: Icon, + X: Icon + } +}) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +function childrenText(children: ReactNode): string { + return React.Children.toArray(children) + .map((child) => { + if (typeof child === 'string') { + return child + } + return React.isValidElement<{ children?: ReactNode }>(child) + ? childrenText(child.props.children) + : '' + }) + .join('') +} + +function Harness({ onSwitchToTerminal }: { onSwitchToTerminal?: () => void }) { + const rootRef = createRef() + const { menu } = useNativeChatContextMenu({ + rootRef, + onSwitchToTerminal, + actions: { + ...emptyNativeChatContextMenuActions, + onPaste: vi.fn() + } satisfies NativeChatContextMenuActions + }) + return menu +} + +describe('useNativeChatContextMenu', () => { + beforeEach(() => { + items.list = [] + }) + + it('restores the bridge switch-to-terminal action when supplied', () => { + const onSwitchToTerminal = vi.fn() + + renderToStaticMarkup() + + // Keep the assertions tied to the mocked menu item's semantic children. + const labels = items.list.map((candidate) => childrenText(candidate.children)) + + expect(labels.some((label) => label.startsWith('Switch to terminal view'))).toBe(true) + const item = items.list.find((candidate) => + childrenText(candidate.children).startsWith('Switch to terminal view') + ) + expect(item).toBeDefined() + item?.onSelect?.() + expect(onSwitchToTerminal).toHaveBeenCalledTimes(1) + }) + + it('does not render a terminal switch action without a bridge callback', () => { + renderToStaticMarkup() + + expect( + items.list.some((candidate) => childrenText(candidate.children) === 'Switch to terminal view') + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 8e939401a47..a2d7dae4c93 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -17,6 +17,7 @@ import { PanelsTopLeft, PanelRightClose, Pencil, + SquareTerminal, X } from 'lucide-react' import { @@ -28,7 +29,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' -import { isMacPlatform } from './native-chat-shortcut' +import { isMacPlatform, nativeChatToggleShortcutLabel } from './native-chat-shortcut' type NativeChatContextMenuState = { open: boolean @@ -38,6 +39,8 @@ type NativeChatContextMenuState = { type UseNativeChatContextMenuArgs = { rootRef: RefObject + /** Bridge-only escape hatch; structured sessions never mount this menu. */ + onSwitchToTerminal?: () => void actions: NativeChatContextMenuActions } @@ -83,7 +86,11 @@ export const emptyNativeChatContextMenuActions: Omit {} } -export function useNativeChatContextMenu({ rootRef, actions }: UseNativeChatContextMenuArgs): { +export function useNativeChatContextMenu({ + rootRef, + onSwitchToTerminal, + actions +}: UseNativeChatContextMenuArgs): { onContextMenuCapture: MouseEventHandler onSelectionCapture: () => void menu: React.JSX.Element @@ -95,6 +102,7 @@ export function useNativeChatContextMenu({ rootRef, actions }: UseNativeChatCont point: { x: 0, y: 0 }, selectedText: '' }) + const shortcutLabel = nativeChatToggleShortcutLabel(isMacPlatform()) const rememberCurrentSelection = useCallback(() => { const selectedText = getNativeChatSelectedText(rootRef.current) @@ -161,6 +169,16 @@ export function useNativeChatContextMenu({ rootRef, actions }: UseNativeChatCont {translate('auto.components.terminal.pane.TerminalContextMenu.0a917b591a', 'Paste')} + {onSwitchToTerminal ? ( + + + {translate( + 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', + 'Switch to terminal view' + )} + {shortcutLabel} + + ) : null} {actions.canContinueAgentSessionInNewSession ? ( diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index 90c84470cee..272e0ba81eb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' -import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import { resolveNativeChatTabAgentEvidence } from '../tab-bar/native-chat-tab-agent-evidence' import { canToggleNativeChat } from './native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' @@ -58,7 +58,10 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( (candidate) => candidate.id === group.activeTabId ) - if (!tab || tab.contentType !== 'terminal') { + // contentType gates out standalone structured (agent-session) tabs; + // structuredSessionId gates out a terminal tab that adopted one, which + // renders the structured surface with no TUI to switch back to. + if (!tab || tab.contentType !== 'terminal' || tab.structuredSessionId) { return } const terminalTab = (state.tabsByWorktree[worktreeId] ?? []).find( @@ -75,10 +78,10 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive terminalLayout, agentStatusByPaneKey: state.agentStatusByPaneKey }) - const titleFallbackAgent = tabWideFallbackSafe - ? (resolveCommittedTitleAgentType(tab.label ?? '') ?? - (terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null)) - : null + const titleFallbackAgent = + tabWideFallbackSafe && terminalTab + ? resolveNativeChatTabAgentEvidence(terminalTab, tab) + : null if ( !canToggleNativeChat({ experimentalNativeChatEnabled: state.settings?.experimentalNativeChat === true, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts index b7288d4a2a3..2c91621d30a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts @@ -10,16 +10,10 @@ // would otherwise release a hold that has not landed yet, and the late hold would never be undone. import { useEffect, useRef } from 'react' +import { structuredAgentSessionHolderId } from '../../../../shared/structured-agent-session-holder' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -let holderOrdinal = 0 - -export function structuredAgentSessionHolderId(surface: string): string { - holderOrdinal += 1 - return `${surface}:${holderOrdinal}` -} - export function useStructuredAgentSessionHold(args: { sessionId: string target: RuntimeClientTarget diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx index 4eae12e965a..1928b205ff6 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx @@ -80,62 +80,66 @@ export function NewWorkspaceComposerProjectSection({ selectedProjectName }: NewWorkspaceComposerProjectSectionProps): React.JSX.Element { return ( -
-
- - {showAddProjectButton ? ( - - - + + + {translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')} + + + ) : null} +
+
+ + {projectError ? ( +

+ {projectError} +

+ ) : projectOptions.length === 0 ? ( +

+ {emptyProjectMessage ?? + translate( + 'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace', + 'Add a project before creating a workspace.' )} - > - - - - - {translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')} - - - ) : null} +

+ ) : null} +
- - {projectError ? ( -

- {projectError} -

- ) : projectOptions.length === 0 ? ( -

- {emptyProjectMessage ?? - translate( - 'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace', - 'Add a project before creating a workspace.' - )} -

- ) : null} {shouldShowRunTargetPicker ? (