Merge remote-tracking branch 'origin/main' into mobile-rearch

Resolves #16239's shared-client terminal identity against the hybrid split: the
hosted page has no native client, so identity readiness is a flag
(hostClientIdentityReady) rather than a non-null clientId, and the bridge
terminal operations keep their workspaceId/terminalId/clientId contract.
Adds getClientId to the disabled hosted client context and keeps the
mobile-web extra resource alongside main's new emoji shortcode dataset.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-04 01:32:48 -04:00
286 changed files with 33836 additions and 1724 deletions
+2 -1
View File
@@ -382,7 +382,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
@@ -818,6 +818,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
+1
View File
@@ -163,6 +163,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
+7
View File
@@ -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==$''']
@@ -123,15 +123,24 @@ describe('incident monitor evaluator', () => {
})
})
// Why: sweeps no longer reach the retry wrapper, so any exhaustion left in this
// counter is a request path that terminally failed. It must still freeze.
it('freezes on a single exhausted request-path transaction', () => {
const sample = healthySample()
sample.sources['relay-logs']!.signals['relay.postgres_retry_exhausted'] = signal(1)
expect(evaluateIncidentSample(sample, startedAt)).toMatchObject({
// 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: 0 })
expect.objectContaining({ signal: 'relay.postgres_retry_exhausted', threshold: 300 })
]
})
})
+18 -19
View File
@@ -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,
@@ -35,24 +35,23 @@ export const INCIDENT_MONITOR_THRESHOLDS = {
// 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.
// margin; relayPostgresRetryExhausted below bounds the terminally failed share.
relayPostgresRetries: 300,
// Why: this bar stays at zero. Cell-inventory contention reaches the retry
// wrapper from exactly two kinds of caller, and neither is a sweep tick that
// can shrug the failure off:
// - request paths, which take a wait bounded at CELL_INVENTORY_LOCK_TIMEOUT_MS
// (assignment, control activation, activity, admin drain/evacuate/supersede);
// - sweep-reachable code that a request also enters, which keeps the pool
// lock_timeout so it cannot fail faster than before this change: the
// completeEvacuation site that waits, reconcileReservationAccounting, and
// placement re-entered from evacuateDeadCells.
// Sweep-only sites take the inventory NOWAIT, so their contention becomes
// database_lock_unavailable, which is not a retryable abort and never reaches
// this counter. The relay's cell-inventory-lock census test holds that split.
// Splitting the metric by the phase label PR #423 put on the log payload would
// need a labelled log-based metric, which this signal's counter does not carry.
relayPostgresRetryExhausted: 0,
// 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; relayPostgresRetries above stays the ~10x discriminator.
// 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
+2 -2
View File
@@ -337,8 +337,8 @@ export type CellInventoryLockMode =
// 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, and
// one terminal failure freezes the incident gate.
// 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 +
@@ -170,8 +170,8 @@ describe('cell inventory lock call-site census', () => {
})
// Why: this is the whole point of the classification. A shorter wait on a
// sweep-reachable site turns contention into a terminal transaction failure,
// and relayPostgresRetryExhausted freezes the incident gate at zero.
// 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.
@@ -300,8 +300,8 @@ describe('bounded cell-inventory lock wait', () => {
})
})
// Why: the incident monitor freezes at zero exhausted transactions. A sweep that
// steps aside must not spend the retry budget or report a terminal failure.
// 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()
@@ -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<unknown>>()
.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<unknown>>().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)']
@@ -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<RelayDatabase> =>
await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' })
const results = await Promise.allSettled(
Array.from({ length: 5 }, async (): Promise<RelayDatabase> =>
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)
})
@@ -22,15 +22,37 @@ function wait(delayMs: number): Promise<void> {
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)
)
}
+13 -3
View File
@@ -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
@@ -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<number> {
const server = createNetServer()
await new Promise<void>((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<void>((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> | 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<void>((resolve) => relay.server.once('listening', resolve))
cleanup.push(() => new Promise<void>((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/)
})
})
+15 -5
View File
@@ -100,7 +100,7 @@ durably marked consumed before mutation and cannot authorize another run.
| Relay pool waiters | over 800 |
| Relay pool wait | over 2,500 ms |
| PostgreSQL retries in five minutes | over 300 |
| Exhausted PostgreSQL retries | over 0 |
| 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,25 @@ 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.
- 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.
+9 -1
View File
@@ -95,11 +95,19 @@ const mobileWebExtraResource = {
// 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.
// 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,
mobileWebExtraResource,
skillFreshnessResources
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.
@@ -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",
File diff suppressed because one or more lines are too long
@@ -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) {
+27
View File
@@ -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",
@@ -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-<arch>` 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`)
@@ -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'
}
}
@@ -1,7 +1,8 @@
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'
import {
createMobileWebResourceFixture,
@@ -9,10 +10,13 @@ import {
} from './electron-builder-mobile-web-fixture.mjs'
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,
@@ -299,3 +303,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 })
}
})
})
+35 -4
View File
@@ -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('|')
@@ -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-<arch>`, 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)
}
+1
View File
@@ -249,6 +249,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',
+1
View File
@@ -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",
+2
View File
@@ -66,6 +66,8 @@ 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 "<id>" 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.
+5 -5
View File
@@ -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
@@ -18,6 +18,7 @@ const value: RpcClientContextValue = {
disconnectHostClient: noop,
getState: () => 'disconnected',
getKnownState: () => null,
getClientId: () => null,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
getActivePath: () => 'lan',
@@ -29,7 +30,7 @@ const value: RpcClientContextValue = {
primeHosts: noop
}
const Ctx = createContext<RpcClientContextValue | null>(null)
const disconnectedClient = { client: null, state: 'disconnected' } as const
const disconnectedClient = { client: null, clientId: null, state: 'disconnected' } as const
export function RpcClientProvider({ children }: { children: ReactNode }) {
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
@@ -45,6 +46,7 @@ export function useRpcClientContext(): RpcClientContextValue {
export function useHostClient(_hostId: string | undefined): {
client: RpcClient | null
clientId: string | null
state: ConnectionState
} {
useRpcClientContext()
@@ -67,11 +67,11 @@ const HOST_COMPONENT_NAMES = new Set([
])
const HEAD_MAIN_HOOK_SHA256 = '4fae0d13d86c343b380969797051a376a101176cfaed2f945b4fb13e86b7fc25'
const HEAD_HOOK_BINDING_SHA256 = 'ccd2f55ded8d6f0a4bfed440d57302cc7bc6ea6d984ed0bba7b800b78fc48171'
const HEAD_HOOK_BINDING_SHA256 = '5d2763bb9f8fd10b67e7018c3c13f4fa0c34e87850081a4ddd4a5fd88a5894ff'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'452c799e8bd87db34cb3176ee6640c6aa44836cd4e54abcaaf49a4c2f264b484'
const HEAD_CALLBACK_BODY_SHA256 = '383158ad94f45f982b1f175f8010ffea88c18f441ae5183b95a9169db8a52ed9'
const HEAD_EFFECT_SHA256 = '163e0de8969d8693c968e5c8f9b1ca366a2949c27f9f97b06882f1d7a87a4625'
'f10dfd8a728821c13560ffff098b7b09152da0c89e62b668c79ddfbb606f1120'
const HEAD_CALLBACK_BODY_SHA256 = 'f0a5822ab05101fadb9a8ef3a5e52fa59bf23ffbabcdd01c29a95c4a917d56e4'
const HEAD_EFFECT_SHA256 = 'f81ef4b4794875643dd429e9dfb6cffab037feb68a334e260c0045a258c07d51'
const HEAD_CONTENT_HOOK_SHA256 = 'd74431115b27c22dd38c29a510604554ca767cdd2585beaa73ec2e2dae0c5de4'
const HEAD_NESTED_FUNCTION_SHA256 =
'74ab705236b5e5a1dec23ceca0c0b7fb1ff80c6150802d744e72ea5d0f86e48e'
@@ -83,13 +83,13 @@ const HEAD_TIMER_CREATION_SHA256 =
'688342d48a1b4a46cdffbf0d8953bac245fb6d3c4fe1b5698a1ea6e1e1929bed'
const HEAD_TIMER_CLEANUP_SHA256 = '8a45ae3c8a01a639a40ffaf3c0fc89a2e0b610623306818c86bad4ef9195b824'
const HEAD_RUNTIME_STRING_SHA256 =
'70c0b2084ed16c423248e698dc1806dbca88b9aec9c043653409e7cc63191ff2'
'77fce1bf3cd5c150255a191a107569b11d334da95d3cb47459189965f57f901b'
const HEAD_HOST_JSX_SHA256 = '2911efcb57dbc9f6de1f2a7b3ed6ca4fa8a9735d48649fe062cb400df756e1ce'
const HEAD_LEAF_JSX_SHA256 = '7551bacf163f59c150cc8a9150c443df9804a882365f459053d3ab73ac557f42'
const HEAD_STYLE_REFERENCE_SHA256 =
'3e4f57e5c8691d443187ffe306eae28506d5505276ea3de7a4f2f1df1cfa3885'
const HEAD_IDENTITY_FIELD_SHA256 =
'2e5c63f41bf88bf07985d834319656306f0688469d212d586e29ec2fd77103ac'
'99e107d872923359c754013141583941e84075f9823269a7f1f841204748f69c'
const HEAD_NAVIGATION_SHA256 = '12aba3574cb12b65e545f19e641e4ee07f90fa9d7ed98d24359a359d2c764aa4'
const HEAD_CAPABILITY_SHA256 = '9eee249038b387931e648d3422d4c39c3a686aa07b90193098fe9ee9f747cee8'
@@ -511,7 +511,7 @@ describe('mobile session route extraction parity', () => {
)
expect(hash(native.cleanups)).toBe(HEAD_TIMER_CLEANUP_SHA256)
const compatibility = readCompatibilityFacts(definitions)
expect(compatibility.identityFields).toHaveLength(5)
expect(compatibility.identityFields).toHaveLength(4)
expect(hash(compatibility.identityFields)).toBe(HEAD_IDENTITY_FIELD_SHA256)
expect(compatibility.navigation).toHaveLength(5)
expect(hash(compatibility.navigation)).toBe(HEAD_NAVIGATION_SHA256)
@@ -521,7 +521,7 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(474)
expect(strings).toHaveLength(475)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(126)
@@ -17,6 +17,14 @@ const terminalStreamPresentationSource = 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): string {
const start = source.indexOf(startPattern)
@@ -71,6 +79,27 @@ describe('mobile session startup', () => {
expect(reconciliationHookSource).toContain('appStateSubscription.remove()')
})
it('binds terminal identity to the shared client before subscription effects run', () => {
// Why: the hosted page has no native client, so identity readiness is a flag rather than a non-null id.
expect(foundationSource).toContain(
'const clientId = nativeHostBinding ? nativeHost.clientId : null'
)
expect(foundationSource).toContain(
'const hostClientIdentityReady = !nativeHostBinding || clientId !== null'
)
expect(foundationSource).toContain(' clientId,')
expect(terminalRuntimeSource).toContain('useRef<string | null>(clientId)')
expect(terminalRuntimeSource).toContain('deviceTokenRef.current = clientId')
expect(terminalRuntimeSource).toContain(
'inputGate.canSend && sessionTerminalOperations != null && hostClientIdentityReady'
)
expect(terminalSubscriptionSourceForIdentity).toContain('if (!hostClientIdentityReady)')
expect(terminalSubscriptionSourceForIdentity).toContain(
'terminalId: handle,\n clientId,'
)
expect(lifecycleSource).not.toContain('deviceTokenRef.current = host.deviceToken')
})
it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => {
expect(terminalStreamPresentationSource).toContain(
"if (data.type === 'end' || data.type === 'error')"
@@ -113,7 +142,7 @@ describe('mobile session startup', () => {
it('fails runtime capability gates closed while probing a replacement client', () => {
const capabilityEffect = sliceBetween(
'const [runtimeCapabilitySnapshot, setRuntimeCapabilitySnapshot]',
'// Why: read deviceToken from host record'
'// Why: the shared client owns authenticated identity'
)
const probeStart = capabilityEffect.indexOf('startRuntimeCapabilityRead(')
@@ -101,6 +101,10 @@ export function useMobileSessionFoundation({
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
const nativeHost = useHostClient(nativeHostBinding ? hostId : undefined)
const client = nativeHost.client
// Why: the shared client owns authenticated identity (#16239); the hosted page has no native
// client, the shell signs its bridge traffic, so identity is ready there by construction.
const clientId = nativeHostBinding ? nativeHost.clientId : null
const hostClientIdentityReady = !nativeHostBinding || clientId !== null
const connState = connectionStateProp ?? nativeHost.state
const sessionTabOperations = useMemo(
() => sessionTabOperationsProp ?? (client ? defaultHostSessionTabOperations(client) : null),
@@ -248,6 +252,8 @@ export function useMobileSessionFoundation({
router,
insets,
client,
clientId,
hostClientIdentityReady,
connState,
reconnectAttempts,
lastConnectedAt,
@@ -14,7 +14,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
connState,
setCustomKeys,
setVisibleBuiltInIds,
deviceTokenRef,
setHostEndpoint,
connStateRef,
terminalRefs,
@@ -25,7 +24,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
subscribeToTerminal,
sessionDeviceOperations
} = 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
@@ -36,7 +35,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
return
}
if (host) {
deviceTokenRef.current = host.deviceToken
setHostEndpoint(host.endpoint)
}
})
@@ -28,6 +28,8 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
worktreeId,
connState,
client,
clientId,
hostClientIdentityReady,
sessionTabs,
setLiveInputCapture,
liveInputTerminalHandles,
@@ -44,7 +46,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
const terminalCwdRef = useRef<Map<string, string>>(new Map())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(null)
const deviceTokenRef = useRef<string | null>(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<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
@@ -131,12 +135,13 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
useEffect(() => {
sessionTerminalOperationsRef.current = sessionTerminalOperations
}, [sessionTerminalOperations])
const { canCompose, canSend: inputGateCanSend } = resolveMobileTerminalInputGate({
const inputGate = resolveMobileTerminalInputGate({
connState,
activeHandle,
activeSessionTabType: activeSessionTab?.type
})
const canSend = inputGateCanSend && sessionTerminalOperations != null
const canCompose = inputGate.canCompose
const canSend = inputGate.canSend && sessionTerminalOperations != null && hostClientIdentityReady
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({
activeHandleRef,
@@ -7,9 +7,10 @@ export function useMobileSessionTerminalSubscription(
scope: MobileSessionTerminalSubscriptionFoundationModel
) {
const {
clientId,
hostClientIdentityReady,
setTerminalModes,
terminalCwdRef,
deviceTokenRef,
viewportRef,
viewportMeasuredRef,
terminalUnsubsRef,
@@ -41,6 +42,10 @@ export function useMobileSessionTerminalSubscription(
logSkippedGate('no-terminal-operations')
return
}
if (!hostClientIdentityReady) {
logSkippedGate('no-client-identity')
return
}
if (terminalUnsubsRef.current.has(handle)) {
logSkippedGate('already-subscribed')
return
@@ -81,7 +86,7 @@ export function useMobileSessionTerminalSubscription(
{
workspaceId: worktreeId,
terminalId: handle,
clientId: deviceTokenRef.current,
clientId,
viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport(
covered,
viewportRef.current
@@ -135,6 +140,8 @@ export function useMobileSessionTerminalSubscription(
subscribingHandlesRef.current.delete(handle)
},
[
clientId,
hostClientIdentityReady,
getTerminalRef,
deliverPendingQuickCommandInput,
markNativeChatInputLeaseReady,
+7 -3
View File
@@ -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 {
+9 -97
View File
@@ -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<RpcClientContextValue | null>(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<ConnectionState>(() =>
hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected'
)
const clientRef = useRef<RpcClient | null>(null)
const clientHostIdRef = useRef<string | undefined>(hostId)
const acquisitionRef = useRef<HostClientAcquisition>({})
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<void> {
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
}
@@ -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 =>
+92
View File
@@ -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<ConnectionState>(() =>
hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected'
)
const clientRef = useRef<RpcClient | null>(null)
const clientHostIdRef = useRef<string | undefined>(hostId)
const acquisitionRef = useRef<HostClientAcquisition>({})
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<void> {
return useRpcClientContext().forceReconnect
}
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
return useRpcClientContext().primeHosts
}
@@ -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,
@@ -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
+3 -2
View File
@@ -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
@@ -322,7 +323,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))
@@ -9766,7 +9767,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)
+1
View File
@@ -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
+43
View File
@@ -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'
})
})
})
+35
View File
@@ -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)
}
@@ -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]
+19 -12
View File
@@ -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/')
@@ -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<void> {
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
)
})
+5 -3
View File
@@ -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'
@@ -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)
}
+1
View File
@@ -25,6 +25,7 @@ const LAZY_LOCALE_LOADERS: Record<
() => Promise<{ default: Record<string, unknown> }>
> = {
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')
@@ -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
)
})
})
@@ -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
}
@@ -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<typeof WorktreeIdModule>()
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()
})
})
})
+13 -7
View File
@@ -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<string>
): boolean {
): Set<string> {
const worktreeMeta =
typeof store.getAllWorktreeMeta === 'function' ? store.getAllWorktreeMeta() : {}
const repoIds = new Set<string>()
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<string> | 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
))
)
}
@@ -324,6 +324,7 @@ describe('registerPtyHandlers', () => {
}
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
markSshRemotePtyLease: vi.fn(),
@@ -345,6 +345,7 @@ describe('registerPtyHandlers', () => {
)
const store = {
upsertSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn(),
persistPtyBinding: vi.fn()
}
registerSshPtyProvider('ssh-1', {
+15 -8
View File
@@ -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
})
})
})
@@ -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(),
@@ -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 }
)
}
)
@@ -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(),
@@ -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')
}),
@@ -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()
@@ -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)
@@ -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<typeof createStore>,
args: { relayPtyId: string; leafId: string }
): Promise<void> {
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<typeof createStore>): 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:<target>` 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'])
})
})
@@ -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)
}
}
+14 -3
View File
@@ -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 (
@@ -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<typeof vi.fn>
}> {
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)
})
})
+30 -1
View File
@@ -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<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
+9 -18
View File
@@ -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()
}
+95 -3
View File
@@ -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<typeof actual.resolveWorktreeExecutionHost>
) => {
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<Store['getRepo']>()
const getWorkspaceSessionMock = vi.fn<Store['getWorkspaceSession']>()
// 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(
+46 -8
View File
@@ -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<typeof createRepoRowExecutionHostLookup<Repo>>,
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<typeof createRepoRowExecutionHostLookup<Repo>>
): (worktreeId: string, executionHostId?: string) => string | null {
const resolved = new Map<string, string | null>()
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))
+2
View File
@@ -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(),
@@ -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<void> => {
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)
}
@@ -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<typeof NodeFsPromises>()
@@ -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<void>((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<void> {
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)
})
})
+7 -1
View File
@@ -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<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'> & {
/** 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
@@ -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<Record<string, unknown>>()),
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<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
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<string, unknown>) {
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<typeof makeRepo>): 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()
})
})
@@ -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', {
@@ -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<typeof SshWorktreeFallbackModule>()
return {
...actual,
// Both builders are counted: the point is that NO index is built on the connected path.
createSshWorktreeMetaIndex: (...args: Parameters<typeof actual.createSshWorktreeMetaIndex>) => {
indexBuildSpy('all-hosts', ...args)
return actual.createSshWorktreeMetaIndex(...args)
},
createSshWorktreeMetaIndexForRepo: (
...args: Parameters<typeof actual.createSshWorktreeMetaIndexForRepo>
) => {
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<string, { instanceId?: string; hostId?: string }> = {
[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])
})
})
@@ -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,
@@ -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<typeof OwnershipModule>()
return {
...actual,
toDetectedWorktree: (args: Parameters<typeof actual.toDetectedWorktree>[0]) => {
toDetectedWorktreeSpy(args)
return actual.toDetectedWorktree(args)
}
}
})
vi.mock('node:crypto', async (importOriginal) => ({
...(await importOriginal<typeof NodeCryptoModule>()),
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> = {}): WorktreeMeta {
return {
...ownershipMeta,
instanceId: 'instance-settled',
orcaCreatedAt: 1,
lastActivityAt: 5,
...overrides
} as WorktreeMeta
}
function createStore(meta: Record<string, WorktreeMeta>, 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<WorktreeMeta>) => {
rows[id] = { ...rows[id], ...patch } as WorktreeMeta
return rows[id]
},
setWorktreeMetaForHost: (id: string, hostId: string, patch: Partial<WorktreeMeta>) => {
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<string, WorktreeMeta>
) {
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<Store>
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))
})
})
@@ -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,
@@ -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(
@@ -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<string, unknown> | 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')
@@ -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')
})
})
@@ -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 () => {
@@ -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<Awaited<ReturnType<typeof createStore>>> {
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' })
])
})
})
@@ -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({})
})
+11 -1
View File
@@ -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 {
@@ -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<void>
}
/**
* 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:<target>` 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()
}
@@ -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<string> {
const bound = new Set<string>()
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
}
@@ -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:<target>`, 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<string>()
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()
}
}
@@ -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'
@@ -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
@@ -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)
}
@@ -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
)
}
: {}),
@@ -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<typeof Store>[] = []
afterEach(() => {
for (const store of stores.splice(0)) {
store.freezeWrites()
}
vi.restoreAllMocks()
})
function openStore(dataFile: string): InstanceType<typeof Store> {
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<WorktreeMeta> = {}) {
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<string, WorktreeMeta> = {}
const worktreeMetaByIdentity: Record<string, WorktreeMeta> = {}
const worktreeIdentityAliases: Record<string, string[]> = {}
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<typeof Store>) {
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`
])
})
})
@@ -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:<hostId>:<instanceId>` 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<string, unknown> {
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<string, WorktreeMeta> {
const derivable = new Map<string, WorktreeMeta>()
const aliases = state.worktreeIdentityAliases
const worktreeMeta = state.worktreeMeta as unknown
if (!isPlainRecord(aliases) || !isPlainRecord(worktreeMeta)) {
return derivable
}
const contested = new Set<string>()
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<string, WorktreeMeta>,
state: WorktreeMetaAliasProjectionSource
): Record<string, WorktreeMeta> {
let projected: Record<string, WorktreeMeta> | 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<PersistedState, 'worktreeMetaByIdentity'>
): Record<string, WorktreeMeta> | 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
}
@@ -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')
@@ -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) {

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