mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+3
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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', {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user