Merge origin/main into brennanb2025/claude-agent-sdk-structured-chat

# Conflicts:
#	src/relay/pty-handler.ts
This commit is contained in:
Merge Sim
2026-09-04 14:15:33 -07:00
358 changed files with 18284 additions and 2046 deletions
+1
View File
@@ -816,6 +816,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
-12
View File
@@ -53,18 +53,6 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
## Localization (i18n)
All user-facing copy is localized. `src/renderer/src/i18n/locales/en.json` is the source of truth; the `zh`, `ja`, `ko`, and `es` catalogs mirror its keys. Strings reach the UI through `translate('auto.<key>', 'English fallback')` — never hardcode display text.
When you touch user-facing copy, keep all five catalogs in sync:
- **New strings** — wrap them in `translate(...)` with an English fallback, run `pnpm sync:localization-catalog` to register the keys in `en.json` and add placeholders to every other locale, then `pnpm bootstrap:<locale>-catalog` (e.g. `bootstrap:ja-catalog`) to translate the placeholders.
- **Reworded strings** — changing the value of an existing key updates only `en.json`. The other locales keep the key with its old translation, and **the lint checks will not catch this**: `verify:localization-catalog` enforces key _parity_, not translation _freshness_. Update the same key in `zh/ja/ko/es` by hand, or re-translate it via `bootstrap:<locale>-catalog`.
- **Removed strings** — delete the key from _every_ locale; the parity check rejects a key that exists in one catalog but not another.
Before pushing copy changes, run `pnpm verify:localization-catalog` and `pnpm verify:localization-coverage` (both also run in `pnpm lint`).
## SSH Use Case
All changes must consider the SSH use case. Don't assume local-only execution. Before changing anything that reports on, stops, or lists remote work, follow [`docs/reference/ssh-execution-boundary.md`](./docs/reference/ssh-execution-boundary.md): the execution host owns everything that touches execution, and loss of contact is never evidence of process death — the verdict vocabulary is `live` / `unverifiable` / `exited`, with no synonyms.
+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.fr.md">Français</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.uk.md">Українська</a></sub>
<sub><a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.fr.md">Français</a> · <a href="docs/readme/README.pt.md">Português</a></sub>
</p>
<p align="center">
@@ -238,9 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **WeChat:** Scan to join the Orca community WeChat group 8.
- **WeChat:** Scan to join the Orca community WeChat group 8. Group 8 may be full; if so, scan the Group 9 QR code instead.
<img src="docs/assets/wechat-qr-group8.jpg" alt="WeChat group 8 QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr-group8.jpg" alt="WeChat group 8 QR code for the Orca community" width="160" />&nbsp;&nbsp;<img src="docs/assets/wechat-qr-group9.jpg" alt="WeChat group 9 QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
@@ -111,27 +111,41 @@ describe('incident monitor evaluator', () => {
})
})
it('freezes when postgres retries exceed the recalibrated ceiling', () => {
const sample = healthySample()
sample.sources['relay-logs']!.signals['relay.postgres_retries'] =
signal(INCIDENT_MONITOR_THRESHOLDS.relayPostgresRetries + 1)
expect(evaluateIncidentSample(sample, startedAt)).toMatchObject({
// Why: the global relay_cells lock made retries a steady-state rate (24 h p99
// 1320/5min on 2026-09-04); the bar fences only unbounded growth beyond that.
it('tolerates the measured healthy retry rate and freezes above the bar', () => {
const healthy = healthySample()
healthy.sources['relay-logs']!.signals['relay.postgres_retries'] = signal(1504)
expect(evaluateIncidentSample(healthy, startedAt).status).toBe('green')
const incident = healthySample()
incident.sources['relay-logs']!.signals['relay.postgres_retries'] = signal(2001)
expect(evaluateIncidentSample(incident, startedAt)).toMatchObject({
status: 'freeze',
failures: [
expect.objectContaining({ signal: 'relay.postgres_retries', threshold: 300 })
expect.objectContaining({ signal: 'relay.postgres_retries', threshold: 2000 })
]
})
})
// Why: 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 })
]
})
})
+31 -23
View File
@@ -15,8 +15,8 @@ export const INCIDENT_MONITOR_THRESHOLDS = {
// Why: healthy latest-sum backends idle near 100 but spike to 216 in 1-minute
// bursts (~10 min/day exceeded the old bar of 160 on 2026-08-26, freezing a
// pre-drain gate on baseline noise). 250 clears measured healthy peaks while
// firing well before the verified 400-connection ceiling; pool-wait and
// exhausted-retry signals keep their strict thresholds.
// firing well before the verified 400-connection ceiling; the retry signals
// below discriminate incident-class contention.
cloudSqlBackends: 250,
// Bound the observed recovery load; deadlocks remain zero-tolerance.
cloudSqlLockWaits: 20,
@@ -32,27 +32,35 @@ export const INCIDENT_MONITOR_THRESHOLDS = {
relayPoolWaiting: 800,
relayPoolWaitMs: 2_500,
// Why: successful lock retries are the contention machinery working, not harm.
// Healthy 2026-08-26 baseline bursts to 234/5min (26% of windows crossed the old
// bar of 20, set unmeasured at the monitor's 2026-07-28 birth); the 2026-08-23
// incident ran ~2,200-3,000/5min. 300 clears healthy bursts with ~10x incident
// margin; relayPostgresRetryExhausted below stays at zero tolerance, so any
// transaction that terminally fails still freezes the gate.
relayPostgresRetries: 300,
// 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,
// Recalibrated 2026-09-04 from 300, which was set 2026-08-26 when healthy bursts
// reached 234/5min. The global relay_cells FOR UPDATE lock has since become the
// fleet's steady state: measured fleet-wide (director + cells, summed per five
// minutes) 2026-09-03T05Z..2026-09-04T05Z p50 430 / p90 924 / p99 1320 / max
// 1504, with 55% of windows over 300 and only 22% of 15-minute gates clean, so
// the bar blocked the very cell roll that carries the 500 ms lock wait (#18521)
// and the beginProof crash guard to the cells. The 2026-08-23 lock incident on
// this same metric peaked at 1510 in one window and 646 in the next, so it is
// not separable from today's contention by retries alone; it is caught by
// relayPostgresRetryExhausted (467 at the peak vs a 300 bar), director
// concurrency, and the pool bars. 2000 passes every healthy 15-minute window
// measured in the last 24 h and still fences unbounded growth. Re-tighten once
// the fleet is on the 500 ms lock wait and the baseline is re-measured.
relayPostgresRetries: 2000,
// Why: 300 per five minutes, recalibrated 2026-09-04 from a bar of zero that no
// production window has cleared since #18521 shipped to the director. That
// change cut the request-path cell-inventory wait from the 1 s pool lock_timeout
// to 500 ms, so a contended waiter now fails fast (one /v1/assign 503 with
// Retry-After, which the client retries) instead of succeeding slowly, and the
// exhaustion count became a steady-state contention rate rather than an
// anomaly. Measured fleet-wide (director + cells) per five minutes over
// 2026-09-03T03Z..2026-09-04T02Z: every one of 236 windows was non-zero;
// quiet hours p50 2 / max 36; pre-#18521 daytime p50 10 / p90 25 / max 87;
// post-#18521 p50 42 / p90 147 / max 220. The 2026-08-23 lock incident peaked
// at 467. 300 clears every measured healthy window and still sits below the
// incident shape; retries above fence only unbounded growth.
// User-facing /v1/assign 503 share did not move with #18521 (13.9% old image
// vs 12.3% new, same evening), so exhaustion is not a proxy for user harm.
relayPostgresRetryExhausted: 300,
// Why: public admission is a per-instance semaphore, so fleet assignment capacity is
// concurrency x instances. A floor of 1 let the 2026-08-04 collapse from five instances
// to two pass unnoticed, which is the exact failure this monitor exists to catch. Keep in
@@ -44,6 +44,12 @@ describePostgres('PostgreSQL assignment connection headroom', () => {
`DELETE FROM relay_assignments
WHERE user_id LIKE 'connection-headroom-postgres-%'`
)
// A snapshot left by an aborted run rejects the replayed watermark
// with stale_connection_snapshot.
await database.query(
`DELETE FROM relay_cell_connection_snapshots WHERE cell_id = ?`,
[cell.id]
)
await database.query(
`DELETE FROM relay_cell_connection_runtime WHERE cell_id = ?`,
[cell.id]
@@ -38,6 +38,10 @@ describePostgres('PostgreSQL control supersession', () => {
[identity.userId]
)
await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [identity.userId])
// A snapshot left by an aborted run rejects the replayed watermark with stale_connection_snapshot.
await database.query(`DELETE FROM relay_cell_connection_snapshots WHERE cell_id = ?`, [
cell.id
])
await database.query(`DELETE FROM relay_cell_connection_runtime WHERE cell_id = ?`, [cell.id])
await database.query(`DELETE FROM relay_cell_connection_limits WHERE cell_id = ?`, [cell.id])
await database.query(`DELETE FROM relay_cell_runtime WHERE cell_id = ?`, [cell.id])
+26 -10
View File
@@ -337,8 +337,8 @@ export type CellInventoryLockMode =
// Never queue: the caller handles database_lock_unavailable and moves on.
| 'nowait'
// A sweep can enter here, so keep the pool default. Failing sooner would turn
// ordinary contention into a 55P03 the retry wrapper reports as terminal, and
// one terminal failure freezes the incident gate.
// ordinary contention into a 55P03 the retry wrapper reports as terminal, which
// spends the incident gate's bounded exhausted-retry budget (300 per 5 min).
| 'pool-default'
// Why: stranded detection (issue #225) needs a grant old enough that a real
// attach would have registered (the 90s activity lease covers dial +
@@ -3202,8 +3202,7 @@ export class RelayAssignmentStore {
)
const requestDelta = ACTIVITY_REQUEST_UNITS[kind] * (after - before)
if (requestDelta !== 0) {
await this.lockCellInventory(transaction, 'request')
await this.adjustCellReservation(transaction, text(row, 'cell_id'), requestDelta)
await this.adjustCellReservationAtomically(transaction, text(row, 'cell_id'), requestDelta)
}
})
})
@@ -3263,9 +3262,12 @@ export class RelayAssignmentStore {
}
const units = ACTIVITY_REQUEST_UNITS[input.kind]
if (existing) {
await this.lockCellInventory(transaction, 'request')
// Why: a client-chosen activity id can move between cells, so lock the
// one or two rows this path touches in cell_id order, the same order
// placement takes the inventory in, and no cycle can form.
await this.lockCellRows(transaction, [text(existing, 'cell_id'), input.cellId])
await this.removeActivityLease(transaction, identity, existing, now)
await this.adjustCellReservation(transaction, input.cellId, units)
await this.adjustCellReservationAtomically(transaction, input.cellId, units)
}
await this.adjustActivityCount(transaction, identity, input.kind, 1, expiresAt, now)
await transaction.query(
@@ -3580,8 +3582,7 @@ export class RelayAssignmentStore {
)
await this.touchAssignment(transaction, identity, expiresAt, now)
} else {
await this.lockCellInventory(transaction, 'request')
await this.adjustCellReservation(transaction, input.cellId, 1)
await this.adjustCellReservationAtomically(transaction, input.cellId, 1)
await this.adjustActivityCount(transaction, identity, 'control', 1, expiresAt, now)
await transaction.query(
`INSERT INTO relay_assignment_activity_leases
@@ -6954,6 +6955,19 @@ export class RelayAssignmentStore {
return rows
}
// Per-connection paths touch one or two cells. Locking exactly those rows,
// in the same ascending order the inventory lock uses (ORDER BY fixes the
// row-lock order), keeps them off the fleet-wide lock without a cycle.
private async lockCellRows(database: RelayDatabase, cellIds: string[]): Promise<SqlRow[]> {
const distinct = [...new Set(cellIds)]
return await database.queryLocked(
`SELECT * FROM relay_cells WHERE cell_id IN (${distinct.map(() => '?').join(', ')})
ORDER BY cell_id ASC`,
distinct,
{ lockTimeoutMs: CELL_INVENTORY_LOCK_TIMEOUT_MS }
)
}
private async lockGeneralCellInventory(
database: RelayDatabase,
mode: CellInventoryLockMode
@@ -7590,7 +7604,10 @@ export class RelayAssignmentStore {
) {
throw new Error('activity_lease_shape_mismatch')
}
const cells = await this.lockCellInventory(database, 'request')
// Why: this recomputes one cell's reservation from its leases, so only that
// row needs to be held; the 23-row inventory lock here serialised every
// desktop control rebind in the fleet behind every other one.
const cellRow = (await this.lockCellRows(database, [cellId]))[0]
await database.query(
`DELETE FROM relay_assignment_activity_leases
WHERE user_id = ? AND relay_host_id = ? AND activity_kind = 'control'
@@ -7611,7 +7628,6 @@ export class RelayAssignmentStore {
[cellId]
)
)[0]!
const cellRow = cells.find((cell) => text(cell, 'cell_id') === cellId)
const cellUnits = integer(cellUnitsRow, 'request_units')
if (!cellRow) throw new Error('assigned_cell_missing')
if (cellUnits > integer(cellRow, 'capacity_requests')) {
@@ -25,10 +25,12 @@ const CENSUS: CensusEntry[] = [
{ method: 'assignOnce', mode: 'nowait', reach: 'both' },
{ method: 'assignOnce', mode: 'nowait', reach: 'both' },
{ method: 'refreshDrainMigrationLeasesOnce', mode: 'request', reach: 'request' },
// Reachable from neither: changeActivity has no production callers, only tests.
{ method: 'changeActivity', mode: 'request', reach: 'orphan' },
{ method: 'acquireActivity', mode: 'request', reach: 'request' },
{ method: 'activateControl', mode: 'request', reach: 'request' },
// changeActivity, acquireActivity, activateControl and
// removeSupersededSameCellControls no longer take the inventory: they lock
// only the one or two cell rows they touch, in cell_id order (lockCellRows),
// so they cannot cycle with placement's ordered inventory lock, and the
// 23-row lock there had serialised every reconnect in the fleet behind every
// other one.
{ method: 'startEvacuation', mode: 'request', reach: 'request' },
{ method: 'completeEvacuationFromDeadSourceOnce', mode: 'request', reach: 'request' },
{ method: 'completeEvacuationFromDeadSourceOnce', mode: 'nowait', reach: 'request' },
@@ -48,8 +50,31 @@ const CENSUS: CensusEntry[] = [
{ method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' },
{ method: 'releaseExpiredActivity', mode: 'nowait', reach: 'sweep' },
{ method: 'reconcileReservationAccounting', mode: 'pool-default', reach: 'both' },
{ method: 'leastLoadedCell', mode: 'pool-default', reach: 'both' },
{ method: 'removeSupersededSameCellControls', mode: 'request', reach: 'request' }
{ method: 'leastLoadedCell', mode: 'pool-default', reach: 'both' }
]
// Every inline `FROM relay_cells ... FOR UPDATE` outside the named lock helpers,
// in source order: whole-table locks in reconciliation and sticky placement,
// and single-row locks for a cell the method is already scoped to (heartbeat,
// fence, drain generation, configuration, or a reservation adjust that runs
// under a lock its caller already holds). A new inline lock fails the census
// below until it is listed here; per-connection paths that touch more than one
// cell go through lockCellRows so the order is fixed.
const NAMED_LOCK_HELPERS = ['lockCellInventory', 'lockGeneralCellInventory', 'lockCellRows']
const INLINE_CELL_LOCK_SITES = [
'reconcileCellsWithOptions',
'assignStickyOnce',
'recordCellHeartbeat',
'attestCellFence',
'adoptLegacyCellFence',
'commitLegacyCellFenceAdoption',
'prepareCellFenceAttempt',
'attestCellFenceAttempt',
'attestCellFenceAttempt',
'configureCell',
'assertDrainCellGeneration',
'adjustCellReservation'
]
// The background sweeps, and nothing else. A method reachable from one of these
@@ -151,6 +176,42 @@ describe('cell inventory lock call-site census', () => {
)
})
// Why: the census only sees lockCellInventory calls, so a hand-written
// `relay_cells ... FOR UPDATE` would escape classification entirely.
it('routes every relay_cells row lock through a named lock helper', () => {
const lines = storeSource()
const rawSites: string[] = []
// Whole statements, not a fixed window: a wide column list or a raw
// FOR UPDATE inside query() must not slip past.
const source = lines.join('\n')
const bounds: { name: string; start: number }[] = []
lines.forEach((line, index) => {
const declaration = DECLARATION.exec(line)
if (declaration) bounds.push({ name: declaration[1]!, start: index })
})
const methodAt = (offset: number): string => {
const lineIndex = source.slice(0, offset).split('\n').length - 1
let name = '<module>'
for (const bound of bounds) if (bound.start <= lineIndex) name = bound.name
return name
}
const tick = String.fromCharCode(96)
const statementCall = new RegExp(
'\\.(queryLocked|query)\\(\\s*' + tick + '([^' + tick + ']*)' + tick,
'g'
)
for (const call of source.matchAll(statementCall)) {
const statement = call[2]!
if (!/\bFROM\s+relay_cells\b/.test(statement)) continue
const locks = call[1] === 'queryLocked' || /\bFOR\s+UPDATE\b/.test(statement)
if (!locks) continue
const method = methodAt(call.index)
if (NAMED_LOCK_HELPERS.includes(method)) continue
rawSites.push(method)
}
expect(rawSites).toEqual(INLINE_CELL_LOCK_SITES)
})
it('leaves no call site taking the inventory without naming a mode', () => {
const source = readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8')
const unclassified = source
@@ -170,8 +231,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()
@@ -0,0 +1,260 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { RelayAssignmentStore } from './assignment-store.js'
import { openRelayDatabase, type RelayDatabase } from './database.js'
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
// Three cells: the inventory lock covers more than the rows a move touches, and
// a high-to-low move exposes any lock taken out of cell_id order.
const cells = [
{
id: 'rebind-inventory-postgres-a',
url: 'https://rebind-inventory-postgres-a.example.com',
capacityRequests: 1_000,
connectionHardCap: 600 as const,
connectionUnobservedBound: 50
},
{
id: 'rebind-inventory-postgres-b',
url: 'https://rebind-inventory-postgres-b.example.com',
capacityRequests: 1_000,
connectionHardCap: 600 as const,
connectionUnobservedBound: 50
},
{
id: 'rebind-inventory-postgres-c',
url: 'https://rebind-inventory-postgres-c.example.com',
capacityRequests: 1_000,
connectionHardCap: 600 as const,
connectionUnobservedBound: 50
}
]
const identity = { userId: 'rebind-inventory-postgres-user', relayHostId: 'rebindinvhost001' }
function heartbeat(cell: (typeof cells)[number]) {
return {
cellId: cell.id,
cellUrl: cell.url,
cellIncarnation: '11111111-1111-4111-8111-111111111111',
startedAt: 50,
ready: true,
observedRequests: 0,
totalConnections: 0,
inFlightConnections: 0,
reservedConnectionUnits: 0,
enforcedConnectionUnits: 0,
connectionInclusionWatermark: 1,
connectionHardCap: 600 as const,
connectionUnobservedBound: 50
}
}
// Why: every desktop control rebind used to take the fleet-wide relay_cells
// FOR UPDATE lock, so a rebind on one cell queued behind whatever held any
// other cell's row, until COMMIT (55P03 at the request bound). A rebind only
// touches its own cell row, so it must proceed while another cell's row is
// held elsewhere.
describePostgres('PostgreSQL control rebind under a held cell row', () => {
const databases: RelayDatabase[] = []
beforeAll(async () => {
databases.push(
await openRelayDatabase({ databaseUrl, dataDir: '' }),
await openRelayDatabase({ databaseUrl, dataDir: '' })
)
})
async function removeTestRows(database: RelayDatabase): Promise<void> {
await database.query(
`DELETE FROM relay_control_connection_reservations WHERE user_id = ?`,
[identity.userId]
)
for (const table of [
'relay_assignment_activity_leases',
'relay_post_drain_migration_pins',
'relay_assignment_migration_incarnations',
'relay_assignment_migrations',
'relay_assignments'
]) {
await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [identity.userId])
}
for (const cell of cells) {
for (const table of [
'relay_cell_connection_snapshots',
'relay_cell_connection_runtime',
'relay_cell_connection_limits',
'relay_cell_runtime',
'relay_cells'
]) {
await database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id])
}
}
}
afterAll(async () => {
if (databases[0]) await removeTestRows(databases[0])
for (const connection of databases) await connection.close()
})
it("rebinds and supersedes a control while another cell's row is held", async () => {
// A prior aborted run leaves connection snapshots that reject a replayed watermark.
await removeTestRows(databases[0]!)
const store = new RelayAssignmentStore(databases[0]!, () => 100)
await store.reconcileCells(cells)
for (const cell of cells) await store.recordCellHeartbeat(heartbeat(cell))
// Pin the host to cell A so placement is deterministic.
await store.setCellEnabled(cells[1]!.id, false)
await store.setCellEnabled(cells[2]!.id, false)
const assignment = await store.assign(identity)
expect(assignment.cellId).toBe(cells[0]!.id)
await store.setCellEnabled(cells[1]!.id, true)
await store.setCellEnabled(cells[2]!.id, true)
await store.activateControl(identity, {
cellId: cells[0]!.id,
assignmentEpoch: assignment.assignmentEpoch,
generation: 1,
connectionInclusionWatermark: 10
})
// Hold only cell B's row on a second connection, the way a rebind on B
// does, for longer than the request-path lock bound.
let releaseInventory!: () => void
const inventoryReleased = new Promise<void>((resolve) => {
releaseInventory = resolve
})
let inventoryHeld!: () => void
const inventoryHeldPromise = new Promise<void>((resolve) => {
inventoryHeld = resolve
})
const holder = databases[1]!.transaction(async (transaction) => {
await transaction.queryLocked(`SELECT * FROM relay_cells WHERE cell_id = ?`, [cells[1]!.id])
inventoryHeld()
await inventoryReleased
})
await inventoryHeldPromise
// A generation-2 rebind on cell A supersedes generation 1. It must not
// wait on cell B's row.
const startedAt = Date.now()
const blockedStatement = async (): Promise<string> => {
const rows = await databases[1]!.query(
`SELECT left(query, 160) AS q FROM pg_stat_activity
WHERE datname = current_database() AND wait_event_type = 'Lock'`
)
return rows.map((row) => String(row.q)).join(' | ')
}
const timeout = new Promise<never>((_, reject) =>
setTimeout(
() =>
void blockedStatement().then((statement) =>
reject(new Error(`rebind on cell A blocked behind cell B's row: ${statement}`))
),
2_000
)
)
const rebound = await Promise.race([
store.activateControl(identity, {
cellId: cells[0]!.id,
assignmentEpoch: assignment.assignmentEpoch,
generation: 2,
connectionInclusionWatermark: 11
}),
timeout
])
const elapsedMs = Date.now() - startedAt
releaseInventory()
await holder
expect(rebound).toBe(`control:${cells[0]!.id}:2`)
expect(elapsedMs).toBeLessThan(2_000)
const controls = await databases[0]!.query(
`SELECT activity_id FROM relay_assignment_activity_leases
WHERE user_id = ? AND activity_kind = 'control' ORDER BY activity_id`,
[identity.userId]
)
expect(controls).toEqual([{ activity_id: `control:${cells[0]!.id}:2` }])
const reserved = await databases[0]!.query(
`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`,
[cells[0]!.id]
)
expect(Number(reserved[0]!.reserved_requests)).toBe(1)
}, 15_000)
// Why: a phone's activity id is client-chosen and can follow the host across
// a migration, so acquireActivity may touch two cell rows. Moving from the
// higher cell to the lower one is where an unordered lock cycles with
// placement's ascending inventory lock (reproduced live before this fix).
it('moves an activity from a higher cell to a lower one in cell_id order', async () => {
await removeTestRows(databases[0]!)
const [cellA, cellB, cellC] = cells as [typeof cells[0], typeof cells[0], typeof cells[0]]
const store = new RelayAssignmentStore(databases[0]!, () => 100)
await store.reconcileCells(cells)
for (const cell of cells) await store.recordCellHeartbeat(heartbeat(cell))
await store.setCellEnabled(cellA.id, false)
await store.setCellEnabled(cellB.id, false)
const assignment = await store.assign(identity)
expect(assignment.cellId).toBe(cellC.id)
await store.setCellEnabled(cellA.id, true)
await store.setCellEnabled(cellB.id, true)
const activityId = 'splice:rebind-inventory-postgres'
await store.acquireActivity(identity, { activityId, kind: 'splice', cellId: cellC.id })
// The migration makes B authoritative; the lease still sits on C.
const migration = await store.startEvacuation(identity, cellB.id)
expect(migration.targetCellId).toBe(cellB.id)
// Hold B elsewhere. An ordered move locks B first and queues here holding
// nothing else. Locking C first (the old lease's row, as an unordered move
// does) or the whole inventory (which takes A) shows up as a held row.
let releaseRow!: () => void
const rowReleased = new Promise<void>((resolve) => {
releaseRow = resolve
})
let rowHeld!: () => void
const rowHeldPromise = new Promise<void>((resolve) => {
rowHeld = resolve
})
const heldWhileMoverWaits: string[] = []
const holder = databases[1]!.transaction(async (transaction) => {
await transaction.queryLocked(`SELECT * FROM relay_cells WHERE cell_id = ?`, [cellB.id])
rowHeld()
await rowReleased
for (const cell of [cellA, cellC]) {
try {
await transaction.queryLocked(`SELECT * FROM relay_cells WHERE cell_id = ?`, [cell.id], {
failIfUnavailable: true
})
} catch {
heldWhileMoverWaits.push(cell.id)
}
}
})
await rowHeldPromise
const move = store.acquireActivity(identity, { activityId, kind: 'splice', cellId: cellB.id })
let moved = false
void move.then(() => {
moved = true
})
await new Promise((resolve) => setTimeout(resolve, 250))
expect(moved).toBe(false)
releaseRow()
await holder
await move
expect(heldWhileMoverWaits).toEqual([])
const reservations = await databases[0]!.query(
`SELECT cell_id, reserved_requests FROM relay_cells
WHERE cell_id IN (?, ?, ?) ORDER BY cell_id ASC`,
[cellA.id, cellB.id, cellC.id]
)
const reserved = reservations.map((row) => [String(row.cell_id), Number(row.reserved_requests)])
expect(reserved).toEqual([
[cellA.id, 0],
// Migration grant plus the moved splice, as in the SQLite origin-scoped
// reservation case: the lock change did not alter accounting.
[cellB.id, 6],
// The sticky grant stays on the source until the migration completes.
[cellC.id, 1]
])
}, 15_000)
})
@@ -0,0 +1,82 @@
import { ASSIGNMENT_LIMITS, RELAY_HOST_CLOSE_REASON } from '@orca-cloud/relay-contract'
import { describe, expect, it } from 'vitest'
import { HostCloseReasonMemory } from './host-close-reason-memory.js'
function memoryAt(clock: { now: number }): HostCloseReasonMemory {
return new HostCloseReasonMemory(() => clock.now)
}
describe('HostCloseReasonMemory', () => {
it('remembers only reasons it knows', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
memory.record('a', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
memory.record('b', 'quitting')
memory.record('c', Buffer.alloc(0))
memory.record('d', undefined)
expect(memory.read('a')).toBe(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
expect(memory.read('b')).toBeNull()
expect(memory.read('c')).toBeNull()
expect(memory.read('d')).toBeNull()
})
it('accepts the reason as the Buffer a ws close delivers', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
memory.record('a', Buffer.from(RELAY_HOST_CLOSE_REASON.SIGNED_OUT))
expect(memory.read('a')).toBe(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
})
it('expires an entry once its host may have been rebalanced away', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
memory.record('a', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
clock.now += ASSIGNMENT_LIMITS.dormantTtlMs - 1
expect(memory.read('a')).toBe(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
clock.now += 1
expect(memory.read('a')).toBeNull()
expect(memory.size()).toBe(0)
})
it('forgets on demand', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
memory.record('a', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
memory.forget('a')
expect(memory.read('a')).toBeNull()
})
it('drops the oldest survivors rather than growing without bound', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
for (let index = 0; index < 50_050; index++) {
memory.record(`host-${index}`, RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
}
expect(memory.size()).toBe(50_000)
expect(memory.read('host-0')).toBeNull()
expect(memory.read('host-50049')).toBe(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
})
it('re-recording refreshes recency so a live host is not evicted first', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
memory.record('a', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
memory.record('b', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
memory.record('a', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
expect([...['a', 'b'].map((key) => memory.read(key))]).toEqual([
RELAY_HOST_CLOSE_REASON.SIGNED_OUT,
RELAY_HOST_CLOSE_REASON.SIGNED_OUT
])
expect(memory.size()).toBe(2)
})
})
@@ -0,0 +1,72 @@
import {
ASSIGNMENT_LIMITS,
relayHostCloseReasonFrom,
type RelayHostCloseReason
} from '@orca-cloud/relay-contract'
// Retention matches the dormant assignment TTL: past it the host may have been
// rebalanced onto another cell, so this cell is no longer the one a phone asks.
const RETENTION_MS = ASSIGNMENT_LIMITS.dormantTtlMs
// A fleet-wide auth outage signs out every host at once; the cap bounds that
// burst well above any single cell's host count without becoming a leak.
const MAX_ENTRIES = 50_000
// Why in-memory and not Postgres: a phone reaches the cell its host's assignment
// row already names, which is the same cell that watched the control socket
// close. Losing this on a cell restart degrades to the pre-existing generic
// verdict, so the failure mode is the old behaviour rather than a wrong one.
export class HostCloseReasonMemory {
private readonly entries = new Map<string, { reason: RelayHostCloseReason; expiresAt: number }>()
constructor(private readonly now: () => number = Date.now) {}
// Silently ignores anything that is not a known reason, which is every close
// from a host that predates the field and every abrupt 1006.
record(key: string, reason: unknown): void {
const parsed = relayHostCloseReasonFrom(reason)
if (!parsed) {
return
}
this.entries.delete(key)
this.entries.set(key, { reason: parsed, expiresAt: this.now() + RETENTION_MS })
this.evict()
}
forget(key: string): void {
this.entries.delete(key)
}
read(key: string): RelayHostCloseReason | null {
const entry = this.entries.get(key)
if (!entry) {
return null
}
if (entry.expiresAt <= this.now()) {
this.entries.delete(key)
return null
}
return entry.reason
}
size(): number {
return this.entries.size
}
private evict(): void {
const now = this.now()
for (const [key, entry] of this.entries) {
if (entry.expiresAt > now) {
break
}
this.entries.delete(key)
}
// Insertion order is recency order (record deletes before setting), so the
// head is always the oldest survivor.
for (const key of this.entries.keys()) {
if (this.entries.size <= MAX_ENTRIES) {
break
}
this.entries.delete(key)
}
}
}
+34 -6
View File
@@ -14,7 +14,8 @@ import {
HostHelloSchema,
InviteCreateSchema,
RELAY_PROTOCOL_LIMITS,
RELAY_CLOSE_CODE
RELAY_CLOSE_CODE,
type RelayHostCloseReason
} from '@orca-cloud/relay-contract'
import nacl from 'tweetnacl'
import type WebSocket from 'ws'
@@ -25,6 +26,7 @@ import {
RelayCredentialStore,
type CredentialReservation
} from './credential-store.js'
import { HostCloseReasonMemory } from './host-close-reason-memory.js'
import { relayHostLogDigest } from './relay-host-log-digest.js'
import type { RelayTokenClaims } from './relay-token-verifier.js'
import type { RelayRuntimeObserver } from './relay-observability.js'
@@ -130,6 +132,10 @@ const ACTIVATION_QUEUE_WAIT_MS = 30_000
export class HostSessionRegistry {
private readonly sessions = new Map<string, HostSession>()
private readonly activationQueues = new Map<string, Promise<void>>()
// Why it outlives `sessions`: the orphan grace deletes the session within 30s,
// but a signed-out desktop never comes back, so the phone that asks minutes
// later would otherwise find nothing to explain its rejection with.
private readonly hostCloseReasons = new HostCloseReasonMemory(() => this.now())
private draining = false
constructor(
@@ -175,7 +181,8 @@ export class HostSessionRegistry {
return
}
this.observer.recordAuth(true)
const session = this.sessions.get(this.key(reservation.userId, hostId))
const sessionKey = this.key(reservation.userId, hostId)
const session = this.sessions.get(sessionKey)
if (
!session ||
session.state !== 'active' ||
@@ -184,7 +191,13 @@ export class HostSessionRegistry {
) {
capacityReservation?.release()
await this.store.failReservation(reservation)
this.rejectClient(socket, RELAY_CLOSE_CODE.HOST_OFFLINE)
// The only rejection that can name a cause: the host is genuinely absent.
// The attach-deadline 4404 below fires while control is still connected.
this.rejectClient(
socket,
RELAY_CLOSE_CODE.HOST_OFFLINE,
this.hostCloseReasons.read(sessionKey)
)
return
}
if (session.activeConnIds.size + session.pendingConns.size >= 8) {
@@ -793,7 +806,10 @@ export class HostSessionRegistry {
regionalDrainTimer: null,
regionalDrainExpiresAt: null
}
this.sessions.set(this.key(identity.sub, identity.relayHostId), session)
const sessionKey = this.key(identity.sub, identity.relayHostId)
// A host that proved itself again is not signed out, whatever it said last.
this.hostCloseReasons.forget(sessionKey)
this.sessions.set(sessionKey, session)
this.wireActiveControl(session)
this.sendHelloAck(session)
}
@@ -813,6 +829,11 @@ export class HostSessionRegistry {
})
socket.once('close', (code, reason) => {
this.observer.recordControlClose?.(code)
// Guarded on identity: a predecessor retired by a rebind must not stamp a
// cause onto the live session that replaced it.
if (session.socket === socket) {
this.hostCloseReasons.record(this.key(session.identity.sub, session.relayHostId), reason)
}
// One line per control close makes reconnect churners attributable by
// host digest without exposing the raw relay host id.
console.warn(
@@ -1187,9 +1208,16 @@ export class HostSessionRegistry {
if (session.socket) send(session.socket, 'control-error', { ...(reqId ? { reqId } : {}), code })
}
private rejectClient(socket: WebSocket, code: number): void {
// hostCloseReason rides the WebSocket close reason, never relay-hello: every
// shipped phone parses relay-hello with a strict schema that rejects an
// unknown key, and none of them read the close reason at all.
private rejectClient(
socket: WebSocket,
code: number,
hostCloseReason?: RelayHostCloseReason | null
): void {
send(socket, 'relay-hello', { ok: false, code })
closeRelayWebSocket(socket, code, 'relay connection rejected')
closeRelayWebSocket(socket, code, hostCloseReason ?? 'relay connection rejected')
}
private releaseControlActivity(session: HostSession): void {
@@ -0,0 +1,206 @@
import { EventEmitter } from 'node:events'
import {
CONTROL_CONTINUITY_LIMITS,
RELAY_CLOSE_CODE,
RELAY_HOST_CLOSE_REASON
} from '@orca-cloud/relay-contract'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type WebSocket from 'ws'
import type { RelayAssignmentStore } from './assignment-store.js'
import type { RelayConfig } from './config.js'
import type { RelayCredentialStore } from './credential-store.js'
import { HostSessionRegistry } from './host-session-registry.js'
import type { RelayRuntimeObserver } from './relay-observability.js'
import type { RelayTokenClaims } from './relay-token-verifier.js'
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
class FakeSocket extends EventEmitter {
readonly OPEN = 1
readonly CLOSED = 3
readyState = this.OPEN
readonly send = vi.fn()
readonly close = vi.fn((code?: number, reason?: string) => {
this.readyState = this.CLOSED
this.emit('close', code, Buffer.from(reason ?? ''))
})
readonly terminate = vi.fn(() => {
this.readyState = this.CLOSED
this.emit('close', 1006, Buffer.alloc(0))
})
}
const config = {
port: 8080,
publicUrl: 'https://relay-c3.example.com',
cellUrl: 'https://relay-c3.example.com',
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: []
} as unknown as RelayConfig
const identity = {
sub: 'user-1',
prof: 'profile-1',
org: 'org-1',
relayHostId: 'AbCdEf0123_-xyZ9'
} as unknown as RelayTokenClaims
const reservation = {
userId: identity.sub,
relayHostId: identity.relayHostId,
credentialKind: 'resume',
relayDeviceId: 'device-1',
leaseExpiresAt: Date.now() + 60_000
}
function createRegistry() {
const store = {
resolveResume: vi.fn().mockResolvedValue({ userId: identity.sub }),
reserveCredential: vi.fn().mockResolvedValue(reservation),
failReservation: vi.fn().mockResolvedValue(undefined)
}
const assignments = {
activateControl: vi.fn().mockResolvedValue('control:production-gce-c3:1'),
markMigrationTargetRegistered: vi.fn().mockResolvedValue(undefined),
resolve: vi.fn().mockResolvedValue({ cellId: config.cellId }),
acquireActivity: vi.fn().mockResolvedValue(undefined),
renewControlActivity: vi.fn().mockResolvedValue(undefined),
releaseActivity: vi.fn().mockResolvedValue(true)
} as unknown as RelayAssignmentStore
const observer = {
recordAuth: vi.fn(),
recordForwardedBytes: vi.fn(),
recordHttp: vi.fn(),
recordReconnect: vi.fn(),
recordSql: vi.fn(),
recordControlClose: vi.fn(),
recordSpliceClose: vi.fn()
} satisfies RelayRuntimeObserver
const registry = new HostSessionRegistry(
config,
vi.fn(),
store as unknown as RelayCredentialStore,
assignments,
new ProcessQueuedByteBudget(),
observer
)
const activate = (socket: WebSocket, generation: number): Promise<void> =>
(
registry as unknown as {
activate: (
socket: WebSocket,
identity: RelayTokenClaims,
existing: null,
generation: number,
rebind: boolean,
assignmentEpoch: number,
appVersion: string
) => Promise<void>
}
).activate(socket, identity, null, generation, false, 1, '1.4.173')
return { registry, activate }
}
async function dialPhone(registry: HostSessionRegistry): Promise<FakeSocket> {
const phone = new FakeSocket()
await registry.acceptClient(phone as unknown as WebSocket, identity.relayHostId, 'credential')
return phone
}
// The 4404 hello body is unchanged: every shipped phone parses it with a strict
// schema, so the cause has to ride the close frame instead.
const HOST_OFFLINE_HELLO = JSON.stringify({
type: 'relay-hello',
ok: false,
code: RELAY_CLOSE_CODE.HOST_OFFLINE
})
describe('host sign-out reason on phone rejection', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('names the sign-out to a phone that arrives after the host is gone', async () => {
const { registry, activate } = createRegistry()
const control = new FakeSocket()
await activate(control as unknown as WebSocket, 1)
control.close(1000, RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs + 1)
const phone = await dialPhone(registry)
expect(phone.send).toHaveBeenCalledWith(HOST_OFFLINE_HELLO)
expect(phone.close).toHaveBeenCalledWith(
RELAY_CLOSE_CODE.HOST_OFFLINE,
RELAY_HOST_CLOSE_REASON.SIGNED_OUT
)
})
it('says nothing when the host died without naming a cause', async () => {
const { registry, activate } = createRegistry()
const control = new FakeSocket()
await activate(control as unknown as WebSocket, 1)
control.terminate()
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs + 1)
const phone = await dialPhone(registry)
expect(phone.close).toHaveBeenCalledWith(
RELAY_CLOSE_CODE.HOST_OFFLINE,
'relay connection rejected'
)
})
it('ignores a close reason the host invented', async () => {
const { registry, activate } = createRegistry()
const control = new FakeSocket()
await activate(control as unknown as WebSocket, 1)
control.close(1000, 'signed-out-ish')
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs + 1)
const phone = await dialPhone(registry)
expect(phone.close).toHaveBeenCalledWith(
RELAY_CLOSE_CODE.HOST_OFFLINE,
'relay connection rejected'
)
})
it('forgets the sign-out once the host proves itself again', async () => {
const { registry, activate } = createRegistry()
const control = new FakeSocket()
await activate(control as unknown as WebSocket, 1)
control.close(1000, RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs + 1)
const reconnected = new FakeSocket()
await activate(reconnected as unknown as WebSocket, 2)
// Drop it abruptly, as a network death would, so only the stale memory
// could still name a cause.
reconnected.terminate()
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs + 1)
const phone = await dialPhone(registry)
expect(phone.close).toHaveBeenCalledWith(
RELAY_CLOSE_CODE.HOST_OFFLINE,
'relay connection rejected'
)
})
// A live host is present: the 4404 there is an attach deadline, not absence.
it('never names a cause while the host control is connected', async () => {
const { registry, activate } = createRegistry()
const control = new FakeSocket()
await activate(control as unknown as WebSocket, 1)
const phone = await dialPhone(registry)
expect(phone.close).not.toHaveBeenCalled()
expect(control.send).toHaveBeenCalledWith(expect.stringContaining('"type":"conn-open"'))
})
})
@@ -133,7 +133,10 @@
"google_logging_metric.relay_snapshot",
"google_monitoring_alert_policy.relay_assignment_5xx",
"google_monitoring_alert_policy.relay_assignment_edge_429",
"google_monitoring_alert_policy.relay_cloud_nat_port_drops",
"google_monitoring_alert_policy.relay_cloud_sql_backends",
"google_monitoring_alert_policy.relay_cloud_sql_checkpoint_loop",
"google_monitoring_alert_policy.relay_cloud_sql_disk",
"google_monitoring_alert_policy.relay_custom",
"google_monitoring_alert_policy.relay_gce_connection_headroom",
"google_monitoring_alert_policy.relay_postgres_retry_exhausted",
+32 -6
View File
@@ -99,8 +99,8 @@ durably marked consumed before mutation and cannot authorize another run.
| Cloud SQL deadlocks | over 0 |
| Relay pool waiters | over 800 |
| Relay pool wait | over 2,500 ms |
| PostgreSQL retries in five minutes | over 300 |
| Exhausted PostgreSQL retries | over 0 |
| PostgreSQL retries in five minutes | over 2,000 |
| Exhausted PostgreSQL retries in five minutes | over 300 |
| Director instances | outside 5–6 |
| Director CPU or memory | over 80% |
| Director concurrency | over 64 |
@@ -132,15 +132,41 @@ heartbeats, and matching live admission.
10 minutes over the old bar of 160 — enough to freeze roughly one in ten
15-minute pre-drain gates on baseline noise. 250 clears measured healthy
peaks and still fires well before the verified 400-connection ceiling;
pool waiters, pool wait latency, and exhausted retries keep their strict
thresholds.
pool waiters and pool wait latency keep their strict thresholds.
- Recalibrated the PostgreSQL-retry freeze from 20 to 300 per five minutes
(2026-08-26). Basis, measured from
`jsonPayload.event="orca_relay_postgres_transaction_retry"` in production
logs: healthy-day bursts reach 234/5min with zero exhausted retries and 26%
of five-minute windows over 20, while the 2026-08-23 lock-contention
incident ran roughly 2,200–3,000/5min. Exhausted retries stay at zero
tolerance.
incident ran roughly 2,200–3,000/5min by raw log-line count (the gate's
own `orca_relay_postgres_retries` metric read 1,510 for that window; see the
2026-09-04 entry).
- Recalibrated the PostgreSQL-retry freeze from 300 to 2,000 per five minutes
(2026-09-04). Basis: the global `relay_cells FOR UPDATE` lock made
successful retries a steady-state rate. Measured fleet-wide (director +
cells, summed per five minutes from the `orca_relay_postgres_retries`
log metric) over 2026-09-03T05Z..2026-09-04T05Z: p50 430 / p90 924 /
p99 1,320 / max 1,504; 55% of windows over 300; only 22% of 15-minute gates
clean at 300 versus 100% at 2,000. Three read-only dry-runs on 2026-09-04
froze on this bar (runs 33836470590, 33838698725) or on a genuine six-cell
crash storm (33837160275), blocking the same-cap roll that carries #18521
and the `beginProof` crash guard to the 23 cells. The 2026-08-23 incident
on this metric peaked at 1,510 then 646, so retries alone no longer
separate it from today's baseline; the exhausted-retry bar (incident peak
467 vs bar 300), director concurrency, and the pool bars carry that role.
Re-tighten after the fleet is on the 500 ms lock wait.
- Recalibrated the exhausted-PostgreSQL-retry freeze from 0 to 300 per five
minutes (2026-09-04). Basis: #18521 cut the request-path cell-inventory
lock wait from the 1 s pool `lock_timeout` to 500 ms, so contended waiters
now fail fast (one `/v1/assign` 503 with `Retry-After`) instead of
succeeding slowly, and `orca_relay_postgres_transaction_exhausted` became
a steady contention rate. Measured fleet-wide per five minutes over
2026-09-03T03Z..2026-09-04T02Z: 236 of 236 windows non-zero; quiet hours
p50 2 / max 36; pre-#18521 daytime p50 10 / p90 25 / max 87; post-#18521
p50 42 / p90 147 / max 220; the 2026-08-23 incident peaked at 467. Every
pre-drain dry-run since the director deploy froze at minute one on this
bar, which blocked the cell roll that carries the same fix to the 23 GCE
cells. `/v1/assign` 503 share was unchanged by #18521 (13.9% vs 12.3%).
- Added a fail-closed state machine with latched threshold freezes,
generation-scoped checkpoint boundaries, continuity-reset evidence, cadence
accounting, restart-gap recovery, and the 15-minute pre-drain gate.
@@ -42,6 +42,12 @@ resource "google_compute_router_nat" "relay_gce" {
router = google_compute_router.relay_gce[0].name
nat_ip_allocate_option = "AUTO_ONLY"
source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS"
# Cells reach Cloud SQL's public IP through this NAT. The static default of 64 ports per VM
# filled during the 2026-09-04 incident and every cell's proxy dial timed out at once.
enable_dynamic_port_allocation = true
enable_endpoint_independent_mapping = false
min_ports_per_vm = 64
max_ports_per_vm = 4096
subnetwork {
name = google_compute_subnetwork.relay_gce[0].id
@@ -85,6 +91,12 @@ resource "google_compute_router_nat" "relay_gce_additional" {
router = google_compute_router.relay_gce_additional[each.key].name
nat_ip_allocate_option = "AUTO_ONLY"
source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS"
# Cells reach Cloud SQL's public IP through this NAT. The static default of 64 ports per VM
# filled during the 2026-09-04 incident and every cell's proxy dial timed out at once.
enable_dynamic_port_allocation = true
enable_endpoint_independent_mapping = false
min_ports_per_vm = 64
max_ports_per_vm = 4096
subnetwork {
name = google_compute_subnetwork.relay_gce_additional[each.key].id
@@ -37,6 +37,10 @@ locals {
description = "Relay PostgreSQL transactions that exhausted bounded retry."
filter = "((resource.type=\"cloud_run_revision\" AND (${local.relay_service_log_filter})) OR resource.type=\"gce_instance\") AND jsonPayload.event=\"orca_relay_postgres_transaction_exhausted\""
}
cloud_sql_wal_checkpoint = {
description = "Cloud SQL checkpoints triggered by WAL volume instead of the timed schedule; a sustained run is the fsync loop that stalled every relay process at once on 2026-09-04."
filter = "resource.type=\"cloudsql_database\" AND resource.labels.database_id=\"${var.project_id}:${local.relay_database_instance_name}\" AND textPayload:\"checkpoint starting: wal\""
}
}
relay_runtime_metrics = {
@@ -523,3 +527,109 @@ resource "google_monitoring_alert_policy" "relay_cloud_sql_backends" {
mime_type = "text/markdown"
}
}
resource "google_monitoring_alert_policy" "relay_cloud_sql_checkpoint_loop" {
project = var.project_id
display_name = "Orca Relay: Cloud SQL checkpoint loop"
combiner = "OR"
enabled = true
notification_channels = var.relay_alert_notification_channels
conditions {
display_name = "WAL-triggered checkpoints above 3 in 5 minutes"
condition_threshold {
filter = "resource.type=\"cloudsql_database\" AND metric.type=\"logging.googleapis.com/user/orca_relay_cloud_sql_wal_checkpoint\""
comparison = "COMPARISON_GT"
threshold_value = 3
duration = "300s"
aggregations {
alignment_period = "300s"
per_series_aligner = "ALIGN_SUM"
cross_series_reducer = "REDUCE_SUM"
}
trigger {
count = 1
}
}
}
documentation {
content = "Healthy operation is one timed checkpoint every 5 minutes. Repeated `checkpoint starting: wal` lines mean WAL is outrunning `max_wal_size` and every checkpoint fsync stalls all relay SQL for seconds. Check `checkpoint complete` sync= times and disk write throughput against the PD-SSD ceiling; the fix is disk size and `max_wal_size` in the Terraform root that owns the instance (orca-cloud `infra/terraform-foundation`)."
mime_type = "text/markdown"
}
depends_on = [google_logging_metric.relay_incident]
}
resource "google_monitoring_alert_policy" "relay_cloud_sql_disk" {
project = var.project_id
display_name = "Orca Relay: Cloud SQL disk utilization"
combiner = "OR"
enabled = true
notification_channels = var.relay_alert_notification_channels
conditions {
display_name = "Cloud SQL disk above 70%"
condition_threshold {
filter = "resource.type=\"cloudsql_database\" AND resource.label.\"database_id\"=\"${var.project_id}:${local.relay_database_instance_name}\" AND metric.type=\"cloudsql.googleapis.com/database/disk/utilization\""
comparison = "COMPARISON_GT"
threshold_value = 0.7
duration = "600s"
aggregations {
alignment_period = "300s"
per_series_aligner = "ALIGN_MAX"
}
trigger {
count = 1
}
}
}
documentation {
content = "The shared auth/relay Cloud SQL disk is filling. `refresh_tokens` is the largest table and grows without pruning; grow the disk (IOPS scale with size) before it reaches the WAL checkpoint loop, and prune revoked token rows."
mime_type = "text/markdown"
}
}
resource "google_monitoring_alert_policy" "relay_cloud_nat_port_drops" {
count = local.relay_gce_configured ? 1 : 0
project = var.project_id
display_name = "Orca Relay: Cloud NAT port exhaustion"
combiner = "OR"
enabled = true
notification_channels = var.relay_alert_notification_channels
conditions {
display_name = "NAT packets dropped for lack of ports"
condition_threshold {
filter = "resource.type=\"nat_gateway\" AND resource.label.\"gateway_name\"=monitoring.regex.full_match(\"${local.relay_gce_name}(-.*)?\") AND metric.type=\"router.googleapis.com/nat/dropped_sent_packets_count\" AND metric.label.\"reason\"=\"OUT_OF_RESOURCES\""
comparison = "COMPARISON_GT"
threshold_value = 0
duration = "120s"
aggregations {
alignment_period = "60s"
per_series_aligner = "ALIGN_SUM"
cross_series_reducer = "REDUCE_SUM"
group_by_fields = ["resource.label.\"gateway_name\""]
}
trigger {
count = 1
}
}
}
documentation {
content = "Relay cells reach Cloud SQL's public IP through this NAT. Port exhaustion makes every cell's Cloud SQL Auth Proxy dial time out at once, which reads as a fleet-wide SQL stall with a healthy database. Check `nat/port_usage` per VM and raise `max_ports_per_vm` in `relay-gce-foundation.tf`, or move the database to a private IP."
mime_type = "text/markdown"
}
}
@@ -0,0 +1,18 @@
// Mirror of src/shared/relay-host-close-reason.ts in the Orca app repo half.
// A host control socket may close with one of these as its WebSocket close
// reason; the cell records it so a later phone rejection can name the cause.
// Anything else (including the empty reason of an abrupt 1006) means "unknown",
// which is what every peer that predates this file sends.
export const RELAY_HOST_CLOSE_REASON = {
SIGNED_OUT: 'signed-out'
} as const
export type RelayHostCloseReason =
(typeof RELAY_HOST_CLOSE_REASON)[keyof typeof RELAY_HOST_CLOSE_REASON]
const REASONS: readonly string[] = Object.values(RELAY_HOST_CLOSE_REASON)
export function relayHostCloseReasonFrom(value: unknown): RelayHostCloseReason | null {
const text = typeof value === 'string' ? value : (value?.toString() ?? '')
return REASONS.includes(text) ? (text as RelayHostCloseReason) : null
}
@@ -5,6 +5,7 @@ export * from './control-messages.js'
export * from './control-continuity.js'
export * from './credential-messages.js'
export * from './director-messages.js'
export * from './host-close-reason.js'
export * from './host-proof-transcript.js'
export * from './persistence-invariants.js'
export * from './protocol-limits.js'
+13 -1
View File
@@ -90,7 +90,19 @@ const bundledPluginResources = {
// from package directories where pnpm's symlink farm is absent. Copy the exact
// runtime dependency closure to Resources/node_modules so bare require() calls
// do not fall through to a developer checkout's node_modules.
const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources]
// Why the single file rather than the package root: app.asar carries no node_modules, so main's
// lazy require in deferred-emoji-shortcode-dataset.ts resolves only out of Resources/node_modules,
// but emojibase-data is 49 MB of locale datasets and worktree naming reads exactly this 166 KB file.
const emojiShortcodeDatasetResource = {
from: 'node_modules/emojibase-data/en/shortcodes/emojibase.json',
to: 'node_modules/emojibase-data/en/shortcodes/emojibase.json'
}
const commonExtraResources = [
relayExtraResource,
bundledPluginResources,
skillFreshnessResources,
emojiShortcodeDatasetResource
]
// Why: native speech addons must be real files outside app.asar; copy only the
// package matching the artifact target instead of every optional variant.
const macSpeechNativeResource = {
+149 -21
View File
@@ -603,7 +603,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..2ae787c5bd4f3eba470584dc658a01a5
}
#endif
diff --git a/src/win/conpty.cc b/src/win/conpty.cc
index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a6a4082ce 100644
index 7b286d3d644c26141df516929703aa6e129df4b2..4aed260dd68e6a171dcfd349e9a7c5c97209248e 100644
--- a/src/win/conpty.cc
+++ b/src/win/conpty.cc
@@ -18,6 +18,7 @@
@@ -614,7 +614,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
#include <vector>
#include <Windows.h>
#include <strsafe.h>
@@ -44,12 +45,29 @@ struct pty_baton {
@@ -44,12 +45,39 @@ struct pty_baton {
HANDLE hOut;
HPCON hpc;
@@ -630,22 +630,32 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
+ // refused to create or assign one (an outer job without breakaway rights),
+ // in which case callers fall back to their pre-job behaviour.
+ HANDLE hJob = nullptr;
+
+ // Orca: teardown needs BOTH the shell's death and an explicit kill() before
+ // the baton can be freed, so each side records that it has run. Whichever
+ // arrives second frees it. Freeing on the shell's death alone -- what this
+ // file did before -- destroyed the only record of `hpc` while
+ // ClosePseudoConsole was still owed, which is why a self-exiting shell
+ // leaked its pseudoconsole and the console host it reaps (#18601 / F24).
+ bool shellExited = false;
+ bool consoleClosed = false;
pty_baton(int _id, HANDLE _hIn, HANDLE _hOut, HPCON _hpc) : id(_id), hIn(_hIn), hOut(_hOut), hpc(_hpc) {};
};
static std::vector<std::unique_ptr<pty_baton>> ptyHandles;
+// Orca: guards the job accessors below against the exit watcher thread. It does
+// NOT make the whole table safe -- PtyResize/PtyClear/PtyKill read it unlocked,
+// as they always have -- but it closes the window this patch opened, where the
+// watcher can close hShell/hJob and free the baton between a lookup and its use.
+// Orca: guards the job accessors below, and PtyKill, against the exit watcher
+// thread. It does NOT make the whole table safe -- PtyResize and PtyClear still
+// read it unlocked, as they always have -- but it closes the window this patch
+// opened, where the watcher can close hShell/hJob and free the baton between a
+// lookup and its use.
+// Handle VALUES are recycled aggressively, so an unguarded read could pass the
+// shell-pid check against an unrelated process and terminate the wrong job.
+static std::mutex ptyJobMutex;
static volatile LONG ptyCounter;
static pty_baton* get_pty_baton(int id) {
@@ -102,8 +120,27 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
@@ -102,8 +130,31 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pty_baton* baton) {
// Get process exit code.
GetExitCodeProcess(baton->hShell, (LPDWORD)(&exit_event->exit_code));
// Clean up handles
@@ -665,9 +675,13 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
+ // Why inside the lock: erasing frees the baton the job accessors hold a
+ // pointer to. Note remove_pty_baton must not be an assert() argument --
+ // NDEBUG would compile the call away and leak every baton.
+ const bool removed = remove_pty_baton(baton->id);
+ assert(removed);
+ (void)removed;
+ baton->shellExited = true;
+ if (baton->consoleClosed) {
+ const bool removed = remove_pty_baton(baton->id);
+ assert(removed);
+ (void)removed;
+ }
+ // Else PtyKill has not run yet and still owns hpc. It frees the baton.
+ }
+ // Why the lock ends here: BlockingCall below waits on the JS thread, and the
+ // JS thread can be waiting on ptyJobMutex inside PtyTerminateJob. Holding
@@ -675,7 +689,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
switch (status) {
@@ -409,6 +446,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -409,6 +460,15 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "UpdateProcThreadAttribute failed");
}
@@ -691,7 +705,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
PROCESS_INFORMATION piClient{};
fSuccess = !!CreateProcessW(
nullptr,
@@ -416,7 +462,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -416,7 +476,10 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
nullptr, // lpProcessAttributes
nullptr, // lpThreadAttributes
false, // bInheritHandles VERY IMPORTANT that this is false
@@ -703,7 +717,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
envArg, // lpEnvironment
mutableCwd.get(), // lpCurrentDirectory
&siEx.StartupInfo, // lpStartupInfo
@@ -426,8 +475,47 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -426,8 +489,47 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
throw errorWithCode(info, "Cannot create process");
}
@@ -753,7 +767,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
if (useConptyDll && fLoadedDll)
{
PFNRELEASEPSEUDOCONSOLE const pfnReleasePseudoConsole = (PFNRELEASEPSEUDOCONSOLE)GetProcAddress(
@@ -440,6 +528,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
@@ -440,6 +542,8 @@ static Napi::Value PtyConnect(const Napi::CallbackInfo& info) {
// Update handle
handle->hShell = piClient.hProcess;
@@ -762,7 +776,91 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
// Close the thread handle to avoid resource leak
CloseHandle(piClient.hThread);
@@ -567,6 +657,143 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
@@ -544,29 +648,215 @@ static Napi::Value PtyKill(const Napi::CallbackInfo& info) {
int id = info[0].As<Napi::Number>().Int32Value();
const bool useConptyDll = info[1].As<Napi::Boolean>().Value();
- const pty_baton* handle = get_pty_baton(id);
+ // Orca: resolve the DLL BEFORE touching any baton state, for the same reason
+ // PtyConnect does it before creating anything. LoadConptyDll throws when
+ // conpty.dll is missing, and a throw after consoleClosed was set would strand
+ // the pseudoconsole permanently: the retry would find the work already
+ // claimed and do nothing. Only the useConptyDll path can throw here; the
+ // other returns kernel32.
+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
+ PFNCLOSEPSEUDOCONSOLE pfnClosePseudoConsole = nullptr;
+ if (hLibrary != nullptr) {
+ pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress(
+ (HMODULE)hLibrary,
+ useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
+ }
- if (handle != nullptr) {
- HANDLE hLibrary = LoadConptyDll(info, useConptyDll);
- bool fLoadedDll = hLibrary != nullptr;
- if (fLoadedDll)
- {
- PFNCLOSEPSEUDOCONSOLE const pfnClosePseudoConsole = (PFNCLOSEPSEUDOCONSOLE)GetProcAddress(
- (HMODULE)hLibrary,
- useConptyDll ? "ConptyClosePseudoConsole" : "ClosePseudoConsole");
- if (pfnClosePseudoConsole)
- {
- pfnClosePseudoConsole(handle->hpc);
+ // Orca: the baton now outlives the shell, so this runs on a self-exited pty
+ // too -- that is the whole point. Take what we need under the lock: the
+ // watcher thread nulls hShell the moment the shell dies, and TerminateProcess
+ // on a handle it just closed is an invalid-handle operation. Duplicating
+ // rather than reordering keeps upstream's close-then-terminate sequence.
+ HPCON hpc = nullptr;
+ HANDLE hShellDup = nullptr;
+ bool owed = false;
+ {
+ std::lock_guard<std::mutex> guard(ptyJobMutex);
+ pty_baton* handle = get_pty_baton(id);
+ // Why the consoleClosed check: a second kill() would otherwise close the
+ // same pseudoconsole twice. Upstream relied on the baton being gone.
+ if (handle != nullptr && !handle->consoleClosed) {
+ hpc = handle->hpc;
+ owed = true;
+ handle->consoleClosed = true;
+ // Null hShell means a self-exited pty, where there is nothing to kill.
+ if (useConptyDll && handle->hShell != nullptr) {
+ if (!DuplicateHandle(GetCurrentProcess(), handle->hShell, GetCurrentProcess(),
+ &hShellDup, 0, FALSE, DUPLICATE_SAME_ACCESS)) {
+ // Why terminate here instead of skipping: a failed duplication leaves
+ // hShellDup null, which is indistinguishable from the self-exit case,
+ // and skipping would leave the shell RUNNING after its pane closed --
+ // a worse outcome than the leak this all exists to fix. hShell is
+ // valid under this lock and TerminateProcess does not block, so the
+ // only cost is that this rare path kills before the console closes.
+ hShellDup = nullptr;
+ TerminateProcess(handle->hShell, 1);
+ }
+ }
+ if (handle->shellExited) {
+ const bool removed = remove_pty_baton(id);
+ assert(removed);
+ (void)removed;
}
+ // Else the shell is still running and the watcher frees the baton.
}
- if (useConptyDll) {
- TerminateProcess(handle->hShell, 1);
+ }
+
+ // Why outside the lock: ClosePseudoConsole blocks until the conout side has
+ // drained, and the watcher must be able to take the lock while it does.
+ if (owed) {
+ if (pfnClosePseudoConsole)
+ {
+ pfnClosePseudoConsole(hpc);
+ }
+ if (hShellDup != nullptr) {
+ TerminateProcess(hShellDup, 1);
+ CloseHandle(hShellDup);
}
}
return env.Undefined();
}
@@ -808,9 +906,11 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
+ * Orca: the pids still alive in this pty's tree, straight from the kernel.
+ *
+ * Descendant liveness for a tree that is still tracked, including children that
+ * detached from the console. Once the shell exits the baton is gone, so this
+ * returns null rather than an empty list -- null means "no answer", never
+ * "they died". Also returns null when no job was assigned.
+ * detached from the console. Once the shell exits the watcher nulls hJob, which
+ * ownsShell rejects, so this returns null rather than an empty list -- null
+ * means "no answer", never "they died". (The baton itself now outlives the
+ * shell, until kill() runs; hJob is what makes the answer null.) Also returns
+ * null when no job was assigned.
+ *
+ * Does not include the ConPTY console host: CreatePseudoConsole spawns it
+ * before this job exists, so it is not a member and ClosePseudoConsole is what
@@ -906,7 +1006,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
/**
* Init
*/
@@ -577,6 +804,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
@@ -577,6 +867,9 @@ Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("clear", Napi::Function::New(env, PtyClear));
exports.Set("kill", Napi::Function::New(env, PtyKill));
@@ -917,7 +1017,7 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a
};
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
index a358ffb..fb3a96f 100644
index a358ffb177357e177661033c1b092f9c9d0e5f5a..26c2a4c58799ce649f5113131e4c52f7ed2d87ad 100644
--- a/lib/windowsPtyAgent.js
+++ b/lib/windowsPtyAgent.js
@@ -136,6 +136,9 @@ var WindowsPtyAgent = /** @class */ (function () {
@@ -930,6 +1030,20 @@ index a358ffb..fb3a96f 100644
this._outSocket.readable = false;
this._getConsoleProcessList().then(function (consoleProcessList) {
consoleProcessList.forEach(function (pid) {
@@ -154,9 +157,10 @@ var WindowsPtyAgent = /** @class */ (function () {
// Close the input write handle to signal the end of session.
this._inSocket.destroy();
this._ptyNative.kill(this._pty, this._useConptyDll);
- this._outSocket.on('data', function () {
- _this._conoutSocketWorker.dispose();
- });
+ // Orca: dispose unconditionally, as the non-DLL branch above does.
+ // Waiting for another 'data' event leaks the conout worker on every
+ // self-exiting shell, because no more data ever arrives (F24).
+ this._conoutSocketWorker.dispose();
}
}
else {
diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js
index 3c38f89..e20b3e6 100644
--- a/lib/windowsTerminal.js
@@ -1015,7 +1129,7 @@ index 3c38f89..e20b3e6 100644
\ No newline at end of file
+//# sourceMappingURL=windowsTerminal.js.map
diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts
index d705444..ce611b8 100644
index d7054449516f0c9a62af351c2caa17331206d530..0c28a32e2e1db2b3f208ddde8443cd4e67bb1ad6 100644
--- a/src/windowsPtyAgent.ts
+++ b/src/windowsPtyAgent.ts
@@ -143,6 +143,9 @@ export class WindowsPtyAgent {
@@ -1028,6 +1142,20 @@ index d705444..ce611b8 100644
this._outSocket.readable = false;
this._getConsoleProcessList().then(consoleProcessList => {
consoleProcessList.forEach((pid: number) => {
@@ -159,9 +162,10 @@ export class WindowsPtyAgent {
// Close the input write handle to signal the end of session.
this._inSocket.destroy();
(this._ptyNative as IConptyNative).kill(this._pty, this._useConptyDll);
- this._outSocket.on('data', () => {
- this._conoutSocketWorker.dispose();
- });
+ // Orca: dispose unconditionally, as the non-DLL branch above does.
+ // Waiting for another 'data' event leaks the conout worker on every
+ // self-exiting shell, because no more data ever arrives (F24).
+ this._conoutSocketWorker.dispose();
}
} else {
// Because pty.kill closes the handle, it will kill most processes by itself.
diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts
index 13f6c6d..eda63c8 100644
--- a/src/windowsTerminal.ts
@@ -0,0 +1,158 @@
const { createHash } = require('node:crypto')
const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs')
const { join, resolve } = require('node:path')
/**
* Release the ConPTY teardown handles a relay's npm-installed node-pty never releases.
*
* Two files, and the ORDER of one of the edits is the whole fix.
*
* `windowsPtyAgent.js` -- `kill()` flips `readable` on both sockets and destroys neither.
* `_cleanUpProcess` destroys `_outSocket`, so the conout handle comes back; nothing ever destroys
* `_inSocket`, and it wraps a real Windows named-pipe handle from `fs.openSync(term.conin, 'w')`.
* Every terminal leaks one File handle for the life of the host process.
*
* The obvious fix -- and the one the desktop patch ships -- releases it at the TOP of the branch,
* before `_getConsoleProcessList()` forks and before the native kill. That is measurably worse than
* leaving the leak alone: teardown aborts partway, the forked console-list agent is never reaped,
* and both pipe handles stay alive instead of one. This asset releases it at the END of the branch
* instead, after the fork and the kill have already happened.
*
* Measured on a Windows SSH host, 20 spawn/kill cycles, handles bucketed by NT object type
* (identical numbers standalone and through a real relay):
*
* published node-pty File +1/terminal, Process flat
* desktop patch placement File +2/terminal, Process +1/terminal <-- 3x WORSE
* released last (here) File flat, Process flat
*
* `windowsTerminal.js` carries the desktop's error-listener hunks verbatim. The conin listener is
* what keeps a pipe error retiring one terminal instead of the host -- its own comment names the
* failure mode: "Without a listener, Node promotes errors such as write EAGAIN to uncaughtException".
* It is not what fixes the leak (adding it changed nothing on its own), but it is the guard that
* makes destroying conin safe at all.
*
* Why this ships as a relay asset rather than only in config/patches/node-pty@1.1.0.patch: pnpm
* patches do not cross the SSH boundary -- a relay host runs the tree `npm install` put there.
*
* DELIBERATE DIVERGENCE FROM THE DESKTOP: the desktop patch has the early placement and therefore
* the +2 File / +1 Process regression, measured against its exact installed tree. Correcting it
* there is a separate change with its own verification, so the two trees differ on this one hunk on
* purpose, and the test pins that so a future "sync the patches" does not copy the bug back.
*
* NOT ADDRESSED, AND A SEPARATE DEFECT THAT IS STILL OPEN: a terminal that exits on its own is
* still torn down through `kill()` -- both hosts call `destroy()` on natural exit and
* `WindowsTerminal.destroy()` is `kill()` -- but the shell is already gone by then, and the
* ordering this patch relies on does not hold. Measured over 20 self-exit cycles with that
* `destroy()` issued: published +3 File/+1 Process per terminal, desktop-patched +2/+1, this tree
* +2/+1. So this patch does not close it and the desktop patch does not either. It is reachable
* for every Windows user, local and relay, on every terminal closed by typing `exit`.
*/
const EXPECTED_NODE_PTY_VERSION = '1.1.0'
/** Each entry is one published file, its patched form, and the edits between them. */
const PATCH_TARGETS = [
{
relativePath: ['lib', 'windowsPtyAgent.js'],
originalSha256: '8636d16b38266112204061a22b135734177c242837982fd3a4055be726efa64a',
patchedSha256: '1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280',
replacements: [
[
' this._ptyNative.kill(this._pty, this._useConptyDll);\n this._conoutSocketWorker.dispose();\n',
' this._ptyNative.kill(this._pty, this._useConptyDll);\n this._conoutSocketWorker.dispose();\n // Orca: released AFTER the console-list fork and the native kill, not before them.\n // Destroying conin first aborts teardown partway -- measured on a Windows SSH relay\n // as +2 File and +1 Process handles per terminal, against +1 File unpatched.\n this._inSocket.destroy();\n'
]
]
},
{
relativePath: ['lib', 'windowsTerminal.js'],
originalSha256: 'c3a65716f53fed0135a8a633373d5f9c2ab092544d651f27ef0a67096dd3bcd9',
patchedSha256: '8247ecd69be8b18257050fb026b290024612c5ffc6d492ff1d46f81e613be2cf',
replacements: [
[
' _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);\n _this._socket = _this._agent.outSocket;\n // Not available until `ready` event emitted.\n _this._pid = _this._agent.innerPid;',
" _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);\n _this._socket = _this._agent.outSocket;\n // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.\n _this._socket.on('error', function (err) {\n var code = err && err.code;\n // PTY output can report EPIPE before `_close()` wins the race.\n _this._close();\n if (code === 'EPIPE' || code === 'ERR_STREAM_PUSH_AFTER_EOF' || code === 'ERR_STREAM_DESTROYED') {\n return;\n }\n // EIO, happens when someone closes our child process: the only process\n // in the terminal.\n // node < 0.6.14: errno 5\n // node >= 0.6.14: read EIO\n if (typeof code === 'string') {\n if (~code.indexOf('errno 5') || ~code.indexOf('EIO'))\n return;\n }\n // Throw anything else.\n if (_this.listeners('error').length < 2) {\n throw err;\n }\n });\n // Not available until `ready` event emitted.\n _this._pid = _this._agent.innerPid;"
],
[
" }\n });\n // Shutdown if `error` event is emitted.\n _this._socket.on('error', function (err) {\n // Close terminal session.\n _this._close();\n // EIO, happens when someone closes our child process: the only process\n // in the terminal.\n // node < 0.6.14: errno 5\n // node >= 0.6.14: read EIO\n if (err.code) {\n if (~err.code.indexOf('errno 5') || ~err.code.indexOf('EIO'))\n return;\n }\n // Throw anything else.\n if (_this.listeners('error').length < 2) {\n throw err;\n }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {",
" }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {"
],
[
' _this._readable = true;\n _this._writable = true;\n _this._forwardEvents();\n return _this;',
" _this._readable = true;\n _this._writable = true;\n // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes\n // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon.\n _this._agent.inSocket.on('error', function () {\n if (!_this._writable) {\n return;\n }\n _this._close();\n try {\n _this._agent.kill();\n }\n catch (_a) {\n // The failing pipe may have raced process exit; the terminal is already unwritable.\n }\n });\n _this._forwardEvents();\n return _this;"
],
[
'exports.WindowsTerminal = WindowsTerminal;\n//# sourceMappingURL=windowsTerminal.js.map',
'exports.WindowsTerminal = WindowsTerminal;\n//# sourceMappingURL=windowsTerminal.js.map\n'
]
]
}
]
function inspectTarget(relayDir, target) {
const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty')
const packageJson = JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8'))
if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) {
throw new Error(
`Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}`
)
}
const filePath = join(nodePtyDir, ...target.relativePath)
return { filePath, source: readFileSync(filePath, 'utf8') }
}
function assertPatchedNodePtyWindowsTeardown(relayDir = process.cwd()) {
for (const target of PATCH_TARGETS) {
const inspected = inspectTarget(relayDir, target)
if (sourceSha256(inspected.source) !== target.patchedSha256) {
throw new Error(
`node-pty ConPTY teardown release is not installed in ${target.relativePath.join('/')}`
)
}
}
}
function patchNodePtyWindowsTeardown(relayDir = process.cwd()) {
for (const target of PATCH_TARGETS) {
const inspected = inspectTarget(relayDir, target)
const sourceHash = sourceSha256(inspected.source)
if (sourceHash === target.patchedSha256) {
continue
}
if (sourceHash !== target.originalSha256) {
throw new Error(
`Refusing to patch unexpected node-pty source in ${target.relativePath.join('/')}`
)
}
let patchedSource = inspected.source
for (const [from, to] of target.replacements) {
// Why the count check: an anchor that matched twice would patch the wrong site silently, and
// the hash below would then reject a tree this script had already rewritten.
if (patchedSource.split(from).length - 1 !== 1) {
throw new Error(`Refusing to patch ${target.relativePath.join('/')}; anchor is not unique`)
}
patchedSource = patchedSource.replace(from, to)
}
const temporaryPath = `${inspected.filePath}.orca-patch-${process.pid}`
// Why: a terminated remote install must leave either known source version recoverable on reconnect.
try {
writeFileSync(temporaryPath, patchedSource)
renameSync(temporaryPath, inspected.filePath)
} finally {
rmSync(temporaryPath, { force: true })
}
}
assertPatchedNodePtyWindowsTeardown(relayDir)
}
function sourceSha256(source) {
return createHash('sha256').update(source).digest('hex')
}
if (require.main === module) {
patchNodePtyWindowsTeardown()
}
module.exports = {
assertPatchedNodePtyWindowsTeardown,
patchNodePtyWindowsTeardown
}
+11
View File
@@ -57,6 +57,13 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
'relay-assets',
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
const NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME = 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'
const NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE = join(
ROOT,
'config',
'relay-assets',
NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME
)
const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs'
const NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE = join(
ROOT,
@@ -132,6 +139,10 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
copyFileSync(
NODE_PTY_WINDOWS_TEARDOWN_PATCH_SOURCE,
join(outDir, NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME)
)
}
copyFileSync(
NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE,
@@ -1,14 +1,18 @@
import { readFileSync, readdirSync } from 'node:fs'
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join, relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const projectRoot = resolve(import.meta.dirname, '..', '..')
const electronBuilderConfig = require('../electron-builder.config.cjs')
const {
createPackagedRuntimeNodeModuleResources,
findAsarEntry,
isPackagedExternalSpecifier,
packageNameFromSpecifier,
prunePackagedNodePty,
prunePackagedParcelWatcher,
prunePackagedSherpaOnnx,
@@ -306,3 +310,91 @@ describe('packaged runtime resources', () => {
}
)
})
// Why source-anchored: the bundler renames a createRequire()'d require, so
// verifyPackagedMainRuntimeDeps' `require("x")` scan cannot see these specifiers — packaging
// stays green while the packaged app throws MODULE_NOT_FOUND the first time the path runs.
function collectLazyRequireSpecifiers(directory, found = new Map()) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) {
collectLazyRequireSpecifiers(entryPath, found)
continue
}
if (!entry.isFile() || !entry.name.endsWith('.ts') || entry.name.includes('.test.')) {
continue
}
const source = readFileSync(entryPath, 'utf8')
if (!source.includes('createRequire(')) {
continue
}
for (const match of source.matchAll(/\brequire[A-Za-z0-9_]*\(\s*'([^']+)'\s*\)/g)) {
if (isPackagedExternalSpecifier(match[1])) {
found.set(match[1], relative(projectRoot, entryPath).replaceAll('\\', '/'))
}
}
}
return found
}
function packagedResourceDestinations(platform) {
return new Set(
(electronBuilderConfig[platform].extraResources ?? []).map((resource) =>
String(resource.to).replaceAll('\\', '/')
)
)
}
describe('lazily required packages reach Resources/node_modules', () => {
it('copies every createRequire specifier main uses into the packaged resource plan', () => {
const specifiers = collectLazyRequireSpecifiers(join(projectRoot, 'src', 'main'))
expect(specifiers.size).toBeGreaterThan(0)
const destinations = {
win: packagedResourceDestinations('win'),
mac: packagedResourceDestinations('mac'),
linux: packagedResourceDestinations('linux')
}
for (const [specifier, source] of specifiers) {
const packageName = packageNameFromSpecifier(specifier)
const covered = (platform) =>
destinations[platform].has(`node_modules/${packageName}`) ||
destinations[platform].has(`node_modules/${specifier}`)
// Windows carries the full closure, so an uncovered specifier is uncovered everywhere.
expect(
covered('win'),
`${source} lazily requires '${specifier}', but nothing copies it to Resources/node_modules`
).toBe(true)
if (covered('mac') && covered('linux')) {
continue
}
// Only the Windows-native loaders may be absent from the mac/linux plans.
expect(source, `'${specifier}' is packaged for Windows only`).toContain('windows')
}
})
it('resolves the copied emoji dataset the way the packaged main bundle does', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-lazy-require-'))
try {
const datasetPath = 'node_modules/emojibase-data/en/shortcodes/emojibase.json'
const entry = electronBuilderConfig.mac.extraResources.find(
(resource) => String(resource.to) === datasetPath
)
expect(entry).toBeDefined()
const destination = join(resourcesDir, ...datasetPath.split('/'))
await mkdir(dirname(destination), { recursive: true })
await cp(join(projectRoot, ...String(entry.from).split('/')), destination)
// app.asar's parent is Resources, so main's bare require walks into Resources/node_modules.
const packagedMainDir = join(resourcesDir, 'app.asar', 'out', 'main')
await mkdir(packagedMainDir, { recursive: true })
const probe = join(packagedMainDir, 'probe.cjs')
await writeFile(probe, 'module.exports = require', 'utf8')
const dataset = require(probe)('emojibase-data/en/shortcodes/emojibase.json')
expect(Object.keys(dataset).length).toBeGreaterThan(1000)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
})
+1 -1
View File
@@ -492,7 +492,7 @@
"ko": "agent CLI를 찾지 못했습니다. 하나를 설치하거나 설정에서 기본 agent를 선택하세요."
},
"auto.components.Terminal.7958465754": {
"ko": "실행 중인 프로세스가 있는 로컬 terminals이 있습니다. 그래도 창을 닫으시겠습니까?"
"ko": "실행 중인 프로세스가 있는 terminals이 있습니다. 그래도 창을 닫으시겠습니까?"
},
"auto.components.Terminal.cdc9ac4b2d": {
"ko": "편집기"
@@ -0,0 +1,213 @@
// The relay's copy of the ConPTY teardown release, and the guard that keeps it in lockstep with the
// desktop's own node-pty patch. pnpm patches do not cross the SSH boundary, so a relay runs the tree
// `npm install` put there; the desktop had this fix and the relay did not, and every terminal on a
// Windows SSH host leaked one File handle for the life of the relay process.
//
// The ORDER of the conin release is the fix. Releasing it at the top of the branch -- what the
// desktop patch does -- was measured at 3x WORSE than shipping nothing (File +2/terminal and a new
// Process +1/terminal); releasing it after the console-list fork and the native kill is flat.
import { createRequire } from 'node:module'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
assertPatchedNodePtyWindowsTeardown,
patchNodePtyWindowsTeardown
} = require('../relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs')
const projectDir = resolve(import.meta.dirname, '..', '..')
const cleanupDirs = []
const PATCHED_FILES = ['windowsPtyAgent.js', 'windowsTerminal.js']
/** The hunks config/patches/node-pty@1.1.0.patch adds to the installed desktop tree. */
const DESKTOP_HUNKS = {
'windowsPtyAgent.js': [
[
[
' this._inSocket.readable = false;',
' // The non-DLL path previously only flipped `readable`, leaving the',
' // conin PipeWrap alive until the host exited (#947).',
' this._inSocket.destroy();',
' this._outSocket.readable = false;',
''
].join('\n'),
[
' this._inSocket.readable = false;',
' this._outSocket.readable = false;',
''
].join('\n')
],
// The useConptyDll branch, which only the DESKTOP runs -- the relay takes the
// non-DLL branch above, where the dispose is already unconditional. Listed here
// so un-applying still yields published; the relay asset needs no counterpart.
[
[
' // Orca: dispose unconditionally, as the non-DLL branch above does.',
" // Waiting for another 'data' event leaks the conout worker on every",
' // self-exiting shell, because no more data ever arrives (F24).',
' this._conoutSocketWorker.dispose();',
''
].join('\n'),
[
" this._outSocket.on('data', function () {",
' _this._conoutSocketWorker.dispose();',
' });',
''
].join('\n')
]
],
'windowsTerminal.js': [
[
' // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.',
null
],
[' // A ConPTY input-pipe error must retire only this terminal.', null]
]
}
function desktopPath(file) {
return join(projectDir, 'node_modules', 'node-pty', 'lib', file)
}
afterEach(() => {
for (const dir of cleanupDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
describe('Windows SSH relay node-pty ConPTY teardown patch', () => {
// Why reconstruct rather than vendor upstream: the installed tree IS the published file plus the
// desktop's hunks, so un-applying them yields upstream exactly -- and pinning that against this
// asset's own hashes is what fails loudly if either side of the pair moves.
it('takes the desktop error listeners verbatim', () => {
const fixture = writeNodePtyFixture('1.1.0')
patchNodePtyWindowsTeardown(fixture.root)
expect(readFileSync(join(fixture.libDir, 'windowsTerminal.js'), 'utf8')).toBe(
readFileSync(desktopPath('windowsTerminal.js'), 'utf8')
)
})
// The one hunk that must NOT match the desktop, and the reason is measured, not stylistic:
// releasing conin before `_getConsoleProcessList()` forks aborts teardown partway.
it('releases conin after the console-list fork, not before it like the desktop patch', () => {
const fixture = writeNodePtyFixture('1.1.0')
patchNodePtyWindowsTeardown(fixture.root)
const patched = readFileSync(join(fixture.libDir, 'windowsPtyAgent.js'), 'utf8')
const branch = patched.slice(
patched.indexOf('if (!this._useConptyDll) {'),
patched.indexOf('else {', patched.indexOf('if (!this._useConptyDll) {'))
)
expect(branch).toContain('this._inSocket.destroy();')
expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan(
branch.indexOf('this._conoutSocketWorker.dispose();')
)
expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan(
branch.indexOf('this._getConsoleProcessList()')
)
// Pinned so a future "sync the relay asset to config/patches" cannot copy the regression back.
expect(patched).not.toBe(readFileSync(desktopPath('windowsPtyAgent.js'), 'utf8'))
})
it('installs and verifies idempotently', () => {
const fixture = writeNodePtyFixture('1.1.0')
patchNodePtyWindowsTeardown(fixture.root)
const once = PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))
for (const file of PATCHED_FILES) {
expect(existsSync(`${join(fixture.libDir, file)}.orca-patch-${process.pid}`)).toBe(false)
}
expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow()
patchNodePtyWindowsTeardown(fixture.root)
expect(PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))).toEqual(
once
)
})
it('refuses a different package version or unexpected source', () => {
const wrongVersion = writeNodePtyFixture('1.2.0-beta.11')
expect(() => patchNodePtyWindowsTeardown(wrongVersion.root)).toThrow('expected 1.1.0')
for (const file of PATCHED_FILES) {
const drifted = writeNodePtyFixture('1.1.0')
const path = join(drifted.libDir, file)
writeFileSync(path, `${readFileSync(path, 'utf8')}\n// drift`)
expect(() => patchNodePtyWindowsTeardown(drifted.root)).toThrow('unexpected node-pty')
}
})
it('refuses a half-applied tree, so one file cannot pass for both', () => {
for (const file of PATCHED_FILES) {
const partial = writeNodePtyFixture('1.1.0')
const fixture = writeNodePtyFixture('1.1.0')
patchNodePtyWindowsTeardown(fixture.root)
writeFileSync(join(partial.libDir, file), readFileSync(join(fixture.libDir, file), 'utf8'))
expect(() => assertPatchedNodePtyWindowsTeardown(partial.root)).toThrow('is not installed')
}
})
})
/** A published node-pty tree, rebuilt by un-applying the desktop hunks from the installed one. */
function writeNodePtyFixture(version) {
const root = mkdtempSync(join(projectDir, '.node-pty-teardown-patch-test-'))
cleanupDirs.push(root)
const libDir = join(root, 'node_modules', 'node-pty', 'lib')
mkdirSync(libDir, { recursive: true })
writeFileSync(join(root, 'node_modules', 'node-pty', 'package.json'), JSON.stringify({ version }))
for (const file of PATCHED_FILES) {
const desktop = readFileSync(desktopPath(file), 'utf8')
for (const [marker] of DESKTOP_HUNKS[file]) {
expect(desktop).toContain(marker)
}
writeFileSync(join(libDir, file), unapplyDesktopHunks(file, desktop))
}
return { root, libDir }
}
/**
* Reverse of the published-to-desktop transform.
*
* `windowsTerminal.js` is taken verbatim from the desktop, so the asset's own replacement table is
* the transform and reversing it is exact. `windowsPtyAgent.js` deliberately diverges, so its
* published form is rebuilt from the desktop hunk instead -- which is also what makes this file the
* place that notices if the desktop hunk itself ever moves.
*/
function unapplyDesktopHunks(file, desktop) {
if (file === 'windowsPtyAgent.js') {
let published = desktop
for (const [patched, original] of DESKTOP_HUNKS[file]) {
expect(published.split(patched).length - 1).toBe(1)
published = published.replace(patched, original)
}
return published
}
const asset = readFileSync(
join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'),
'utf8'
)
const { PATCH_TARGETS } = loadPatchTargets(asset)
const target = PATCH_TARGETS.find((entry) => entry.relativePath.at(-1) === file)
expect(target).toBeDefined()
let published = desktop
for (const [from, to] of target.replacements.toReversed()) {
expect(published.split(to).length - 1).toBe(1)
published = published.replace(to, from)
}
return published
}
function loadPatchTargets(assetSource) {
const module = { exports: {} }
const factory = new Function(
'module',
'exports',
'require',
`${assetSource}\nmodule.exports.PATCH_TARGETS = PATCH_TARGETS`
)
factory(module, module.exports, require)
return module.exports
}
+1
View File
@@ -228,6 +228,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',
@@ -0,0 +1,39 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const skillsDir = resolve(import.meta.dirname, '../../skills')
// Why: the Agent Skills spec caps `description` at 1024 chars and conforming installers
// reject the whole skill (#17935); the frontmatter is what the installer parses, so check it.
const MAX_DESCRIPTION_LENGTH = 1024
function readDescription(skillName) {
const skillMarkdown = readFileSync(join(skillsDir, skillName, 'SKILL.md'), 'utf8')
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/u.exec(skillMarkdown)?.[1]
expect(frontmatter, `${skillName}: missing frontmatter`).toBeDefined()
return parse(frontmatter ?? '').description
}
describe('bundled skill descriptions', () => {
const skillNames = readdirSync(skillsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
it('discovers the bundled skills', () => {
expect(skillNames).toContain('orchestration')
})
it.each(skillNames)('%s keeps description within the Agent Skills spec limit', (name) => {
const description = readDescription(name)
expect(typeof description, `${name}: description must be a string`).toBe('string')
expect(description.trim().length, `${name}: description is empty`).toBeGreaterThan(0)
expect(
description.length,
`${name}: description is ${description.length} chars`
).toBeLessThanOrEqual(MAX_DESCRIPTION_LENGTH)
})
})
+1
View File
@@ -19,6 +19,7 @@
"../src/preload/usage-provider-api.ts",
"../src/shared/**/*",
"../src/main/gitlab/mappers.ts",
"../src/main/ipc/deferred-emoji-shortcode-dataset.ts",
"../src/main/ipc/worktree-branch-name.ts",
"../src/main/ipc/worktree-logic.ts",
"../src/main/ipc/worktree-display-name.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 38m">
<title>downloads: 38m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 39m">
<title>downloads: 39m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">38m</text>
<text x="90" y="14">38m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">39m</text>
<text x="90" y="14">39m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

+1 -1
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a> · <a href="README.uk.md">Українська</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.uk.md">Українська</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
@@ -243,9 +243,9 @@ Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votr
- **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces.
- **WeChat :** Scannez pour rejoindre le groupe WeChat 8 de la communauté Orca.
- **WeChat :** Scannez pour rejoindre le groupe WeChat 8 de la communauté Orca. Le groupe 8 est peut-être complet ; dans ce cas, scannez plutôt le QR code du groupe 9.
<img src="../assets/wechat-qr-group8.jpg" alt="QR code WeChat groupe 8 de la communauté Orca" width="160" />
<img src="../assets/wechat-qr-group8.jpg" alt="QR code WeChat groupe 8 de la communauté Orca" width="160" />&nbsp;&nbsp;<img src="../assets/wechat-qr-group9.jpg" alt="QR code WeChat groupe 9 de la communauté Orca" width="160" />
- **Feedback &amp; idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
- **Confidentialité :** Voir la [doc confidentialité &amp; télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie.
+1 -1
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a> · <a href="README.uk.md">Українська</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a> · <a href="README.uk.md">Українська</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
@@ -238,9 +238,9 @@ yay -S stably-orca-bin
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 8에 참여하세요.
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 8에 참여하세요. 그룹 8이 가득 찼을 수 있으니, 그런 경우 그룹 9 QR 코드를 스캔하세요.
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 커뮤니티 WeChat 그룹 8 QR 코드" width="160" />
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 커뮤니티 WeChat 그룹 8 QR 코드" width="160" />&nbsp;&nbsp;<img src="../assets/wechat-qr-group9.jpg" alt="Orca 커뮤니티 WeChat 그룹 9 QR 코드" width="160" />
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
+1 -1
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.uk.md">Українська</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a></sub>
</p>
<p align="center">
-269
View File
@@ -1,269 +0,0 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&amp;label=%E2%98%85&amp;color=08C" alt="Зірки на GitHub" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Загальна кількість завантажень усіх релізів" /></a>
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="Ліцензія: MIT" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Приєднатися до Discord Orca" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Стежити за Orca в X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Підтримувані платформи: macOS, Windows і Linux" />
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
<strong>AI-оркестратор для розробників рівня 100x.</strong><br/>
Запускайте Codex, Claude Code, OpenCode або Pi паралельно — кожен у власному worktree, усі під контролем в одному місці.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Завантажити Orca</ins></a></h3>
<p align="center">
<img src="../assets/readme-hero.jpg" alt="Десктопний застосунок Orca запускає агентів у паралельних worktree, у кутку — супутній мобільний застосунок Orca" width="960" />
</p>
## Можливості
<table>
<tr>
<td width="50%" valign="middle">
### Супутній мобільний застосунок
Стежте за агентами та керуйте ними з телефону — отримуйте сповіщення про завершення роботи агента та надсилайте подальші вказівки, де б ви не були.
[App Store для iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Документація →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Десктоп Orca із супутнім мобільним застосунком" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Паралельні worktree
Надішліть один промпт одразу п’ятьом агентам, кожен із яких працюватиме у власному ізольованому git worktree, — порівняйте результати та виконайте злиття найкращого з них.
[Документація →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Оркестрація паралельних worktree" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Розділені термінали
Термінали рівня Ghostty з рендерингом на WebGL, необмеженою кількістю розділень і буфером прокручування, який зберігається після перезапуску.
[Документація →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Розділені термінали" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Режим дизайну
Клацніть на будь-якому елементі інтерфейсу у справжньому вікні Chromium, щоб надіслати його HTML, CSS і обрізаний скриншот прямо в промпт агента.
[Документація →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Вбудований браузер і режим дизайну" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub і Linear, нативно
Переглядайте PR, issue та дошки проєктів прямо в застосунку — відкривайте worktree з будь-якої задачі та рев'юйте без перемикання контексту.
[Документація →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Робочі процеси GitHub і Linear в Orca" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### SSH worktree
Запускайте агентів на потужній віддаленій машині з повноцінним редагуванням файлів, git і терміналами — з автоперепідключенням і прокиданням портів.
[Документація →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Віддалені worktree через SSH" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Анотуйте diff-и агентів
Залишайте коментарі на будь-якому рядку diff-у й надсилайте їх агенту — рев'юйте, редагуйте та комітьте, не виходячи з Orca.
[Документація →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Анотування diff-ів, згенерованих AI" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Перетягуйте файли агентам
Редактор на базі VS Code з автозбереженням усюди — перетягуйте файли чи зображення прямо в промпт агента.
[Документація →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Перетягування файлів і зображень у промпт агента" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
Агенти теж керують Orca — автоматизуйте будь-який робочий процес командами `orca worktree create`, `snapshot`, `click` і `fill`.
[Документація →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Керування Orca з CLI" width="100%" /></picture></a>
</td>
</tr>
</table>
**Також у комплекті:**
- **[Швидкий пошук](https://www.onorca.dev/docs/model/quick-open)** — Шукайте серед worktree, файлів, агентів, команд і контексту репозиторію, не відриваючись від роботи.
- **[Перемикач акаунтів і відстеження використання](https://www.onorca.dev/docs/agents/usage-tracking)** — Стежте за використанням Claude і Codex та скиданням лімітів, перемикайте акаунти на льоту без повторного входу.
- **[Розширені перегляди репозиторію](https://www.onorca.dev/docs/editing/markdown)** — Переглядайте Markdown, зображення, PDF та документацію репозиторію прямо в робочому просторі.
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Дозвольте агентам керувати десктопними застосунками та видимим інтерфейсом, коли робочий процес потребує реальної взаємодії.
- **[Сповіщення та статус непрочитаного](https://www.onorca.dev/docs/notifications)** — Дізнавайтеся, коли агент завершив роботу або потребує уваги, і позначайте треди як непрочитані, щоб повернутися пізніше.
- **І багато іншого** — ми випускаємо оновлення щодня, тож цей список завжди відстає. Справжній перелік можливостей — це [changelog](https://github.com/stablyai/orca/releases).
---
## Підтримувані агенти
Працює з **будь-яким CLI-агентом** — якщо він запускається в терміналі, він запуститься і в Orca.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Devin logo" width="16" valign="middle" /> Devin</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ будь-який CLI-агент</kbd>
</p>
---
## Встановлення
### Десктоп — macOS, Windows, Linux
- **[Завантажити з onOrca.dev](https://onorca.dev/download)**
- Або завантажте білд напряму: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Усі білди](https://github.com/stablyai/orca/releases/latest)
- Запускаєте `orca serve` на headless Linux-сервері? Дивіться [посібник із headless Linux-сервера](../reference/headless-linux-server.md).
_Або через пакетний менеджер:_
```bash
# macOS (Homebrew)
brew install --cask stablyai/orca/orca
# Arch Linux (AUR) — або stably-orca-git для збірки з джерела
yay -S stably-orca-bin
```
### Супутній мобільний застосунок — iOS, Android
Під’єднайте мобільний застосунок до десктопного, щоб стежити за агентами та керувати ними з телефону.
- **iOS:** [Завантажити з App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) або [приєднатися до TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Завантажити APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Інструкція зі встановлення](https://www.onorca.dev/docs/android-apk)
---
## Спільнота та підтримка
- **Discord:** Приєднуйтеся до спільноти в **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Стежте за **[@orca_build](https://x.com/orca_build)**, щоб бути в курсі оновлень і анонсів.
- **WeChat:** Відскануйте QR-код, щоб приєднатися до групи № 7 спільноти Orca у WeChat. Якщо вона заповнена, приєднайтеся до групи № 8.
<img src="../assets/wechat-qr-group7.jpg" alt="QR-код групи WeChat 7 спільноти Orca" width="160" />&nbsp;&nbsp;
<img src="../assets/wechat-qr-group8.jpg" alt="QR-код групи WeChat 8 спільноти Orca" width="160" />
- **Зворотний зв'язок та ідеї:** Ми випускаємо оновлення швидко. Чогось бракує? [Запропонуйте нову функцію](https://github.com/stablyai/orca/issues).
- **Конфіденційність:** Перегляньте [документацію про конфіденційність і телеметрію](https://www.onorca.dev/docs/telemetry), щоб дізнатися, які анонімні дані про використання збирає Orca і як від цього відмовитися.
- **Підтримайте нас:** Поставте [зірку](https://github.com/stablyai/orca) цьому репозиторію, щоб стежити за нашими щоденними релізами.
---
## Розробка
Хочете зробити внесок або запустити проєкт локально? Перегляньте наш посібник [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Контриб'ютори Orca" />
</a>
<p align="center">
<img src="../assets/star-history.png" alt="Графік історії зірок на GitHub для stablyai/orca" width="880" />
</p>
## Підписані білди
Підписання коду для Windows надано за підтримки [SignPath.io](https://signpath.io), сертифікат надано [SignPath Foundation](https://signpath.org).
## Ліцензія
Orca — безкоштовний проєкт із відкритим кодом за ліцензією [MIT](../../LICENSE).
+3 -3
View File
@@ -12,7 +12,7 @@
</p>
<p align="center">
<sub><a href="../../README.md">English</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a> · <a href="README.uk.md">Українська</a></sub>
<sub><a href="../../README.md">English</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a> · <a href="README.fr.md">Français</a> · <a href="README.pt.md">Português</a></sub>
</p>
<p align="center">
@@ -235,9 +235,9 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **微信:** 扫码加入 Orca 社区微信第 8 群。
- **微信:** 扫码加入 Orca 社区微信第 8 群。第 8 群可能已满,如遇这种情况请扫描第 9 群二维码。
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 社区微信第 8 群二维码" width="160" />
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 社区微信第 8 群二维码" width="160" />&nbsp;&nbsp;<img src="../assets/wechat-qr-group9.jpg" alt="Orca 社区微信第 9 群二维码" width="160" />
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
+6 -1
View File
@@ -19,6 +19,7 @@ type MobileHomeHostListProps = {
hostAttempts: Record<string, number>
hostLastConnected: Record<string, number | null>
hostPairingRejected: Record<string, boolean>
hostSignedOut: Record<string, boolean>
hostPaths: Record<string, MobileConnectionPath>
hostPendingPaths: Record<string, MobileConnectionPath | null>
hosts: HostCatalogEntry[]
@@ -40,6 +41,7 @@ export function MobileHomeHostList(props: MobileHomeHostListProps) {
hostAttempts={props.hostAttempts}
hostLastConnected={props.hostLastConnected}
hostPairingRejected={props.hostPairingRejected}
hostSignedOut={props.hostSignedOut}
hostPaths={props.hostPaths}
hostPendingPaths={props.hostPendingPaths}
hostStates={props.hostStates}
@@ -54,6 +56,7 @@ export function MobileHomeHostList(props: MobileHomeHostListProps) {
props.hostAttempts,
props.hostLastConnected,
props.hostPairingRejected,
props.hostSignedOut,
props.hostPaths,
props.hostPendingPaths,
props.hostStates,
@@ -91,6 +94,7 @@ type MobileHomeHostRowProps = Pick<
| 'hostAttempts'
| 'hostLastConnected'
| 'hostPairingRejected'
| 'hostSignedOut'
| 'hostPaths'
| 'hostPendingPaths'
| 'hostStates'
@@ -113,7 +117,8 @@ const MobileHomeHostRow = memo(function MobileHomeHostRow(props: MobileHomeHostR
lastConnectedAt: props.hostLastConnected[item.id] ?? null,
endpoint: item.endpoint,
pendingPath: props.hostPendingPaths[item.id] ?? null,
pairingRejected: props.hostPairingRejected[item.id] ?? false
pairingRejected: props.hostPairingRejected[item.id] ?? false,
hostSignedOut: props.hostSignedOut[item.id] ?? false
})
const open = useCallback(() => onOpen(item), [item, onOpen])
const longPress = useCallback(() => onLongPress(item), [item, onLongPress])
+1
View File
@@ -143,6 +143,7 @@ export function MobileHomeScreen() {
hostAttempts={data.hostAttempts}
hostLastConnected={data.hostLastConnected}
hostPairingRejected={data.hostPairingRejected}
hostSignedOut={data.hostSignedOut}
hostPaths={data.hostPaths}
hostPendingPaths={data.hostPendingPaths}
hosts={data.sortedHostCatalog}
@@ -5,12 +5,14 @@ export type HomeHostConnectionProjectionEntry = {
path: MobileConnectionPath
pendingPath: MobileConnectionPath | null
pairingRejected: boolean
hostSignedOut: boolean
}
export type HomeHostConnectionProjection = {
hostPaths: Record<string, MobileConnectionPath>
hostPendingPaths: Record<string, MobileConnectionPath | null>
hostPairingRejected: Record<string, boolean>
hostSignedOut: Record<string, boolean>
}
/** Build all host lookup maps while reading each connection entry once. */
@@ -22,16 +24,19 @@ export function projectHomeHostConnections(
const hostPaths = Object.create(null) as Record<string, MobileConnectionPath>
const hostPendingPaths = Object.create(null) as Record<string, MobileConnectionPath | null>
const hostPairingRejected = Object.create(null) as Record<string, boolean>
const hostSignedOut = Object.create(null) as Record<string, boolean>
for (const { hostId, path, pendingPath, pairingRejected } of entries) {
for (const { hostId, path, pendingPath, pairingRejected, hostSignedOut: signedOut } of entries) {
hostPaths[hostId] = path
hostPendingPaths[hostId] = pendingPath
hostPairingRejected[hostId] = pairingRejected
hostSignedOut[hostId] = signedOut
}
Object.setPrototypeOf(hostPaths, Object.prototype)
Object.setPrototypeOf(hostPendingPaths, Object.prototype)
Object.setPrototypeOf(hostPairingRejected, Object.prototype)
Object.setPrototypeOf(hostSignedOut, Object.prototype)
return { hostPaths, hostPendingPaths, hostPairingRejected }
return { hostPaths, hostPendingPaths, hostPairingRejected, hostSignedOut }
}
+1
View File
@@ -179,6 +179,7 @@ export function useMobileHomeData() {
connectedHosts,
hostCatalog,
hostPairingRejected: hostConnectionProjection.hostPairingRejected,
hostSignedOut: hostConnectionProjection.hostSignedOut,
hostPaths: hostConnectionProjection.hostPaths,
hostPendingPaths: hostConnectionProjection.hostPendingPaths,
primaryHost,
@@ -50,7 +50,9 @@ describe('attachMobileImageToTerminal', () => {
const sendCall = client.calls.find((c) => c.method === 'terminal.send')
expect(sendCall?.params).toEqual({
terminal: 'term-1',
text: '\x1b[200~/tmp/orca-attach.png\x1b[201~',
// Trailing space: the user types on this same line next, so a bare
// `…\x1b[201~` would arrive as `…pngadd` (STA-4847).
text: '\x1b[200~/tmp/orca-attach.png\x1b[201~ ',
enter: false,
client: { id: 'device-9', type: 'mobile' }
})
@@ -1,4 +1,5 @@
import type { RpcClient } from '../transport/rpc-client'
import { separateImagePasteFromFollowingText } from '../../../src/shared/image-paste-following-text'
import {
buildMobileImagePastePayload,
saveMobileClipboardImageAsTempFile
@@ -47,7 +48,10 @@ export async function attachMobileImageToTerminal(
})
// Why: a generated image path is terminal image injection, so it's always
// bracketed (matching desktop paste) regardless of terminal mode.
const payload = buildMobileImagePastePayload(imagePath)
// Always separated: attach-then-type is the whole interaction here, so the user's
// next keystroke would otherwise glue onto the path (`…pngadd`). Unlike native
// chat there is no batch to look ahead in, and a trailing space is inert.
const payload = separateImagePasteFromFollowingText(buildMobileImagePastePayload(imagePath), true)
if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) {
return false
}
@@ -38,7 +38,8 @@ describe('pasteMobileNativeChatImagePaths', () => {
client,
terminal: 'term-1',
deviceToken: 'device-9',
imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png']
imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'],
followedByText: true
})
expect(ok).toBe(true)
@@ -55,7 +56,7 @@ describe('pasteMobileNativeChatImagePaths', () => {
})
expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~')
expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~')
expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~ ')
})
it('stops and reports failure as soon as a paste is rejected', async () => {
@@ -66,7 +67,8 @@ describe('pasteMobileNativeChatImagePaths', () => {
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png']
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
followedByText: true
})
expect(ok).toBe(false)
@@ -94,7 +96,8 @@ describe('pasteMobileNativeChatImagePaths', () => {
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png']
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
followedByText: true
})
expect(ok).toBe(false)
@@ -121,6 +124,7 @@ describe('clearing a parked multi-line launch draft before the image paste', ()
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png'],
followedByText: true,
clearInput
})
@@ -137,6 +141,7 @@ describe('clearing a parked multi-line launch draft before the image paste', ()
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
followedByText: true,
clearInput
})
@@ -151,9 +156,27 @@ describe('clearing a parked multi-line launch draft before the image paste', ()
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png']
imagePaths: ['/tmp/a.png'],
followedByText: true
})
expect(client.calls[0]?.params.text).toBe('\x15')
})
it('keeps image writes byte-clean when no text or submit follows', async () => {
const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
await pasteMobileNativeChatImagePaths({
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
followedByText: false
})
expect(client.calls.slice(1).map((call) => call.params.text)).toEqual([
'\x1b[200~/tmp/a.png\x1b[201~',
'\x1b[200~/tmp/b.png\x1b[201~'
])
})
})
@@ -1,4 +1,5 @@
import type { RpcClient } from '../transport/rpc-client'
import { imagePasteWritesFollowedByText } from '../../../src/shared/image-paste-following-text'
import { buildMobileImagePastePayload } from './mobile-clipboard-image'
import {
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS,
@@ -23,6 +24,7 @@ type PasteImagesArgs = {
readonly terminal: string
readonly deviceToken: string | null
readonly imagePaths: readonly string[]
readonly followedByText: boolean
/** Budget shared with the rest of the user action (the text body that follows, or
* the send this is healing for). Omit to open a fresh one for this paste alone. */
readonly deadline?: number
@@ -42,6 +44,7 @@ export async function pasteMobileNativeChatImagePaths({
terminal,
deviceToken,
imagePaths,
followedByText,
deadline: sharedDeadline,
clearInput
}: PasteImagesArgs): Promise<boolean> {
@@ -55,7 +58,7 @@ export async function pasteMobileNativeChatImagePaths({
const deadline = sharedDeadline ?? openMobileNativeChatSendBudget()
for (const text of [
clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
...imagePaths.map(buildMobileImagePastePayload)
...imagePasteWritesFollowedByText(imagePaths.map(buildMobileImagePastePayload), followedByText)
]) {
const remainingMs = deadline - Date.now()
// Why: the budget is the whole sequence's — starting a write it can't fund would
@@ -55,6 +55,7 @@ export async function healMobileNativeChatStaleInput(args: {
terminal: args.terminal,
deviceToken: args.deviceToken,
imagePaths: [],
followedByText: false,
...(args.deadline === undefined ? {} : { deadline: args.deadline })
})
} catch {
@@ -182,9 +182,12 @@ describe('useMobileNativeChatImageAttachments', () => {
expect(sendCalls).toHaveLength(2)
expect(sendCalls[0]?.params).toMatchObject({ text: '\x15', enter: false })
expect(sendCalls[1]?.params).toMatchObject({
text: '\x1b[200~/tmp/a.png\x1b[201~',
text: '\x1b[200~/tmp/a.png\x1b[201~ ',
enter: false
})
const combined = String(sendCalls[1]?.params.text ?? '') + 'look at this'
expect(combined).toContain('.png\x1b[201~ look')
expect(combined).not.toContain('.png\x1b[201~look')
// Clear, then paste, then settle, then the text send — in that order.
expect(order).toEqual(['clear', 'paste', 'settle', 'text:look at this'])
// The local preview URI rides along so the sent bubble shows the photo.
@@ -267,7 +270,10 @@ describe('useMobileNativeChatImageAttachments', () => {
}
})
it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => {
it.each([
['empty', ''],
['whitespace-only', ' ']
])('routes an attachments-only send through baseSend with %s text', async (_label, text) => {
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
@@ -283,16 +289,17 @@ describe('useMobileNativeChatImageAttachments', () => {
})
let accepted = false
await act(async () => {
accepted = await hook!.sendNativeChat('')
accepted = await hook!.sendNativeChat(text)
})
expect(accepted).toBe(true)
// Empty text still goes through baseSend (which submits the bare Enter) so the
// Attachment-only text still goes through baseSend (which submits Enter) so the
// optimistic echo carries the preview URI.
expect(baseSend).toHaveBeenCalledWith('', ['file:///a.jpg'], expect.any(Number))
expect(baseSend).toHaveBeenCalledWith(text, ['file:///a.jpg'], expect.any(Number))
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
// Only the clear + image paste hit the wire here; baseSend owns the submit.
expect(sendCalls).toHaveLength(2)
expect(sendCalls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
expect(hook!.attachments).toEqual([])
})
@@ -240,6 +240,7 @@ export function useMobileNativeChatImageAttachments({
terminal: handle,
deviceToken: deviceTokenRef.current,
imagePaths: pendingImages.map((attachment) => attachment.path),
followedByText: text.trim().length > 0,
deadline,
...(seededLaunchDraft
? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) }
@@ -30,14 +30,16 @@ export function useConnectionPathStatus(hostId: string | undefined): {
export function useRelayRecoveryStatus(hostId: string | undefined): {
pendingPath: MobileConnectionPath | null
pairingRejected: boolean
hostSignedOut: boolean
} {
return useHostMetric(
hostId,
(context, id) => ({
pendingPath: context.getPendingPath(id),
pairingRejected: context.isPairingRejected(id)
pairingRejected: context.isPairingRejected(id),
hostSignedOut: context.isHostSignedOut(id)
}),
{ pendingPath: null, pairingRejected: false }
{ pendingPath: null, pairingRejected: false, hostSignedOut: false }
)
}
+2 -2
View File
@@ -533,12 +533,12 @@ describe('useAllHostClients', () => {
await Promise.resolve()
})
act(() => client.emitPendingPath('relay'))
expect(status).toEqual({ pendingPath: 'relay', pairingRejected: false })
expect(status).toEqual({ pendingPath: 'relay', pairingRejected: false, hostSignedOut: false })
// Why: the desktop refusing the credential is a status-only change — no
// transport state moves, so only the connection-path signal can carry it.
act(() => client.emitPairingRejected(true))
expect(status).toEqual({ pendingPath: 'relay', pairingRejected: true })
expect(status).toEqual({ pendingPath: 'relay', pairingRejected: true, hostSignedOut: false })
act(() => renderer.unmount())
})
+19
View File
@@ -29,6 +29,10 @@ const STALE_SINCE_LAST_CONNECT_MS = 60_000
// instead of leaving the user staring at a generic "Can't connect".
const TAILSCALE_HINT = 'check Tailscale'
// No hint field: the remedy is the label, and appending "— check Tailscale" to
// it would be wrong advice for a desktop that is reachable but signed out.
const SIGNED_OUT_LABEL = 'Desktop signed out — sign in to Orca on your desktop to reconnect'
export type ConnectionVerdict =
| { kind: 'normal'; label: string }
| { kind: 'warning'; label: string; hint?: string } // "Can't connect"
@@ -54,6 +58,10 @@ export function classifyConnection(args: {
// The desktop has repeatedly refused this device's relay credential — retrying
// cannot fix it, so it outranks any "still connecting" reading (STA-4681).
pairingRejected?: boolean
// The relay says the desktop's last control close named its own Orca Cloud
// sign-out. Retrying is still correct and still happens on the same cadence,
// but only the desktop's owner can end it, so the label has to say so.
hostSignedOut?: boolean
nowMs?: number
}): ConnectionVerdict {
const { state, reconnectAttempts, lastConnectedAt } = args
@@ -70,6 +78,17 @@ export function classifyConnection(args: {
return { kind: 'normal', label: 'Connected' }
}
// Ahead of the attempt thresholds: this is evidence, not an inference from a
// failure streak, and waiting twelve dials to show it wastes the whole point.
// Below auth-failed because a revoked pairing cannot be fixed by signing in.
if (args.hostSignedOut) {
return {
kind: 'unreachable',
label: SIGNED_OUT_LABEL,
reason: lastConnectedAt == null ? 'never-connected' : 'stale'
}
}
// A disconnected pending path can survive a cleared retry timer during a
// lifecycle race. Only narrate Relay while dialing or after a retry has
// recorded progress; otherwise the idle transport must read Disconnected.
@@ -89,10 +89,16 @@ export function createHostClientSelectors(
getPendingPath: (hostId: string): MobileConnectionPath | null =>
clientPendingPath(entries.get(hostId)?.client),
isPairingRejected: (hostId: string): boolean =>
clientPairingRejected(entries.get(hostId)?.client)
clientPairingRejected(entries.get(hostId)?.client),
isHostSignedOut: (hostId: string): boolean => clientHostSignedOut(entries.get(hostId)?.client)
}
}
export function clientHostSignedOut(client: RpcClient | undefined): boolean {
const logical = client as Partial<StableLogicalRpcClient> | undefined
return logical?.isHostSignedOut?.() ?? false
}
export function clientPairingRejected(client: RpcClient | undefined): boolean {
const logical = client as Partial<StableLogicalRpcClient> | undefined
return logical?.isPairingRejected?.() ?? false
@@ -5,6 +5,7 @@ export class LogicalClientConnectionPath {
private recovery: MobileConnectionPath | null = null
private recoveryAttempt = 0
private pairingRejected = false
private hostSignedOut = false
private readonly listeners = new Set<() => void>()
constructor(private readonly isConnected: () => boolean) {}
@@ -35,12 +36,23 @@ export class LogicalClientConnectionPath {
})
}
isHostSignedOut(): boolean {
return this.hostSignedOut
}
setHostSignedOut(signedOut: boolean): void {
this.update(() => {
this.hostSignedOut = signedOut
})
}
clearAfterConnected(): void {
this.migration = null
this.recovery = null
this.recoveryAttempt = 0
// Why: an authenticated session is the desktop accepting this device.
this.pairingRejected = false
this.hostSignedOut = false
}
setRecovery(path: MobileConnectionPath | null, attempt?: number): void {
@@ -69,11 +81,13 @@ export class LogicalClientConnectionPath {
const previousPath = this.pending()
const previousAttempt = this.reconnectAttempt(0)
const previousRejected = this.pairingRejected
const previousSignedOut = this.hostSignedOut
apply()
if (
previousPath === this.pending() &&
previousAttempt === this.reconnectAttempt(0) &&
previousRejected === this.pairingRejected
previousRejected === this.pairingRejected &&
previousSignedOut === this.hostSignedOut
) {
return
}
@@ -86,7 +86,7 @@ function createSupervisor(
): MobileEndpointSupervisor {
return new MobileEndpointSupervisor(logical, host, {
openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
openRelay: (relay, credential, confirmReqId) =>
openRelay: (relay, credential, confirmReqId, onHostCloseReason) =>
connectMobileRelayRpcSession({
relay,
resumeToken: credential.token,
@@ -94,6 +94,7 @@ function createSupervisor(
resumeConfirmReqId: confirmReqId,
deviceToken: host.deviceToken,
desktopPublicKeyB64: host.publicKeyB64,
onHostCloseReason,
onLog
}),
resolveRelay: resolveMobileRelayEndpoint,
@@ -1,4 +1,5 @@
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import type { RelayHostCloseReason } from '../../../src/shared/relay-host-close-reason'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
@@ -10,7 +11,8 @@ export type MobileEndpointSupervisorDependencies = {
openRelay: (
relay: MobileRelayEndpoint,
credential: { token: string; version: number },
confirmReqId: string
confirmReqId: string,
onHostCloseReason?: (reason: RelayHostCloseReason) => void
) => MobileRelayRpcSession
resolveRelay: typeof resolveMobileRelayEndpoint
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
@@ -134,10 +134,22 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl
}
})
isPairingRejected = () => this.pairingRejected
private hostSignedOut = false
setHostSignedOut = vi.fn((signedOut: boolean) => {
if (this.hostSignedOut === signedOut) {
return
}
this.hostSignedOut = signedOut
for (const listener of this.pathListeners) {
listener()
}
})
isHostSignedOut = () => this.hostSignedOut
// Mirrors LogicalClientConnectionPath.clearAfterConnected.
publishState(state: ConnectionState): void {
if (state === 'connected') {
this.pairingRejected = false
this.hostSignedOut = false
}
super.publishState(state)
}
@@ -185,7 +185,12 @@ describe('mobile endpoint supervisor', () => {
await supervisor.start()
expect(deps.resolveRelay).toHaveBeenCalledOnce()
expect(openRelay).toHaveBeenLastCalledWith(resolved, expect.any(Object), expect.any(String))
expect(openRelay).toHaveBeenLastCalledWith(
resolved,
expect.any(Object),
expect.any(String),
expect.any(Function)
)
expect(deps.saveHost).toHaveBeenCalledWith(
expect.objectContaining({ relay: resolved, endpoint: host.endpoint })
)
@@ -556,7 +561,8 @@ describe('mobile endpoint supervisor', () => {
expect(openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 3 }),
expect.any(String)
expect.any(String),
expect.any(Function)
)
supervisor.stop()
})
@@ -603,7 +609,8 @@ describe('mobile endpoint supervisor', () => {
expect(openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 3 }),
expect.any(String)
expect.any(String),
expect.any(Function)
)
supervisor.stop()
})
@@ -2,6 +2,10 @@ import {
RelayPhoneHelloSchema,
type RelayPhoneHello
} from '../../../src/shared/mobile-relay-phone-protocol'
import {
relayHostCloseReasonFrom,
type RelayHostCloseReason
} from '../../../src/shared/relay-host-close-reason'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
@@ -26,6 +30,12 @@ type MobileRelayE2eeLinkOptions = {
onText: (plaintext: string) => void
onBinary: (plaintext: Uint8Array) => void
onHello?: (hello: Extract<RelayPhoneHello, { ok: true }>) => void
// The cell's account of why the desktop is absent, read off the close frame.
// Reported separately from onError because a rejection is delivered as both a
// relay-hello and a close, and which one the runtime dispatches first is not
// ordered — only the close carries the reason, and it must not be lost to
// that race.
onHostCloseReason?: (reason: RelayHostCloseReason) => void
// Fired once relay-auth is on the wire: from here the cell owns the wait.
onOpen?: () => void
onError: (error: Error) => void
@@ -129,6 +139,11 @@ export class MobileRelayE2eeLink {
clearTimeout(this.transportErrorTimer)
this.transportErrorTimer = null
}
// Ahead of fail(), which no-ops once the hello already reported this close.
const hostCloseReason = relayHostCloseReasonFrom(event.reason)
if (hostCloseReason) {
this.options.onHostCloseReason?.(hostCloseReason)
}
this.fail(new RelayOuterError(event.code || 1006))
}
}
@@ -13,6 +13,7 @@ import { RelayDialStageTracker, type RelayDialStageSource } from './relay-dial-s
import { RelayPendingRequests } from './relay-pending-requests'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { settleMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation'
import type { RelayHostCloseReason } from '../../../src/shared/relay-host-close-reason'
import type { RpcClient } from './rpc-client'
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
@@ -40,6 +41,7 @@ export function connectMobileRelayRpcSession(args: {
desktopPublicKeyB64: string
requestTimeoutMs?: number
createSocket?: (url: string) => WebSocket
onHostCloseReason?: (reason: RelayHostCloseReason) => void
onLog?: ConnectionLogSink
}): MobileRelayRpcSession {
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
@@ -69,6 +71,7 @@ export function connectMobileRelayRpcSession(args: {
deviceToken: args.deviceToken,
desktopPublicKeyB64: args.desktopPublicKeyB64,
createSocket: args.createSocket,
onHostCloseReason: args.onHostCloseReason,
onOpen: () => dialStage.advance('awaiting-hello'),
onHello: (hello) => {
if (
@@ -145,10 +145,22 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
}
})
isPairingRejected = () => this.pairingRejected
private hostSignedOut = false
setHostSignedOut = vi.fn((signedOut: boolean) => {
if (this.hostSignedOut === signedOut) {
return
}
this.hostSignedOut = signedOut
for (const listener of this.pathListeners) {
listener()
}
})
isHostSignedOut = () => this.hostSignedOut
// Mirrors LogicalClientConnectionPath.clearAfterConnected.
publishState(state: ConnectionState): void {
if (state === 'connected') {
this.pairingRejected = false
this.hostSignedOut = false
}
super.publishState(state)
}
@@ -264,7 +276,8 @@ describe('relay runtime recovery without direct connectivity', () => {
expect(openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 3 }),
expect.any(String)
expect.any(String),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
@@ -353,7 +366,8 @@ describe('relay runtime recovery without direct connectivity', () => {
expect(deps.openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 2 }),
expect.any(String)
expect.any(String),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
@@ -382,7 +396,8 @@ describe('relay runtime recovery without direct connectivity', () => {
expect(openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 1 }),
expect.any(String)
expect.any(String),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
@@ -10,6 +10,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason'
import type { HostProfile } from './types'
type EstablishResult = { ok: true } | { ok: false; error: Error }
@@ -100,7 +101,16 @@ export class MobileRelaySessionEstablisher {
const session = args.openRelay(
relay,
credential,
`confirm-${encodeBase64Url(args.randomBytes(16))}`
`confirm-${encodeBase64Url(args.randomBytes(16))}`,
// Latched on the logical client, not on the dial result: the close that
// carries the reason can land after this dial has already reported its
// failure. Clearing is clearAfterConnected's job, so any path that
// reaches connected retires it.
(reason) => {
if (reason === RELAY_HOST_CLOSE_REASON.SIGNED_OUT) {
args.logical.setHostSignedOut(true)
}
}
)
try {
// Why: backgrounding or a direct winner withdraws this dial before cutover.
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes'
import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import {
dependencies,
FakeLogicalClient,
FakeRelaySession,
host
} from './mobile-endpoint-supervisor-test-fakes'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
// The reason travels from the cell's close frame to the screens. This covers
// the production wiring between them: the supervisor's own openRelay callback.
describe('a signed-out desktop reaches the phone verdict', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
})
afterEach(() => vi.useRealTimers())
function supervisorOver(closeReason: string | null) {
const logical = new FakeLogicalClient('disconnected', 'lan')
const deps = dependencies({
openDirect: vi.fn(() => new FakeRelaySession('disconnected')),
openRelay: vi.fn((_relay, _credential, _confirmReqId, onHostCloseReason) => {
if (closeReason) {
onHostCloseReason?.(closeReason as never)
}
return new FakeRelaySession(
'disconnected',
new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE)
)
})
})
return { logical, supervisor: new MobileEndpointSupervisor(logical, host, deps) }
}
it('latches the sign-out the cell reported', async () => {
const { logical, supervisor } = supervisorOver(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
await supervisor.start()
await vi.waitFor(() => expect(logical.isHostSignedOut()).toBe(true))
supervisor.stop()
})
it('stays quiet for an ordinary host-offline rejection', async () => {
const { logical, supervisor } = supervisorOver(null)
await supervisor.start()
expect(logical.isHostSignedOut()).toBe(false)
supervisor.stop()
})
})
@@ -0,0 +1,216 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('./mobile-e2ee-v2-client-session', () => ({
MobileE2EEV2ClientSession: { create: () => ({}) }
}))
vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
MobileE2EEAuthenticationError: class extends Error {},
MobileE2EEV2PhysicalChannel: class {
start = vi.fn()
handleMessage = vi.fn(async () => {})
sendText = vi.fn(() => true)
sendBinary = vi.fn(() => true)
dispose = vi.fn()
}
}))
import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason'
import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes'
import { classifyConnection, verdictDisplayLabel } from './connection-health'
import { MobileRelayE2eeLink, RelayOuterError } from './mobile-relay-e2ee-link'
import { LogicalClientConnectionPath } from './logical-client-connection-path'
import { RelayReconnectController } from './mobile-relay-reconnect-controller'
const SIGNED_OUT_LABEL = 'Desktop signed out — sign in to Orca on your desktop to reconnect'
class FakeSocket {
static readonly OPEN = 1
readonly OPEN = FakeSocket.OPEN
readyState = FakeSocket.OPEN
bufferedAmount = 0
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number; reason: string }) => void) | null = null
send = vi.fn()
close = vi.fn()
}
function linkOver(
socket: FakeSocket,
onHostCloseReason: (reason: string) => void,
onError: (error: Error) => void
): MobileRelayE2eeLink {
return new MobileRelayE2eeLink({
endpoint: { cellUrl: 'https://relay-c1.onorca.dev', relayHostId: 'AbCdEf0123_-xyZ9' },
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onHostCloseReason,
onError,
createSocket: () => socket as unknown as WebSocket
})
}
describe('relay close reason on the phone', () => {
it('reports the cell close reason and still fails with 4404', () => {
const socket = new FakeSocket()
const onHostCloseReason = vi.fn()
const onError = vi.fn()
linkOver(socket, onHostCloseReason, onError)
socket.onclose?.({
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE,
reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT
})
expect(onHostCloseReason).toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
expect(onError).toHaveBeenCalledWith(new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE))
})
// An old cell sends its constant, and every other close sends nothing.
it('reports nothing for a reason it does not know', () => {
const socket = new FakeSocket()
const onHostCloseReason = vi.fn()
linkOver(socket, onHostCloseReason, vi.fn())
socket.onclose?.({
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE,
reason: 'relay connection rejected'
})
expect(onHostCloseReason).not.toHaveBeenCalled()
})
// The rejection arrives as a relay-hello AND a close, in an unordered pair.
// Whichever lands first, the reason must survive.
it('still reports the reason when the hello already failed the link', async () => {
const socket = new FakeSocket()
const onHostCloseReason = vi.fn()
linkOver(socket, onHostCloseReason, vi.fn())
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-hello',
ok: false,
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE
})
})
await Promise.resolve()
await Promise.resolve()
socket.onclose?.({
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE,
reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT
})
expect(onHostCloseReason).toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
})
})
describe('the signed-out signal on the logical client', () => {
it('publishes on change and retires when any path reaches connected', () => {
const path = new LogicalClientConnectionPath(() => false)
const changes = vi.fn()
path.subscribe(changes)
path.setHostSignedOut(true)
path.setHostSignedOut(true)
expect(path.isHostSignedOut()).toBe(true)
expect(changes).toHaveBeenCalledTimes(1)
path.clearAfterConnected()
expect(path.isHostSignedOut()).toBe(false)
})
})
describe('RelayReconnectController cadence', () => {
// The reason changes no recovery decision; 4404 keeps the host-offline
// backoff it has always had, so a phone on this build retries exactly as
// often as one that never hears the reason.
it('keeps the host-offline retry delay for a 4404', () => {
const delays: number[] = []
const controller = new RelayReconnectController(
{
now: () => 0,
randomBytes: () => new Uint8Array([0, 0]),
setTimer: ((callback: () => void, delay: number) => {
delays.push(delay)
return 1 as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout,
clearTimer: (() => {}) as unknown as typeof clearTimeout
},
vi.fn()
)
controller.registerFailure(new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE))
// hostOfflineDelayMs' 5s floor, not the 250ms transport-backoff floor.
expect(delays.at(-1)).toBe(5_000)
})
})
describe('classifyConnection with a signed-out desktop', () => {
const base = { reconnectAttempts: 0, lastConnectedAt: null, hostSignedOut: true }
it('says so from the first failed dial instead of "Connecting via Relay…"', () => {
const verdict = classifyConnection({
...base,
state: 'connecting',
pendingPath: 'relay'
})
expect(verdict).toEqual({
kind: 'unreachable',
label: SIGNED_OUT_LABEL,
reason: 'never-connected'
})
expect(verdictDisplayLabel(verdict)).toBe(SIGNED_OUT_LABEL)
})
it('replaces "Can\'t reach desktop" on the direct path too', () => {
expect(
classifyConnection({ ...base, state: 'reconnecting', reconnectAttempts: 20 }).label
).toBe(SIGNED_OUT_LABEL)
})
it('reads as stale once this session had been connected', () => {
expect(
classifyConnection({ ...base, state: 'reconnecting', lastConnectedAt: 1, nowMs: 2 }).reason
).toBe('stale')
})
// A Tailscale endpoint cannot make "sign in on your desktop" better advice.
it('never appends the Tailscale hint', () => {
expect(
classifyConnection({ ...base, state: 'reconnecting', endpoint: '100.64.0.1' })
).not.toHaveProperty('hint')
})
it('never outranks a connected session', () => {
expect(classifyConnection({ ...base, state: 'connected' }).label).toBe('Connected')
})
// Re-pairing, not signing in, is the remedy when the pairing itself is dead.
it('never outranks a revoked pairing', () => {
expect(classifyConnection({ ...base, state: 'reconnecting', pairingRejected: true }).kind).toBe(
'auth-failed'
)
})
it('leaves every other verdict alone when the desktop is not signed out', () => {
expect(
classifyConnection({
state: 'connecting',
reconnectAttempts: 0,
lastConnectedAt: null,
pendingPath: 'relay',
hostSignedOut: false
}).label
).toBe('Connecting via Relay…')
})
})
@@ -24,6 +24,7 @@ export type RpcClientContextValue = {
getActivePath: (hostId: string) => MobileConnectionPath
getPendingPath: (hostId: string) => MobileConnectionPath | null
isPairingRejected: (hostId: string) => boolean
isHostSignedOut: (hostId: string) => boolean
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => { hostId: string; client: RpcClient }[]
subscribeAllHosts: (listener: () => void) => () => void
@@ -55,6 +55,9 @@ export type StableLogicalRpcClient = RpcClient & {
// Latched when the desktop has repeatedly refused this device's relay credential.
setPairingRejected(rejected: boolean): void
isPairingRejected(): boolean
// Latched when the relay named the desktop's own sign-out as the reason it is absent.
setHostSignedOut(signedOut: boolean): void
isHostSignedOut(): boolean
// Recovery attempts share this signal so status-only changes rerender.
onConnectionPathChange(listener: () => void): () => void
getGeneration(): number
@@ -282,6 +285,8 @@ export function createStableLogicalRpcClient(
setRecoveryAttempt: (attempt) => connectionPath.setRecoveryAttempt(attempt),
setPairingRejected: (rejected) => connectionPath.setPairingRejected(rejected),
isPairingRejected: () => connectionPath.isPairingRejected(),
setHostSignedOut: (signedOut) => connectionPath.setHostSignedOut(signedOut),
isHostSignedOut: () => connectionPath.isHostSignedOut(),
onConnectionPathChange: (listener) => connectionPath.subscribe(listener),
getGeneration: () => generation
}
+3 -1
View File
@@ -138,6 +138,7 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
path: MobileConnectionPath
pendingPath: MobileConnectionPath | null
pairingRejected: boolean
hostSignedOut: boolean
}>((hostId) => {
const client = clientsByHostId.get(hostId)
return client
@@ -148,7 +149,8 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
state: ctx.getState(hostId),
path: ctx.getActivePath(hostId),
pendingPath: ctx.getPendingPath(hostId),
pairingRejected: ctx.isPairingRejected(hostId)
pairingRejected: ctx.isPairingRejected(hostId),
hostSignedOut: ctx.isHostSignedOut(hostId)
}
]
: []
+3 -3
View File
@@ -116,7 +116,7 @@ patchedDependencies:
'@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e
'@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
node-pty@1.1.0: e262847f57a1d4d3f2287a843822f7dcf3c9d8655892b07a69eba464e1317eaa
node-pty@1.1.0: 7cc9d45f3d2c38f142490d0805e75db55f0eef5174ad41c4b52abc5fbe079ad1
importers:
@@ -160,7 +160,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=e262847f57a1d4d3f2287a843822f7dcf3c9d8655892b07a69eba464e1317eaa)
version: 1.1.0(patch_hash=7cc9d45f3d2c38f142490d0805e75db55f0eef5174ad41c4b52abc5fbe079ad1)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -12285,7 +12285,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=e262847f57a1d4d3f2287a843822f7dcf3c9d8655892b07a69eba464e1317eaa):
node-pty@1.1.0(patch_hash=7cc9d45f3d2c38f142490d0805e75db55f0eef5174ad41c4b52abc5fbe079ad1):
dependencies:
node-addon-api: 7.1.1
+6 -6
View File
@@ -131,17 +131,17 @@
"name": "orchestration",
"sourcePath": "skills/orchestration",
"releaseRevision": 29,
"packageDigest": "7a386ce558ba54abe02b4a0de5d71fe3d63c944ef0888ddde130c729b37f7cc8",
"gitTreeSha": "4199ec6988801dd491631706cba62631b4bed8fb",
"packageDigest": "689e31d84256aded123c801eaa87413474943a9a30d96bff9a19d0a321aefb54",
"gitTreeSha": "902cc33dd65730b32ac234dd0ae7166d75498b46",
"files": [
{
"path": "SKILL.md",
"size": 4451,
"size": 4398,
"executable": false,
"classification": "text",
"exactSha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f",
"textNormalizedSha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f",
"identitySha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f"
"exactSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
"textNormalizedSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
"identitySha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18"
}
]
}
+6 -6
View File
@@ -1046,17 +1046,17 @@
},
{
"releaseRevision": 29,
"packageDigest": "7a386ce558ba54abe02b4a0de5d71fe3d63c944ef0888ddde130c729b37f7cc8",
"gitTreeSha": "4199ec6988801dd491631706cba62631b4bed8fb",
"packageDigest": "689e31d84256aded123c801eaa87413474943a9a30d96bff9a19d0a321aefb54",
"gitTreeSha": "902cc33dd65730b32ac234dd0ae7166d75498b46",
"files": [
{
"path": "SKILL.md",
"size": 4451,
"size": 4398,
"executable": false,
"classification": "text",
"exactSha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f",
"textNormalizedSha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f",
"identitySha256": "a7e3350f037698ebbce36b2818d8383ec6e96c2a4825537caab39a83b4fb4b7f"
"exactSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
"textNormalizedSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
"identitySha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18"
}
]
}
+12 -12
View File
@@ -3,18 +3,18 @@ name: orchestration
description: >-
Use Orca orchestration for structured multi-agent coordination: threaded
messages, blocking ask/reply flows, task dispatch, worker_done/escalation
waits, task DAGs, decision gates, coordinator loops, or decomposing work
across agents. Use `orca-cli` instead for full ownership handoffs, including
requests phrased as "hand off", "handoff", "handover", "give this to another
agent", or "another worktree" when the user did not explicitly ask to
supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for
terminal control, lightweight terminal prompts, shell commands, Orca
worktree management, reading or waiting on terminals, and automation of the
browser embedded inside Orca. Use Computer Use for external browser windows,
webviews, Orca app UI, or desktop UI outside Orca's embedded browser only when
the task requires OS/window-level control such as focus, menus, dialogs,
coordinates, or screenshots. Use `orca-cli` for Orca's embedded pages and a
page-automation tool such as Playwright or CDP for external pages.
waits, task DAGs, decision gates, or coordinator loops. Use `orca-cli`
instead for full ownership handoffs, including requests phrased as "hand
off", "handoff", "handover", "give this to another agent", or "another
worktree" when the user did not explicitly ask to supervise, monitor, wait
for results, or coordinate a DAG. Use `orca-cli` for terminal control,
lightweight terminal prompts, shell commands, Orca worktree management,
reading or waiting on terminals, and the Orca embedded browser. Use Computer
Use for external browser windows, webviews, Orca app UI, or desktop UI
outside Orca's embedded browser only when the task requires OS/window-level
control such as focus, menus, dialogs, coordinates, or screenshots. Use
`orca-cli` for Orca's embedded pages and a page-automation tool such as
Playwright or CDP for external pages.
---
# Orca Inter-Agent Orchestration
+12 -12
View File
@@ -3,18 +3,18 @@ name: orchestration
description: >-
Use Orca orchestration for structured multi-agent coordination: threaded
messages, blocking ask/reply flows, task dispatch, worker_done/escalation
waits, task DAGs, decision gates, coordinator loops, or decomposing work
across agents. Use `orca-cli` instead for full ownership handoffs, including
requests phrased as "hand off", "handoff", "handover", "give this to another
agent", or "another worktree" when the user did not explicitly ask to
supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for
terminal control, lightweight terminal prompts, shell commands, Orca
worktree management, reading or waiting on terminals, and automation of the
browser embedded inside Orca. Use Computer Use for external browser windows,
webviews, Orca app UI, or desktop UI outside Orca's embedded browser only when
the task requires OS/window-level control such as focus, menus, dialogs,
coordinates, or screenshots. Use `orca-cli` for Orca's embedded pages and a
page-automation tool such as Playwright or CDP for external pages.
waits, task DAGs, decision gates, or coordinator loops. Use `orca-cli`
instead for full ownership handoffs, including requests phrased as "hand
off", "handoff", "handover", "give this to another agent", or "another
worktree" when the user did not explicitly ask to supervise, monitor, wait
for results, or coordinate a DAG. Use `orca-cli` for terminal control,
lightweight terminal prompts, shell commands, Orca worktree management,
reading or waiting on terminals, and the Orca embedded browser. Use Computer
Use for external browser windows, webviews, Orca app UI, or desktop UI
outside Orca's embedded browser only when the task requires OS/window-level
control such as focus, menus, dialogs, coordinates, or screenshots. Use
`orca-cli` for Orca's embedded pages and a page-automation tool such as
Playwright or CDP for external pages.
---
# Orca Orchestration
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { rm } from './asar-transparent-fs'
// Why not an asar fixture here: plain Node has no asar shim to see through, so the archive case can
// only be settled by the real binary — `host-tree-removal-asar.electron.test.ts` does that. What
// this pins is the other half: outside Electron `original-fs` does not resolve, and the helper has
// to degrade to `node:fs/promises` rather than throw at first use.
const roots: string[] = []
afterAll(async () => {
for (const root of roots) {
await rm(root, { recursive: true, force: true }).catch(() => {})
}
})
describe('asar-transparent rm', () => {
it('removes a tree recursively where `original-fs` is unresolvable', async () => {
expect(process.versions.electron).toBeUndefined()
const root = await mkdtemp(join(tmpdir(), 'orca-asar-transparent-'))
roots.push(root)
const target = join(root, 'wt-1700000000000-abcdef01')
await mkdir(join(target, 'nested'), { recursive: true })
await writeFile(join(target, 'nested', 'file.txt'), 'x', 'utf8')
await expect(rm(target, { recursive: true, force: true })).resolves.toBeUndefined()
expect(existsSync(target)).toBe(false)
expect(existsSync(root)).toBe(true)
})
it('honours `force: false` rather than swallowing a missing path', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-asar-transparent-'))
roots.push(root)
await expect(rm(join(root, 'absent'), { recursive: true })).rejects.toMatchObject({
code: 'ENOENT'
})
})
})
+35
View File
@@ -0,0 +1,35 @@
// Why: Electron patches `fs` so a `*.asar` file reports `isDirectory() === true`, so Node's
// recursive `rm` descends into the archive, tries to `rmdir` a real file, and fails the parent with
// ENOTEMPTY. Every worktree that has ever run `pnpm install` carries at least one
// (`node_modules/.pnpm/electron@…/…/Electron.app/Contents/Resources/default_app.asar`), so a
// worktree removal aborts there deterministically — the residue is not a concurrent-writer race and
// no amount of retrying clears it. `original-fs` is Electron's unpatched `fs`; unlike
// `process.noAsar` it is scoped to this call rather than to the whole process, which matters because
// a multi-GB removal runs for seconds while the main process may still be loading modules out of
// `app.asar`. See `cli/appimage-payload-removal.ts` for the same bug at a call site short enough to
// use the process-global flag.
import { rm as nodeRm } from 'node:fs/promises'
import { createRequire } from 'node:module'
type Rm = typeof nodeRm
let resolvedRm: Rm | undefined
function resolveRm(): Rm {
try {
// Why require and not an import: `original-fs` only exists inside Electron, so vitest, the
// `orca` CLI and the plain-node entrypoints must resolve `node:fs/promises` instead — and there
// the shim does not exist either, so plain `fs` is already asar-transparent.
const originalFs = createRequire(__filename)('original-fs') as { promises?: { rm?: Rm } }
return typeof originalFs.promises?.rm === 'function' ? originalFs.promises.rm : nodeRm
} catch {
return nodeRm
}
}
/** `fs.promises.rm` that sees a `*.asar` as the file it is rather than as a directory. */
export const rm: Rm = (path, options) => {
resolvedRm ??= resolveRm()
return resolvedRm(path, options)
}
@@ -1,7 +1,14 @@
import type { ChildProcess } from 'node:child_process'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
findSelfInitiatedTreeKills,
resetSelfInitiatedTreeKillLogForTest
} from '../crash-reporting/self-initiated-tree-kill-log'
import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown'
/** Above pid_max on every supported POSIX host, so the group signal is a real ESRCH. */
const UNREACHABLE_PGID = 2_147_483_647
function child() {
return {
pid: 1234,
@@ -10,6 +17,10 @@ function child() {
}
describe('terminateCodexAppServerProcessTree', () => {
beforeEach(() => {
resetSelfInitiatedTreeKillLogForTest()
})
it('waits for the Windows tree kill before releasing the wrapper', async () => {
const target = child()
const release = Promise.withResolvers<void>()
@@ -120,6 +131,55 @@ describe('terminateCodexAppServerProcessTree', () => {
expect(target.kill).not.toHaveBeenCalled()
})
/**
* `selfInitiatedTreeKillCount` decides whether a `render-process-gone` was
* ours. A group that had already exited was killed by nobody, so crediting it
* puts a suspect in the five-second window that Orca never issued. Exercised
* through the real `process.kill(-pgid)` because the swallow being tested
* lives in the production default, not in an injectable seam.
*/
it('does not claim a snapshot group that was already gone', async () => {
const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) as ChildProcess['kill'] }
await expect(
terminateCodexAppServerProcessTree(target, undefined, {
platform: 'darwin',
captureDescendants: async () => ({
rootPgid: UNREACHABLE_PGID,
descendants: [],
capturedAtMs: 1
}),
terminateDescendants: async () => true
})
).resolves.toBe(true)
expect(target.kill).toHaveBeenLastCalledWith('SIGKILL')
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([])
})
it('claims a snapshot group the signal actually reached', async () => {
const target = child()
const signalProcessGroup = vi.fn()
await expect(
terminateCodexAppServerProcessTree(target, undefined, {
platform: 'darwin',
captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }),
terminateDescendants: async () => true,
signalProcessGroup
})
).resolves.toBe(true)
expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL')
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([
expect.objectContaining({
pid: 1234,
site: 'codex-app-server-teardown',
scope: 'posix-process-group'
})
])
})
it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => {
const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true))
const targets = killMocks.map((kill, index) => ({
@@ -128,19 +128,24 @@ async function terminatePosixTree(
if (descendantsExited && snapshot.rootPgid === rootPid) {
const signalGroup =
deps.signalProcessGroup ??
((pgid: number, signal: NodeJS.Signals) => {
try {
process.kill(-pgid, signal)
} catch {
// Group already exited.
}
((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal))
let groupSignalled = false
try {
signalGroup(snapshot.rootPgid, 'SIGKILL')
groupSignalled = true
} catch {
// Already-gone is still the desired outcome, but nothing here killed it,
// and a crumb for a kill we never landed is a false render-process-gone suspect.
}
if (groupSignalled) {
// Outside the try, as in terminateDedicatedPosixGroup: that catch is the
// already-gone contract, not a breadcrumb handler.
recordSelfInitiatedTreeKill({
pid: snapshot.rootPgid,
site: 'codex-app-server-teardown',
scope: 'posix-process-group'
})
signalGroup(snapshot.rootPgid, 'SIGKILL')
recordSelfInitiatedTreeKill({
pid: snapshot.rootPgid,
site: 'codex-app-server-teardown',
scope: 'posix-process-group'
})
}
}
if (!descendantsExited) {
child.kill('SIGCONT')
@@ -1,77 +0,0 @@
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
// ─── System memory at gone time ─────────────────────────────────────
// Why: the system outlives the crashed process, so this IS sampleable at
// process-gone — it separates "renderer grew huge" from "machine out of
// memory/commit", which the per-process buckets alone cannot.
// Timing honesty: this reads AFTER the crashed process's memory returned to
// the OS, so free/swapFree can look healthier than they were at kill time.
// Platform honesty: swap* exist on Windows/Linux only. On Linux `free` is
// /proc/meminfo MemFree and is NOT the pressure signal — it excludes page cache
// and other reclaimable memory; `available` (MemAvailable, Linux-only) is. On
// macOS `free` is near-meaningless (file cache and compression keep it low on
// healthy machines); fileBacked/purgeable are the only reclaimability proxy this
// API gives there, and none of these fields answers "was the machine under
// pressure" on macOS — that needs a signal Electron does not expose.
type CrashReportDetails = Record<string, CrashReportDetailValue>
export function memoryKBFieldMB(value: unknown): number | undefined {
const kb = typeof value === 'number' && Number.isFinite(value) ? value : undefined
return kb === undefined ? undefined : Math.round(Math.max(0, kb) / 1024)
}
type SystemMemoryInfoLike = {
total?: unknown
free?: unknown
available?: unknown
swapTotal?: unknown
swapFree?: unknown
fileBacked?: unknown
purgeable?: unknown
}
type SystemMemoryInfoReader = () => SystemMemoryInfoLike | null
function readElectronSystemMemoryInfo(): SystemMemoryInfoLike | null {
const read = (process as NodeJS.Process & { getSystemMemoryInfo?: () => SystemMemoryInfoLike })
.getSystemMemoryInfo
if (typeof read !== 'function') {
return null
}
try {
return read.call(process)
} catch {
return null
}
}
let systemMemoryInfoReader: SystemMemoryInfoReader = readElectronSystemMemoryInfo
export function setSystemMemoryInfoReaderForTest(reader: SystemMemoryInfoReader | null): void {
systemMemoryInfoReader = reader ?? readElectronSystemMemoryInfo
}
export function getSystemMemoryAtGoneDetails(): CrashReportDetails {
const info = systemMemoryInfoReader()
if (!info) {
return {}
}
const details: CrashReportDetails = {}
const fields: readonly [keyof SystemMemoryInfoLike, string][] = [
['total', 'systemMemoryTotalMB'],
['free', 'systemMemoryFreeMB'],
['available', 'systemMemoryAvailableMB'],
['swapTotal', 'systemMemorySwapTotalMB'],
['swapFree', 'systemMemorySwapFreeMB'],
['fileBacked', 'systemMemoryFileBackedMB'],
['purgeable', 'systemMemoryPurgeableMB']
]
for (const [field, key] of fields) {
const mb = memoryKBFieldMB(info[field])
if (mb !== undefined) {
details[key] = mb
}
}
return details
}
@@ -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]
@@ -0,0 +1,379 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getSystemMemoryDetails,
setSystemMemoryInfoReaderForTest,
withSwapVolumeFreeSpace
} from './system-memory-details'
import {
readSwapVolumeFreeSpace,
setSwapVolumeFreeSpaceReaderForTest,
type SwapVolumeFreeSpace
} from './swap-volume-free-space'
import { samplePreGoneSystemMemory } from './pre-gone-host-memory'
import {
buildProcessGoneCrashDetails,
resetPreGoneCrashSamplingForTest,
samplePreGoneProcessMetrics,
startPreGoneCrashSampling
} from './process-gone-diagnostics'
type MetricFixture = {
pid: number
creationTime: number
type: string
memory: { workingSetSize: number; peakWorkingSetSize?: number; privateBytes?: number }
}
const { appMetricsMock } = vi.hoisted(() => ({
appMetricsMock: vi.fn<() => MetricFixture[]>(() => [])
}))
vi.mock('electron', () => ({ app: { getAppMetrics: appMetricsMock } }))
const BROWSER_AND_RENDERER: MetricFixture[] = [
{ pid: 10, creationTime: 1, type: 'Browser', memory: { workingSetSize: 1024 * 250 } },
{
pid: 11,
creationTime: 2,
type: 'Tab',
memory: { workingSetSize: 1024 * 400, peakWorkingSetSize: 1024 * 420, privateBytes: 1024 * 260 }
}
]
const BROWSER_ONLY: MetricFixture[] = [BROWSER_AND_RENDERER[0]]
const UNDER_COMMIT_PRESSURE = {
total: 16_000 * 1024,
free: 400 * 1024,
swapTotal: 48_000 * 1024,
swapFree: 200 * 1024
}
const AFTER_THE_CORPSE_RELEASED = {
total: 16_000 * 1024,
free: 3_000 * 1024,
swapTotal: 48_000 * 1024,
swapFree: 2_900 * 1024
}
// Commit limit ~= RAM: a disabled or fixed pagefile, which no amount of empty
// disk can grow into. `swapTotal > total` is all this API can say about that.
const FIXED_PAGEFILE_UNDER_PRESSURE = {
total: 16_000 * 1024,
free: 300 * 1024,
swapTotal: 16_100 * 1024,
swapFree: 180 * 1024
}
const NO_PAGEFILE_UNDER_PRESSURE = {
...FIXED_PAGEFILE_UNDER_PRESSURE,
swapTotal: 15_900 * 1024
}
const BEFORE_THE_STORM = {
total: 16_000 * 1024,
free: 9_000 * 1024,
swapTotal: 48_000 * 1024,
swapFree: 30_000 * 1024
}
describe('pre-gone host memory', () => {
beforeEach(() => {
resetPreGoneCrashSamplingForTest()
setSystemMemoryInfoReaderForTest(null)
setSwapVolumeFreeSpaceReaderForTest(null)
appMetricsMock.mockClear()
appMetricsMock.mockReturnValue(BROWSER_AND_RENDERER)
})
it('carries a pre-gone host reading, not only the post-mortem one', async () => {
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' }))
await samplePreGoneSystemMemory(Date.now() - 5_000)
// The renderer dies; its ~400 MB returns to the OS, so the gone-time read
// now shows a much healthier machine than the one that refused the alloc.
setSystemMemoryInfoReaderForTest(() => AFTER_THE_CORPSE_RELEASED)
appMetricsMock.mockReturnValue(BROWSER_ONLY)
const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer')
expect(details.systemMemorySwapFreeMB).toBe(2_900)
expect(details.systemMemoryPreGoneSwapFreeMB).toBe(200)
expect(details.systemMemoryPreGoneFreeMB).toBe(400)
expect(details.systemMemoryPreGoneTotalMB).toBe(16_000)
// Why: host memory keeps its own key family, so a `systemMemory` prefix scan sees both reads.
expect(
Object.keys(details).filter((key) => key.startsWith('processMetricsPreGoneSystem'))
).toEqual([])
})
// Why this decides the cluster: 200 MB available commit is only a REFUSAL when
// the pagefile cannot grow, which is what the volume's free space says.
it('reports swap-volume free space so low commit can be told from refused commit', async () => {
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' }))
await samplePreGoneSystemMemory(Date.now() - 5_000)
const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer')
expect(details.systemMemoryPreGoneSwapVolumeFreeMB).toBe(120)
// Which volume was measured: Windows only names the DEFAULT pagefile drive.
expect(details.systemMemoryPreGoneSwapVolume).toBe('C:')
})
it('omits swap-volume free space on Linux, where swap cannot grow into free disk', async () => {
// Linux swap is a fixed partition, a fixed-size swapfile, or zram; reporting
// root-fs free space next to SwapFreeMB 0 would read as headroom that is not there.
setSwapVolumeFreeSpaceReaderForTest(null)
await expect(readSwapVolumeFreeSpace('linux')).resolves.toBeUndefined()
})
it('labels the reading with the pressure verdict the platform can actually give', () => {
// Windows available commit is only a REFUSAL when the pagefile cannot grow,
// which nothing here proves, so no label may read as that verdict.
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
const windowsCommit = getSystemMemoryDetails('win32')
expect(windowsCommit.systemMemoryPressureSignal).toBe('available-commit-unqualified')
expect(
withSwapVolumeFreeSpace(windowsCommit, { freeMB: 120, volume: 'C:' }, 'win32')
.systemMemoryPressureSignal
).toBe('available-commit-volume-cotimed')
// A volume number from a different moment describes a different machine.
expect(
withSwapVolumeFreeSpace(windowsCommit, { freeMB: 120, volume: 'C:' }, 'win32', false)
.systemMemoryPressureSignal
).toBe('available-commit-unqualified')
setSystemMemoryInfoReaderForTest(() => ({ total: 16_000 * 1024, free: 400 * 1024 }))
expect(getSystemMemoryDetails('linux').systemMemoryPressureSignal).toBe('none')
setSystemMemoryInfoReaderForTest(() => ({ total: 16_000 * 1024, available: 900 * 1024 }))
expect(getSystemMemoryDetails('linux').systemMemoryPressureSignal).toBe('mem-available')
// darwin free/fileBacked/purgeable answer reclaimability, never pressure.
setSystemMemoryInfoReaderForTest(() => ({
total: 16_000 * 1024,
free: 272 * 1024,
fileBacked: 2_694 * 1024,
purgeable: 0
}))
expect(getSystemMemoryDetails('darwin').systemMemoryPressureSignal).toBe('none')
})
// Why this and not the volume number: the branch's own repro needed a pagefile
// that CANNOT grow to kill anything, and neither the pagefile maximum nor its
// drive is readable here — `swapVolumeAnchor` measures SystemRoot's volume,
// which a relocated pagefile does not live on.
it('never reads free disk as proof the pagefile could have grown', () => {
setSystemMemoryInfoReaderForTest(() => FIXED_PAGEFILE_UNDER_PRESSURE)
const fixedPagefile = withSwapVolumeFreeSpace(
getSystemMemoryDetails('win32'),
{ freeMB: 812_000, volume: 'C:' },
'win32'
)
// 180 MB of commit beside 812 GB of free disk: co-timed, and still not a
// verdict — reading it as "the pagefile had room" is the opposite conclusion.
expect(fixedPagefile.systemMemoryPressureSignal).toBe('available-commit-volume-cotimed')
// The one decisive win32 case: commit limit at or below RAM means there is
// no pagefile behind it, so the floor cannot heal however empty the disk is.
setSystemMemoryInfoReaderForTest(() => NO_PAGEFILE_UNDER_PRESSURE)
expect(
withSwapVolumeFreeSpace(
getSystemMemoryDetails('win32'),
{ freeMB: 812_000, volume: 'C:' },
'win32'
).systemMemoryPressureSignal
).toBe('available-commit-hard-capped')
})
// Why the verdict and not just the field: a statfs issued on a healthy host at
// t=0 that resolves 20 s into a commit storm prints "200 MB commit, 40 GB of
// pagefile headroom" — which reads as NOT a commit refusal, the opposite
// conclusion, under the branch's most confident label.
it('will not let a statfs that outlived its tick qualify the win32 commit verdict', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.useFakeTimers()
let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {}
try {
setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM)
setSwapVolumeFreeSpaceReaderForTest(
() =>
new Promise<SwapVolumeFreeSpace>((resolve) => {
resolveVolume = resolve
})
)
void samplePreGoneSystemMemory(0)
// The storm arrives; the in-flight latch makes every tick skip the merge,
// so the pending statfs is as old as the tick that STARTED it.
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
await samplePreGoneSystemMemory(10_000)
await samplePreGoneSystemMemory(20_000)
resolveVolume({ freeMB: 40_000, volume: 'C:' })
await vi.advanceTimersByTimeAsync(0)
vi.setSystemTime(20_000)
const stale = buildProcessGoneCrashDetails({}, 'renderer')
expect(stale.systemMemoryPreGoneSwapFreeMB).toBe(200)
// The pre-storm volume number still ships — but carrying its own age, and
// without promoting the verdict the analyst reads.
expect(stale.systemMemoryPreGoneSwapVolumeFreeMB).toBe(40_000)
expect(stale.systemMemoryPreGoneSampleAgeMs).toBe(0)
expect(stale.systemMemoryPreGoneSwapVolumeAgeMs).toBe(20_000)
expect(stale.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified')
// The next tick's statfs answers on its own tick, so it qualifies again.
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 900, volume: 'C:' }))
await samplePreGoneSystemMemory(30_000)
vi.setSystemTime(30_000)
const fresh = buildProcessGoneCrashDetails({}, 'renderer')
expect(fresh.systemMemoryPreGoneSwapVolumeFreeMB).toBe(900)
expect(fresh.systemMemoryPreGoneSwapVolumeAgeMs).toBe(0)
expect(fresh.systemMemoryPreGonePressureSignal).toBe('available-commit-volume-cotimed')
} finally {
vi.useRealTimers()
Object.defineProperty(process, 'platform', platform)
}
})
// Round 5: sample identity alone could not see these ticks. A host read that
// returns nothing leaves the sample object in place, so `sample === issuedFor`
// still held 25 s and two ticks later and the statfs re-qualified the verdict.
it('will not let ticks with a failed host read pass a stale statfs off as co-timed', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.useFakeTimers()
let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {}
try {
setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM)
setSwapVolumeFreeSpaceReaderForTest(
() =>
new Promise<SwapVolumeFreeSpace>((resolve) => {
resolveVolume = resolve
})
)
void samplePreGoneSystemMemory(0)
// GlobalMemoryStatusEx starts failing: the sample is neither replaced nor erased.
setSystemMemoryInfoReaderForTest(() => null)
await samplePreGoneSystemMemory(10_000)
await samplePreGoneSystemMemory(20_000)
resolveVolume({ freeMB: 40_000, volume: 'C:' })
await vi.advanceTimersByTimeAsync(0)
vi.setSystemTime(25_000)
const details = buildProcessGoneCrashDetails({}, 'renderer')
// 25 s of lag: the label must not say co-timed beside that age.
expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(25_000)
expect(details.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified')
} finally {
vi.useRealTimers()
Object.defineProperty(process, 'platform', platform)
}
})
it("arms the host sampler on its own unref'd 10 s timer, not the metric sweep's", async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
const readHostMemory = vi.fn(() => UNDER_COMMIT_PRESSURE)
setSystemMemoryInfoReaderForTest(readHostMemory)
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' }))
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
try {
startPreGoneCrashSampling()
// Literal millisecond values: asserting the constants against themselves
// would let a cadence regression through, and 37 s of staleness is the bug.
expect(setIntervalSpy.mock.calls.map(([, ms]) => ms)).toEqual([60_000, 10_000])
for (const { value } of setIntervalSpy.mock.results) {
expect((value as NodeJS.Timeout).hasRef()).toBe(false)
}
expect(readHostMemory).toHaveBeenCalledTimes(1)
readHostMemory.mockReturnValue(AFTER_THE_CORPSE_RELEASED)
await vi.advanceTimersByTimeAsync(10_000)
// One host tick, no extra metric sweep: the two samplers run independently.
expect(readHostMemory).toHaveBeenCalledTimes(2)
expect(appMetricsMock).toHaveBeenCalledTimes(1)
const details = buildProcessGoneCrashDetails({}, 'renderer')
expect(details.systemMemoryPreGoneSampleAgeMs).toBe(0)
expect(details.systemMemoryPreGoneSwapFreeMB).toBe(2_900)
} finally {
setIntervalSpy.mockRestore()
vi.useRealTimers()
}
})
it('commits the host reading without waiting on the swap-volume statfs', async () => {
// Why: statfs is slowest during the paging storm this sampler targets, and
// a hung volume must not stall or silently skip host sampling.
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
setSwapVolumeFreeSpaceReaderForTest(() => new Promise(() => {}))
void samplePreGoneSystemMemory(Date.now() - 5_000)
expect(buildProcessGoneCrashDetails({}, 'renderer').systemMemoryPreGoneSwapFreeMB).toBe(200)
// A second tick still refreshes the reading while that statfs hangs.
setSystemMemoryInfoReaderForTest(() => AFTER_THE_CORPSE_RELEASED)
void samplePreGoneSystemMemory(Date.now())
expect(buildProcessGoneCrashDetails({}, 'renderer').systemMemoryPreGoneSwapFreeMB).toBe(2_900)
})
it('publishes no pre-gone host keys when every memory field failed to read', async () => {
// Why not "no keys at all": the reading always carries its signal label, so a
// committed empty one would ship an age and a volume number with no memory
// numbers beside them — a disk-free figure standing in for a host reading.
setSystemMemoryInfoReaderForTest(() => ({ total: Number.NaN, free: undefined }))
await samplePreGoneSystemMemory(Date.now())
const details = buildProcessGoneCrashDetails({}, 'renderer')
expect(Object.keys(details).filter((key) => key.startsWith('systemMemoryPreGone'))).toEqual([])
})
it('carries the last volume reading forward, aged, instead of dropping it', async () => {
vi.useFakeTimers()
try {
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 42, volume: 'C:' }))
await samplePreGoneSystemMemory(0)
// The next tick's statfs hangs — during the paging storm this targets, that
// is the normal case — so the tick has no volume reading of its own, and
// the sample that replaces the last one would otherwise drop the field.
setSwapVolumeFreeSpaceReaderForTest(() => new Promise<never>(() => {}))
void samplePreGoneSystemMemory(10_000)
vi.setSystemTime(10_000)
const details = buildProcessGoneCrashDetails({}, 'renderer')
expect(details.systemMemoryPreGoneSwapVolumeFreeMB).toBe(42)
expect(details.systemMemoryPreGoneSwapVolume).toBe('C:')
expect(details.systemMemoryPreGoneSampleAgeMs).toBe(0)
// Carried, not re-read: it ships at its real age, never as a fresh number.
expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(10_000)
} finally {
vi.useRealTimers()
}
})
it('keeps a failed host read from erasing the process-metric sample', async () => {
samplePreGoneProcessMetrics(Date.now() - 5_000)
setSystemMemoryInfoReaderForTest(() => {
throw new Error('getSystemMemoryInfo unavailable')
})
await samplePreGoneSystemMemory(Date.now() - 5_000)
setSystemMemoryInfoReaderForTest(null)
const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer')
expect(details.processMetricsPreGoneRendererWorkingSetMB).toBe(400)
expect(Object.keys(details).filter((key) => key.startsWith('systemMemoryPreGone'))).toEqual([])
})
})
@@ -0,0 +1,164 @@
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
import { readSwapVolumeFreeSpace } from './swap-volume-free-space'
import {
getSystemMemoryDetails,
SYSTEM_MEMORY_KEY_PREFIX,
withSwapVolumeFreeSpace
} from './system-memory-details'
// ─── Pre-gone host memory sampling ──────────────────────────────────
// Why sample at all: the gone-time host read lands after the corpse released
// its pages, so it reports a healthier machine than the one that refused the
// allocation.
// Why 10 s and not the 60 s process-metrics cadence: at 60 s, four of five
// field OOMs carried a ~37 s old host reading — far too stale to see a
// transient commit refusal. A refusal shorter than the interval stays
// invisible; no cadence fixes that.
export const PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS = 10_000
type CrashReportDetails = Record<string, CrashReportDetailValue>
type PreGoneSystemMemorySample = {
details: CrashReportDetails
sampledAtMs: number
/** Tick that ISSUED the statfs now merged in — never the tick it resolved on. */
swapVolumeSampledAtMs?: number
}
let preGoneSample: PreGoneSystemMemorySample | null = null
let preGoneTimer: ReturnType<typeof setInterval> | null = null
let swapVolumeReadInFlight = false
let samplingGeneration = 0
let sampleTick = 0
const PRESSURE_SIGNAL_KEY = `${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`
/**
* Carries the last volume reading onto the sample that replaces its own.
*
* Why: a statfs slower than one tick would otherwise make the field vanish from
* the reports it exists for — the next tick replaces the sample wholesale, and
* the in-flight latch keeps intervening ticks from merging anything. It ships
* with its own (now larger) age and, not being co-timed, never names the label.
*/
function withCarriedSwapVolume(sample: PreGoneSystemMemorySample): PreGoneSystemMemorySample {
const previous = preGoneSample
if (!previous || previous.swapVolumeSampledAtMs === undefined) {
return sample
}
const freeMB = previous.details[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB`]
const volume = previous.details[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolume`]
if (typeof freeMB !== 'number' || typeof volume !== 'string') {
return sample
}
return {
...sample,
details: withSwapVolumeFreeSpace(sample.details, { freeMB, volume }, process.platform, false),
swapVolumeSampledAtMs: previous.swapVolumeSampledAtMs
}
}
function commitHostMemorySample(nowMs: number): boolean {
try {
const details = getSystemMemoryDetails()
// Why not `length === 0`: the signal label is appended unconditionally, so a
// reading that resolved no memory field at all still arrives with one key.
if (!Object.keys(details).some((key) => key !== PRESSURE_SIGNAL_KEY)) {
return false
}
preGoneSample = withCarriedSwapVolume({ details, sampledAtMs: nowMs })
return true
} catch {
// Why: a failed read must not erase the previous good sample.
return false
}
}
async function mergeSwapVolumeFreeSpace(issuedOnTick: number): Promise<void> {
if (swapVolumeReadInFlight) {
return
}
swapVolumeReadInFlight = true
const generation = samplingGeneration
const issuedFor = preGoneSample
try {
const volume = await readSwapVolumeFreeSpace()
if (volume && preGoneSample && generation === samplingGeneration) {
// Why only its own tick qualifies: a statfs that outlived its tick carries a
// pre-storm volume number, and the latch makes that lag unbounded. It still
// ships beside its age, but it may not decide the verdict.
// Why the tick counter and not sample identity: a tick whose host read fails
// leaves the sample object in place, so identity alone reads as co-timed.
const coTimed = issuedOnTick === sampleTick
preGoneSample = {
...preGoneSample,
details: withSwapVolumeFreeSpace(preGoneSample.details, volume, process.platform, coTimed),
swapVolumeSampledAtMs: issuedFor?.sampledAtMs
}
}
} catch {
// Why: the memory reading is already committed and stands on its own.
} finally {
swapVolumeReadInFlight = false
}
}
export async function samplePreGoneSystemMemory(nowMs: number = Date.now()): Promise<void> {
// Why commit before awaiting: the volume read is a statfs, and under the very
// paging storm this targets it is slowest — it must never delay, or (via an
// in-flight latch) skip, the cheap synchronous host reading.
const tick = ++sampleTick
if (!commitHostMemorySample(nowMs)) {
return
}
await mergeSwapVolumeFreeSpace(tick)
}
export function startPreGoneSystemMemorySampling(
intervalMs: number = PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS
): void {
if (preGoneTimer) {
return
}
void samplePreGoneSystemMemory()
preGoneTimer = setInterval(() => void samplePreGoneSystemMemory(), intervalMs)
preGoneTimer.unref?.()
}
export function resetPreGoneSystemMemorySamplingForTest(): void {
if (preGoneTimer) {
clearInterval(preGoneTimer)
}
preGoneTimer = null
preGoneSample = null
swapVolumeReadInFlight = false
// Why bump: an already-awaited volume read must not repopulate a reset sample.
samplingGeneration += 1
}
/** Keyed as `systemMemoryPreGone*` so a scan over the `systemMemory` family sees both reads. */
export function preGoneSystemMemoryDetails(nowMs: number): CrashReportDetails {
if (!preGoneSample) {
return {}
}
const details: CrashReportDetails = {
[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSampleAgeMs`]: Math.max(
0,
nowMs - preGoneSample.sampledAtMs
)
}
// Why its own age: the volume read resolves out of band, so it can be older
// than the memory reading printed beside it, and that gap must be readable.
if (preGoneSample.swapVolumeSampledAtMs !== undefined) {
details[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSwapVolumeAgeMs`] = Math.max(
0,
nowMs - preGoneSample.swapVolumeSampledAtMs
)
}
for (const [key, value] of Object.entries(preGoneSample.details)) {
details[`${SYSTEM_MEMORY_KEY_PREFIX}PreGone${key.slice(SYSTEM_MEMORY_KEY_PREFIX.length)}`] =
value
}
return details
}
@@ -2,11 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
buildProcessGoneCrashDetails,
collectProcessGoneMetricDetails,
resetPreGoneProcessMetricsSamplingForTest,
resetPreGoneCrashSamplingForTest,
samplePreGoneProcessMetrics,
startPreGoneProcessMetricsSampling
startPreGoneCrashSampling
} from './process-gone-diagnostics'
import { setSystemMemoryInfoReaderForTest } from './gone-time-system-memory'
import { setSystemMemoryInfoReaderForTest } from './system-memory-details'
type MetricFixture = {
pid?: number
@@ -27,7 +27,7 @@ vi.mock('electron', () => ({
describe('process gone diagnostics', () => {
beforeEach(() => {
resetPreGoneProcessMetricsSamplingForTest()
resetPreGoneCrashSamplingForTest()
setSystemMemoryInfoReaderForTest(null)
})
@@ -141,8 +141,8 @@ describe('process gone diagnostics', () => {
appMetricsMock.mockReturnValue([
{ pid: 30, type: 'Tab', memory: { workingSetSize: 1024 * 100 } }
])
startPreGoneProcessMetricsSampling(1_000)
startPreGoneProcessMetricsSampling(1_000)
startPreGoneCrashSampling(1_000)
startPreGoneCrashSampling(1_000)
// A crash inside the first interval already has a sample to draw from.
expect(buildProcessGoneCrashDetails({}, 'renderer')).toMatchObject({
@@ -582,12 +582,12 @@ describe('process gone diagnostics', () => {
it("arms an unref'd interval so sampling never holds the event loop open", () => {
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
try {
startPreGoneProcessMetricsSampling(60_000)
startPreGoneCrashSampling(60_000)
const timer = setIntervalSpy.mock.results[0]?.value as NodeJS.Timeout
expect(timer.hasRef()).toBe(false)
} finally {
setIntervalSpy.mockRestore()
resetPreGoneProcessMetricsSamplingForTest()
resetPreGoneCrashSamplingForTest()
}
})
@@ -641,7 +641,7 @@ describe('process gone diagnostics', () => {
expect(details.systemMemoryTotalMB).toBe(16_384)
})
it('samples system memory at gone time but never into the pre-gone snapshot', () => {
it('samples system memory at gone time but never into the processMetrics family', () => {
appMetricsMock.mockReturnValue([{ pid: 1, type: 'Browser', memory: { workingSetSize: 0 } }])
samplePreGoneProcessMetrics()
setSystemMemoryInfoReaderForTest(() => ({
@@ -658,7 +658,9 @@ describe('process gone diagnostics', () => {
systemMemorySwapTotalMB: 8_192,
systemMemorySwapFreeMB: 40
})
expect(details.processMetricsPreGoneSystemMemoryTotalMB).toBeUndefined()
expect(
Object.keys(details).filter((key) => key.startsWith('processMetricsPreGoneSystem'))
).toEqual([])
})
it('leaves records unflagged when the crashed bucket is still populated', () => {
@@ -3,7 +3,13 @@ import {
sanitizeCrashReportDetails,
type CrashReportDetailValue
} from '../../shared/crash-reporting'
import { getSystemMemoryAtGoneDetails, memoryKBFieldMB } from './gone-time-system-memory'
import { getSystemMemoryDetails, memoryKBFieldMB } from './system-memory-details'
import {
PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS,
preGoneSystemMemoryDetails,
resetPreGoneSystemMemorySamplingForTest,
startPreGoneSystemMemorySampling
} from './pre-gone-host-memory'
type ProcessMetricLike = {
pid?: unknown
@@ -204,8 +210,9 @@ export function samplePreGoneProcessMetrics(nowMs: number = Date.now()): void {
}
}
export function startPreGoneProcessMetricsSampling(
intervalMs: number = PROCESS_METRICS_PRE_GONE_SAMPLE_INTERVAL_MS
export function startPreGoneCrashSampling(
intervalMs: number = PROCESS_METRICS_PRE_GONE_SAMPLE_INTERVAL_MS,
systemMemoryIntervalMs: number = PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS
): void {
if (preGoneSampleTimer) {
return
@@ -213,14 +220,16 @@ export function startPreGoneProcessMetricsSampling(
samplePreGoneProcessMetrics()
preGoneSampleTimer = setInterval(() => samplePreGoneProcessMetrics(), intervalMs)
preGoneSampleTimer.unref?.()
startPreGoneSystemMemorySampling(systemMemoryIntervalMs)
}
export function resetPreGoneProcessMetricsSamplingForTest(): void {
export function resetPreGoneCrashSamplingForTest(): void {
if (preGoneSampleTimer) {
clearInterval(preGoneSampleTimer)
}
preGoneSampleTimer = null
preGoneSample = null
resetPreGoneSystemMemorySamplingForTest()
}
const PROCESS_METRICS_KEY_PREFIX = 'processMetrics'
@@ -271,7 +280,7 @@ export function buildProcessGoneCrashDetails(
const crashDetails: CrashReportDetails = {
...sanitizedDetails,
...liveMetricDetails,
...getSystemMemoryAtGoneDetails()
...getSystemMemoryDetails()
}
// Why: with the crasher gone, Largest names a survivor — flag that so the
// live buckets are read as "everyone else", not as the crashed process.
@@ -290,8 +299,10 @@ export function buildProcessGoneCrashDetails(
if (liveMetricDetails[crashedBucketCountKey] === 0 || sampledSameBucketProcessVanished) {
crashDetails.processMetricsCrashedProcessAbsent = true
}
const nowMs = Date.now()
if (preGoneSample) {
Object.assign(crashDetails, preGoneSampleDetails(preGoneSample, Date.now()))
Object.assign(crashDetails, preGoneSampleDetails(preGoneSample, nowMs))
}
Object.assign(crashDetails, preGoneSystemMemoryDetails(nowMs))
return crashDetails
}
@@ -0,0 +1,67 @@
import { statfs } from 'node:fs/promises'
import path from 'node:path'
// Why: a system-managed Windows pagefile — and a macOS swapfile — only grows
// into free space on its own volume, so low available commit is a REFUSED
// allocation only when that volume is full too. Linux is excluded on purpose:
// its swap is a fixed partition, a fixed-size swapfile, or zram, none of which
// grow into root-fs free space, so the number would read as headroom that
// cannot exist. The measured volume ships alongside because Windows only names
// the DEFAULT pagefile drive; a relocated pagefile lives elsewhere.
const BYTES_PER_MB = 1024 * 1024
export type SwapVolumeFreeSpace = {
freeMB: number
/** Which volume was measured, separator-trimmed so redaction sees no path. */
volume: string
}
type SwapVolumeFreeSpaceReader = (
platform: NodeJS.Platform
) => Promise<SwapVolumeFreeSpace | undefined>
function swapVolumeAnchor(platform: NodeJS.Platform): string | undefined {
if (platform === 'win32') {
const anchor = process.env.SystemRoot || process.env.SystemDrive
return anchor ? path.parse(anchor).root || anchor : undefined
}
return platform === 'darwin' ? path.sep : undefined
}
function volumeLabel(root: string): string {
const trimmed = root.replace(/[\\/]+$/, '')
return trimmed.length > 0 ? trimmed : root
}
async function statfsSwapVolumeFreeSpace(
platform: NodeJS.Platform
): Promise<SwapVolumeFreeSpace | undefined> {
const root = swapVolumeAnchor(platform)
if (!root) {
return undefined
}
try {
const stats = await statfs(root)
const bytes = Number(stats.bsize) * Number(stats.bavail)
return Number.isFinite(bytes)
? { freeMB: Math.round(Math.max(0, bytes) / BYTES_PER_MB), volume: volumeLabel(root) }
: undefined
} catch {
return undefined
}
}
let swapVolumeFreeSpaceReader: SwapVolumeFreeSpaceReader = statfsSwapVolumeFreeSpace
export function setSwapVolumeFreeSpaceReaderForTest(
reader: SwapVolumeFreeSpaceReader | null
): void {
swapVolumeFreeSpaceReader = reader ?? statfsSwapVolumeFreeSpace
}
export function readSwapVolumeFreeSpace(
platform: NodeJS.Platform = process.platform
): Promise<SwapVolumeFreeSpace | undefined> {
return swapVolumeFreeSpaceReader(platform)
}
@@ -0,0 +1,161 @@
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
import type { SwapVolumeFreeSpace } from './swap-volume-free-space'
// ─── Host system memory for crash reports ───────────────────────────
// Why: the system outlives the crashed process, so this IS sampleable at
// process-gone — it separates "renderer grew huge" from "machine out of
// memory/commit", which the per-process buckets alone cannot. The gone-time
// caller reads AFTER the corpse returned its pages, so free/swapFree read
// healthier than at kill time; the pre-gone sampler carries a live reading past
// that.
// Every reading is labelled `systemMemoryPressureSignal` so no report can be
// read as a pressure verdict the platform never gave:
// win32 — swapFree is MEMORYSTATUSEX.ullAvailPageFile, i.e. available
// COMMIT, which pagefile growth can heal (a 127 MB commit floor healed to
// 2029 MB mid-hold on the win-lowspec repro, killing nothing). Free space on
// the swap volume does NOT establish that it could: a fixed-size or disabled
// pagefile grows into no amount of empty disk, its maximum is unreadable
// here (needs a registry read), and the measured volume is only the DEFAULT
// pagefile drive. So a co-timed volume reading is context beside the commit
// number — `available-commit-volume-cotimed` — never a verdict. The one
// decisive win32 case is a commit limit at or below RAM: no pagefile exists
// to grow, so the floor cannot heal (`available-commit-hard-capped`).
// linux — MemAvailable is the real signal; MemFree is not (it excludes page
// cache and other reclaimable memory).
// darwin — none. `free` stays low on healthy machines and
// fileBacked/purgeable are only a reclaimability proxy. The real signal
// needs `memory_pressure -Q`; Orca's reader for it
// (src/main/memory/host-memory.ts) is on-demand, and spawning a subprocess
// on a 10 s app-lifetime timer costs more than the gap it closes.
type CrashReportDetails = Record<string, CrashReportDetailValue>
export const SYSTEM_MEMORY_KEY_PREFIX = 'systemMemory'
export function memoryKBFieldMB(value: unknown): number | undefined {
const kb = typeof value === 'number' && Number.isFinite(value) ? value : undefined
return kb === undefined ? undefined : Math.round(Math.max(0, kb) / 1024)
}
type SystemMemoryInfoLike = {
total?: unknown
free?: unknown
available?: unknown
swapTotal?: unknown
swapFree?: unknown
fileBacked?: unknown
purgeable?: unknown
}
type SystemMemoryInfoReader = () => SystemMemoryInfoLike | null
/** How far this reading may be read as a "was the host under pressure" verdict. */
export type SystemMemoryPressureSignal =
| 'available-commit-hard-capped'
| 'available-commit-volume-cotimed'
| 'available-commit-unqualified'
| 'mem-available'
| 'none'
function readElectronSystemMemoryInfo(): SystemMemoryInfoLike | null {
const read = (process as NodeJS.Process & { getSystemMemoryInfo?: () => SystemMemoryInfoLike })
.getSystemMemoryInfo
if (typeof read !== 'function') {
return null
}
try {
return read.call(process)
} catch {
return null
}
}
let systemMemoryInfoReader: SystemMemoryInfoReader = readElectronSystemMemoryInfo
export function setSystemMemoryInfoReaderForTest(reader: SystemMemoryInfoReader | null): void {
systemMemoryInfoReader = reader ?? readElectronSystemMemoryInfo
}
function numericDetail(details: CrashReportDetails, suffix: string): number | undefined {
const value = details[`${SYSTEM_MEMORY_KEY_PREFIX}${suffix}`]
return typeof value === 'number' ? value : undefined
}
/** Windows commit limit = RAM + pagefile, so a limit at or below RAM has no pagefile behind it. */
function pagefileBacksCommit(details: CrashReportDetails): boolean | undefined {
const total = numericDetail(details, 'TotalMB')
const swapTotal = numericDetail(details, 'SwapTotalMB')
return total === undefined || swapTotal === undefined ? undefined : swapTotal > total
}
function pressureSignal(
platform: NodeJS.Platform,
details: CrashReportDetails,
volumeCoTimed = true
): SystemMemoryPressureSignal {
if (platform === 'win32' && `${SYSTEM_MEMORY_KEY_PREFIX}SwapFreeMB` in details) {
if (pagefileBacksCommit(details) === false) {
return 'available-commit-hard-capped'
}
return volumeCoTimed && `${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB` in details
? 'available-commit-volume-cotimed'
: 'available-commit-unqualified'
}
if (platform === 'linux' && `${SYSTEM_MEMORY_KEY_PREFIX}AvailableMB` in details) {
return 'mem-available'
}
return 'none'
}
export function getSystemMemoryDetails(
platform: NodeJS.Platform = process.platform
): CrashReportDetails {
const info = systemMemoryInfoReader()
if (!info) {
return {}
}
const details: CrashReportDetails = {}
const fields: readonly [keyof SystemMemoryInfoLike, string][] = [
['total', 'TotalMB'],
['free', 'FreeMB'],
['available', 'AvailableMB'],
['swapTotal', 'SwapTotalMB'],
['swapFree', 'SwapFreeMB'],
['fileBacked', 'FileBackedMB'],
['purgeable', 'PurgeableMB']
]
for (const [field, suffix] of fields) {
const mb = memoryKBFieldMB(info[field])
if (mb !== undefined) {
details[`${SYSTEM_MEMORY_KEY_PREFIX}${suffix}`] = mb
}
}
details[`${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`] = pressureSignal(platform, details)
return details
}
/**
* Merges the statfs-derived volume datum, which needs an await and so is only
* reachable from the periodic sampler, and relabels the reading it sits beside.
*
* `coTimed` false means the statfs outlived the tick that issued it, so this
* volume number and the commit number beside it describe different moments —
* during a pagefile-growth storm that is exactly when they diverge, and a
* pre-storm 40 GB printed next to 200 MB of commit reads as "the pagefile had
* room", the opposite conclusion. The datum still ships (with its own age), but
* only a co-timed one is named in the label.
*/
export function withSwapVolumeFreeSpace(
details: CrashReportDetails,
volume: SwapVolumeFreeSpace,
platform: NodeJS.Platform = process.platform,
coTimed = true
): CrashReportDetails {
const merged: CrashReportDetails = {
...details,
[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB`]: volume.freeMB,
[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolume`]: volume.volume
}
merged[`${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`] = pressureSignal(platform, merged, coTimed)
return merged
}
+19 -12
View File
@@ -77,6 +77,13 @@ describe('getStatus', () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
})
/** `access` targets outside the git dir — i.e. working-tree probes, not conflict-marker reads. */
function conflictFileProbes(): string[] {
return accessMock.mock.calls
.map(([target]) => String(target).replaceAll('\\', '/'))
.filter((target) => !target.includes('/.git/'))
}
it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
accessMock.mockImplementation(async (target: string) => {
@@ -104,11 +111,12 @@ describe('getStatus', () => {
])
})
it('maps deleted conflicts to deleted when the working tree file is absent', async () => {
// The 7th field of a `u` record is the working-tree mode; `000000` is how Git reports an absent path.
it('maps deleted conflicts to deleted from the porcelain working-tree mode', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'u UD N... 100644 100644 000000 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n'
'u UD N... 100644 100644 000000 000000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n'
})
const result = await getStatus('/repo')
@@ -120,10 +128,12 @@ describe('getStatus', () => {
conflictKind: 'deleted_by_them',
conflictStatus: 'unresolved'
})
expect(conflictFileProbes()).toEqual([])
})
it('falls back to modified when the working-tree probe fails for a non-absence reason', async () => {
it('never re-probes the working tree for a conflict row, whatever the filesystem would say', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
// Every probe fails ENOENT (beforeEach) or EIO — neither may reach the row's status.
accessMock.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }))
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
@@ -134,19 +144,14 @@ describe('getStatus', () => {
expect(result.entries[0]?.status).toBe('modified')
expect(result.entries[0]?.conflictKind).toBe('added_by_us')
expect(conflictFileProbes()).toEqual([])
})
// Why both cases normalize separators: git reports the worktree in the WSL guest namespace, and
// the assertion is about which path is probed, not which separator this host's `path` emits.
it('probes the conflict working tree through the distro spelling on Windows', async () => {
it('resolves a WSL conflict row without crossing the 9p share', async () => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n')
accessMock.mockImplementation(async (target: string) => {
if (String(target).endsWith('new.ts')) {
return undefined
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'u DU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n'
@@ -156,10 +161,12 @@ describe('getStatus', () => {
const result = await getStatus('/home/me/repo/feature', { wslDistro: 'Ubuntu' })
const probed = accessMock.mock.calls.map(([target]) => String(target).replaceAll('\\', '/'))
expect(probed).toContain('//wsl.localhost/Ubuntu/home/me/repo/feature/src/new.ts')
// No `\\wsl.localhost` round trip per conflict row: the porcelain `mW` field already answered.
expect(probed).not.toContain('//wsl.localhost/Ubuntu/home/me/repo/feature/src/new.ts')
expect(conflictFileProbes()).toEqual([])
expect(result.entries[0]?.status).toBe('modified')
expect(result.entries[0]?.conflictKind).toBe('deleted_by_us')
// The conflict-marker probes travel the same way.
// The conflict-marker probes still travel through the distro spelling.
expect(
probed.filter((target) =>
target.startsWith('//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/feature/')
@@ -0,0 +1,132 @@
import { spawnSync } from 'node:child_process'
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
writeFileSync
} from 'node:fs'
import { createRequire, isBuiltin } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { removeTreeSync } from '../shared/windows-transient-lock-removal'
/**
* Why the real binary: Electron patches `fs` so a `*.asar` file reports `isDirectory() === true`, so
* a recursive `rm` descends into the archive, `rmdir`s a real file, and fails the parent with
* ENOTEMPTY. Plain Node has no such shim, so no in-process unit test can reproduce it — and every
* worktree that has run `pnpm install` carries a `default_app.asar`, which is what stranded 267
* trash entries on the reporting machine. This runs the shipped `removeHostTree` against a real
* archive under the real binary.
*/
const requireFromTest = createRequire(import.meta.url)
const electronBinary = requireFromTest('electron') as string
const electronDist = join(dirname(requireFromTest.resolve('electron/package.json')), 'dist')
const FIXTURE_ASAR = [
join(electronDist, 'Electron.app/Contents/Resources/default_app.asar'),
join(electronDist, 'resources/default_app.asar')
].find((candidate) => existsSync(candidate))
// Mirrors the residue reported on the failing machine, down to the depth of the blocking leaf.
const ENTRY_NAME = 'wt-1700000000000-abcdef01'
const ASAR_PARENT = 'node_modules/.pnpm/electron/node_modules/electron/dist/App/Contents/Resources'
const roots: string[] = []
afterAll(() => {
for (const root of roots) {
try {
removeTreeSync(root)
} catch {
// A fixture the shim strands is exactly what this file is about; never fail teardown on it.
}
}
})
type ProbeResult = { failure: string | null; residue: string[] }
function buildDriver(bundlePath: string, target: string, resultPath: string): string {
return [
`const fs = require('node:fs')`,
`const { removeHostTree } = require(${JSON.stringify(bundlePath)})`,
// Why noAsar for the read-back: the shim would report the stranded archive as a directory here
// too, so the residue listing has to be taken with real filesystem semantics.
`const withoutAsar = (fn) => { const prev = process.noAsar; process.noAsar = true; try { return fn() } finally { process.noAsar = prev } }`,
`;(async () => {`,
` let failure = null`,
` try { await removeHostTree(${JSON.stringify(target)}) } catch (error) { failure = error.code ?? String(error) }`,
` const residue = withoutAsar(() => fs.existsSync(${JSON.stringify(target)})`,
` ? fs.readdirSync(${JSON.stringify(target)}, { recursive: true }).map(String)`,
` : [])`,
` fs.writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({ failure, residue }))`,
`})()`
].join('\n')
}
async function bundleHostTreeRemoval(outFile: string): Promise<void> {
const { build } = await import('vite')
const result = await build({
root: process.cwd(),
configFile: false,
logLevel: 'error',
build: {
write: false,
minify: false,
ssr: true,
rollupOptions: {
input: 'src/main/host-tree-removal.ts',
// Why mirror `isExternalMainModule` from electron.vite.config.ts exactly — CJS, and
// `original-fs` deliberately *not* externalized: the shipped bundle does not list it either,
// so if the archive-aware `rm` ever became a static import (or the bundler learned to fold
// `createRequire(...)('original-fs')`) production would silently degrade to the shimmed `fs`
// while a test that pre-externalized it kept passing.
output: { format: 'cjs' },
external: (id: string) => isBuiltin(id) || id === 'electron' || id.startsWith('electron/')
}
}
})
const output = (Array.isArray(result) ? result[0] : result) as { output: { code?: string }[] }
const code = output.output[0]?.code
expect(typeof code).toBe('string')
writeFileSync(outFile, code as string, 'utf8')
}
function buildStrandedTree(root: string): string {
const target = join(root, ENTRY_NAME)
const asarParent = join(target, ...ASAR_PARENT.split('/'))
mkdirSync(asarParent, { recursive: true })
copyFileSync(FIXTURE_ASAR as string, join(asarParent, 'default_app.asar'))
writeFileSync(join(asarParent, 'plain.txt'), 'x', 'utf8')
return target
}
describe('removeHostTree against a tree holding an asar archive', () => {
it.runIf(FIXTURE_ASAR)(
'removes the whole tree under the real Electron binary',
async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-host-tree-asar-'))
roots.push(root)
const bundlePath = join(root, 'host-tree-removal.cjs')
await bundleHostTreeRemoval(bundlePath)
const target = buildStrandedTree(root)
const resultPath = join(root, 'result.json')
const driverPath = join(root, 'driver.cjs')
writeFileSync(driverPath, buildDriver(bundlePath, target, resultPath), 'utf8')
const run = spawnSync(electronBinary, [driverPath], {
encoding: 'utf8',
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
timeout: 60_000
})
expect(run.status, run.stderr?.slice(-2000)).toBe(0)
const probe = JSON.parse(readFileSync(resultPath, 'utf8')) as ProbeResult
// Without an asar-transparent `rm` this is `ENOTEMPTY` and the residue stops at the archive,
// on every attempt, forever — it is not a race a retry can win.
expect(probe).toEqual({ failure: null, residue: [] })
},
120_000
)
})
+5 -3
View File
@@ -1,10 +1,12 @@
// Why: every recursive host delete Orca performs (worktrees, terminal history, quarantined recovery
// generations) hits the same Windows stickiness — AV/indexers/late handle releases surface transient
// EBUSY/ENOTEMPTY/EPERM on a tree Node just emptied. One helper so no call site forgets the retries.
// generations) hits the same two hazards, so one helper exists so no call site forgets either.
// Windows stickiness — AV/indexers/late handle releases surface transient EBUSY/ENOTEMPTY/EPERM on a
// tree Node just emptied — and Electron's asar shim, which strands any tree holding a `*.asar`
// (see `asar-transparent-fs`).
import { rm } from 'node:fs/promises'
import { win32 } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { rm } from './asar-transparent-fs'
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
import { isWslUncPath } from '../shared/wsl-paths'
import { transientLockRemovalOptions } from '../shared/windows-transient-lock-removal'
@@ -1,4 +1,4 @@
import { app, type BrowserWindow } from 'electron'
import { runAfterFirstWindowShown } from '../startup/first-window-deferral'
import { reportSecretProtectionGap } from './secret-protection-report'
/**
@@ -72,21 +72,5 @@ export function scheduleSecretProtectionGapReport({
}
}
let ran = false
const run = (): void => {
if (ran) {
return
}
ran = true
clearTimeout(fallback)
// Why setImmediate: keep the blocking keyring probe off the event handler that
// reveals the window, so the reveal paints first.
setImmediate(report)
}
const fallback = setTimeout(run, REPORT_FALLBACK_MS)
fallback.unref?.()
app.once('browser-window-created', (_event: Electron.Event, window: BrowserWindow) => {
window.once('ready-to-show', run)
})
runAfterFirstWindowShown(report, REPORT_FALLBACK_MS)
}
@@ -49,6 +49,7 @@ function recordRendererBreadcrumbTrace(
const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees'
const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn'
const REACT_COMMIT_CASCADE_BREADCRUMB = 'react_commit_cascade'
const REPLAY_GUARD_WEDGED_BREADCRUMB = 'terminal_replay_guard_wedged_release'
const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([
'renderer_error',
'renderer_unhandled_rejection',
@@ -56,6 +57,7 @@ const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([
DUPLICATE_TAB_OWNER_BREADCRUMB,
PARK_VERDICT_CHURN_BREADCRUMB,
REACT_COMMIT_CASCADE_BREADCRUMB,
REPLAY_GUARD_WEDGED_BREADCRUMB,
TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB
])
const RENDERER_BREADCRUMB_COALESCE_MS = 30_000
@@ -69,6 +71,11 @@ const RENDERER_BREADCRUMB_COALESCE_MS = 30_000
// 30-entry ring to two such bursts. `suppressedSinceLast` keeps the pane count
// — the only signal these carry — in one slot.
const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set(['terminal_safe_fit_retry_exhausted'])
// Why: the 30-slot ring is the scarce sink; the durable span stream is not. For
// bounded-rate pane telemetry whose multiplicity is the whole signal, spans are the
// only place a burst survives the restart that clears the ring, so coalesce the ring
// but keep every event's span.
const PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES = new Set([REPLAY_GUARD_WEDGED_BREADCRUMB])
function rendererBreadcrumbCoalesceKey(
name: string,
@@ -77,6 +84,13 @@ function rendererBreadcrumbCoalesceKey(
if (NAME_ONLY_COALESCED_BREADCRUMB_NAMES.has(name)) {
return name
}
// Why presence and not value: `ptyId`/`tabIdHash` are absent on the restore call
// site (layout-serialization restoreScrollbackBuffers) and present on reattach, so
// their presence is the call-site identity a mixed burst would otherwise lose. Four
// slots per storm at most, regardless of pane count.
if (name === REPLAY_GUARD_WEDGED_BREADCRUMB) {
return `${name}:${data?.ptyId ? 'pty' : ''}:${data?.tabIdHash ? 'tab' : ''}`
}
// Why trigger and not name alone: `burst` means damping engaged a commit
// short of React #185, `window` means slow benign churn. Collapsing them
// would drop the near-crash signal into a slow-churn slot. Still bounded —
@@ -191,9 +205,13 @@ export function recordRendererBreadcrumbFromRenderer(
minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS,
...(origin ? { origin } : {})
})
// Why: tracing every suppressed duplicate would preserve the same
// serialization and disk churn that breadcrumb coalescing removes.
if (coalesceResult) {
if (PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES.has(args.name)) {
// Why the raw data: every event already gets its own span, so folding the ring's
// running count in here would double-count in any span-stream total.
recordRendererBreadcrumbTrace(args.name, data)
} else if (coalesceResult) {
// Why gated: tracing every suppressed duplicate would preserve the same
// serialization and disk churn that breadcrumb coalescing removes.
recordRendererBreadcrumbTrace(
args.name,
coalesceResult.suppressedSinceLast > 0
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearCrashBreadcrumbsForTest,
getCrashBreadcrumbSnapshot,
recordCrashBreadcrumb
} from '../crash-reporting/crash-breadcrumb-store'
import { recordRendererBreadcrumbFromRenderer } from './crash-reporting-renderer-breadcrumbs'
type SpanOptions = { attributes: Record<string, unknown> }
const startSpanMock = vi.fn((_name: string, _options: SpanOptions) => ({ end: () => {} }))
vi.mock('../observability/tracer', () => ({
startSpan: (name: string, options: SpanOptions) => startSpanMock(name, options)
}))
const WEDGE_BREADCRUMB = 'terminal_replay_guard_wedged_release'
/** Reattach-path shape: identity-bearing (`tabIdHash`, optionally `ptyId`). */
function emitReattachWedge(pane: number, withPtyId = false): void {
recordRendererBreadcrumbFromRenderer({
name: WEDGE_BREADCRUMB,
data: {
paneId: pane,
leafIdHash: `leaf${String(pane).padStart(5, '0')}`,
tabIdHash: `tab${String(pane).padStart(6, '0')}`,
worktreeIdHash: 'caa15fa9',
...(withPtyId ? { ptyId: `…@@pty-${pane}` } : {})
}
})
}
/** Restore-path shape (restoreScrollbackBuffers): no tabIdHash, no ptyId. */
function emitRestoreWedge(pane: number): void {
recordRendererBreadcrumbFromRenderer({
name: WEDGE_BREADCRUMB,
data: { paneId: pane, leafIdHash: `leaf${String(pane).padStart(5, '0')}` }
})
}
function wedgeCrumbs(): ReturnType<typeof getCrashBreadcrumbSnapshot> {
return getCrashBreadcrumbSnapshot().filter((entry) => entry.name === WEDGE_BREADCRUMB)
}
function wedgeSpanCount(): number {
return startSpanMock.mock.calls.filter(
(call) => call[1].attributes['breadcrumb.name'] === WEDGE_BREADCRUMB
).length
}
beforeEach(() => {
startSpanMock.mockClear()
})
afterEach(() => {
clearCrashBreadcrumbsForTest()
})
// One mount/reveal/wake transition expires every in-flight replay write at once, so
// the burst reaches the 30-slot ring as N distinct entries. Field span streams measure
// bursts of 26 in 0.96s and 62 over 85s. No captured report in the 09-02 corpus shows
// a ring that actually drained — all nine bursts predate their report's ring window —
// so this bounds a demonstrated hazard, not an observed loss, and must not cost the
// durable span evidence that did carry those bursts.
describe('replay-guard wedge burst against the fixed-size breadcrumb ring', () => {
it('costs one ring slot per call site and preserves the pre-crash trail', () => {
for (let index = 0; index < 10; index += 1) {
recordCrashBreadcrumb(`pre_crash_evidence_${index}`, { index })
}
for (let pane = 0; pane < 26; pane += 1) {
emitReattachWedge(pane)
}
const snapshot = getCrashBreadcrumbSnapshot()
expect(snapshot.filter((entry) => entry.name.startsWith('pre_crash_evidence_'))).toHaveLength(
10
)
expect(wedgeCrumbs()).toHaveLength(1)
})
it('carries the burst multiplicity into the ring as suppressedSinceLast', () => {
for (let pane = 0; pane < 26; pane += 1) {
emitReattachWedge(pane)
}
// 26 emissions: one owns the slot, 25 fold into it.
expect(wedgeCrumbs()[0]?.data?.suppressedSinceLast).toBe(25)
})
// The 121-event field corpus lives entirely in the renderer.breadcrumb span stream,
// and the ring is cleared by the restart that usually precedes the crash report, so
// ring coalescing must not suppress the per-event spans.
it('still emits one durable span per wedge event', () => {
for (let pane = 0; pane < 26; pane += 1) {
emitReattachWedge(pane)
}
expect(wedgeSpanCount()).toBe(26)
// Why no count on the span: one span per event already carries the multiplicity.
expect(
startSpanMock.mock.calls.some((call) =>
JSON.stringify(call[1]).includes('suppressedSinceLast')
)
).toBe(false)
})
// Bundle 8907a508 mixes restore-path (identity-less) and reattach-path crumbs in one
// window; name-only keying would report only the last one's shape.
it('keeps restore-path and reattach-path call sites in separate slots', () => {
emitRestoreWedge(1)
emitRestoreWedge(2)
emitReattachWedge(3)
emitReattachWedge(4, true)
const crumbs = wedgeCrumbs()
expect(crumbs).toHaveLength(3)
expect(crumbs.map((crumb) => Boolean(crumb.data?.tabIdHash))).toEqual([false, true, true])
expect(crumbs.map((crumb) => Boolean(crumb.data?.ptyId))).toEqual([false, false, true])
})
it('bounds a many-pane burst to one slot within a call site', () => {
for (let pane = 0; pane < 40; pane += 1) {
emitReattachWedge(pane, pane % 2 === 0)
}
expect(wedgeCrumbs()).toHaveLength(2)
})
})
@@ -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,372 @@
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktreeGraph } from '../repo-worktrees'
import type * as ProjectGroupsModule from '../../shared/project-groups'
import { buildProjectGroupChildIndex, getProjectGroupSubtreeIds } from '../../shared/project-groups'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { getWorktreeMirrorDistro } from '../project-runtime-git-options'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Project } from '../../shared/project-types'
import type { Repo } from '../../shared/repo-types'
import { getAllowedRoots } from './filesystem-allowed-roots'
import { authorizeExternalPath, resolveAuthorizedPath } from './filesystem-auth'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) }
})
vi.mock('../../shared/project-groups', async () => {
const actual = await vi.importActual<typeof ProjectGroupsModule>('../../shared/project-groups')
return {
...actual,
buildProjectGroupChildIndex: vi.fn(actual.buildProjectGroupChildIndex),
getProjectGroupSubtreeIds: vi.fn(actual.getProjectGroupSubtreeIds)
}
})
type StoreFixture = {
repos: Repo[]
projects: Project[]
projectGroups: ProjectGroup[]
folderWorkspaces: FolderWorkspace[]
workspaceDir?: string
}
type StoreCallCounts = {
getRepos: number
getProjects: number
getProjectGroups: number
getFolderWorkspaces: number
}
function makeCountingStore(fixture: StoreFixture): { store: Store; counts: StoreCallCounts } {
const counts: StoreCallCounts = {
getRepos: 0,
getProjects: 0,
getProjectGroups: 0,
getFolderWorkspaces: 0
}
const store = {
getRepos: () => {
counts.getRepos += 1
// Match the real store, which rehydrates fresh repo objects on every read.
return fixture.repos.map((repo) => ({ ...repo }))
},
getProjects: () => {
counts.getProjects += 1
return fixture.projects.map((project) => ({ ...project }))
},
getProjectGroups: () => {
counts.getProjectGroups += 1
return fixture.projectGroups.map((group) => ({ ...group }))
},
getFolderWorkspaces: () => {
counts.getFolderWorkspaces += 1
return fixture.folderWorkspaces.map((workspace) => ({ ...workspace }))
},
getSettings: () => ({ nestWorkspaces: false, workspaceDir: fixture.workspaceDir ?? '' })
} as unknown as Store
return { store, counts }
}
/**
* The pre-change `getAllowedRoots` algorithm, kept verbatim so the equivalence test compares the
* new root list against the old one rather than against a hand-written expectation.
*/
function referenceAllowedRoots(store: Store): string[] {
const scopeStore = store as unknown as {
getRepos: () => Repo[]
getProjectGroups?: () => ProjectGroup[]
getFolderWorkspaces?: () => FolderWorkspace[]
getSettings: () => { workspaceDir?: string; nestWorkspaces?: boolean }
}
const localRepos = scopeStore.getRepos().filter((repo) => !repo.connectionId)
const settings = scopeStore.getSettings()
const scopeRepos = scopeStore.getRepos()
const projectGroups = scopeStore.getProjectGroups?.() ?? []
const isRemoteOnly = (
folderPath: string,
projectGroupId: string,
connectionId: string | null | undefined
): boolean => {
if (connectionId) {
return true
}
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
const candidates = scopeRepos.filter(
(repo) =>
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
isPathInsideOrEqual(folderPath, repo.path)
)
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
}
const folderScopeRoots: string[] = []
for (const group of projectGroups) {
if (group.parentPath && !isRemoteOnly(group.parentPath, group.id, group.connectionId)) {
folderScopeRoots.push(resolve(group.parentPath))
}
}
for (const workspace of scopeStore.getFolderWorkspaces?.() ?? []) {
const connectionId =
workspace.connectionId ??
projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ??
null
if (!isRemoteOnly(workspace.folderPath, workspace.projectGroupId, connectionId)) {
folderScopeRoots.push(resolve(workspace.folderPath))
}
}
const roots = [...localRepos.map((repo) => resolve(repo.path)), ...folderScopeRoots]
if (settings.workspaceDir) {
if (localRepos.length === 0) {
roots.push(resolve(settings.workspaceDir))
} else {
for (const repo of localRepos) {
roots.push(
resolve(
computeWorkspaceRoot(
repo.path,
getWorktreePathSettings(repo, settings as never, getWorktreeMirrorDistro(store, repo))
)
)
)
}
}
}
return roots
}
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'path'>): Repo {
return {
displayName: overrides.id,
badgeColor: '#000000',
addedAt: 1,
kind: 'git',
...overrides
}
}
function makeGroup(overrides: Partial<ProjectGroup> & Pick<ProjectGroup, 'id'>): ProjectGroup {
return {
name: overrides.id,
parentPath: null,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeWorkspace(
overrides: Partial<FolderWorkspace> & Pick<FolderWorkspace, 'id' | 'folderPath'>
): FolderWorkspace {
return {
projectGroupId: 'group-root',
name: overrides.id,
comment: '',
linkedTask: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
/** Repos, nested groups, folder workspaces (one not a git worktree), and an SSH repo. */
function makeMixedFixture(): StoreFixture {
const repos = [
makeRepo({ id: 'repo-local', path: '/repos/app', projectGroupId: 'group-root' }),
makeRepo({ id: 'repo-nested', path: '/repos/nested', projectGroupId: 'group-child' }),
makeRepo({ id: 'repo-folder', path: '/folders/plain', kind: 'folder' }),
makeRepo({
id: 'repo-ssh',
path: '/remote/app',
connectionId: 'ssh-1',
projectGroupId: 'group-remote'
})
]
const projectGroups = [
makeGroup({ id: 'group-root', parentPath: '/folders/root' }),
makeGroup({ id: 'group-child', parentGroupId: 'group-root', parentPath: '/folders/child' }),
makeGroup({ id: 'group-grandchild', parentGroupId: 'group-child' }),
makeGroup({ id: 'group-remote', parentPath: '/remote/scope' }),
makeGroup({ id: 'group-connection', parentPath: '/remote/via-group', connectionId: 'ssh-1' })
]
const folderWorkspaces = [
makeWorkspace({ id: 'ws-git', folderPath: '/folders/root/feature' }),
// Not a git worktree: a plain folder workspace under a folder-kind repo.
makeWorkspace({
id: 'ws-plain',
folderPath: '/folders/plain/scratch',
projectGroupId: 'group-child'
}),
makeWorkspace({ id: 'ws-remote', folderPath: '/remote/ws', projectGroupId: 'group-remote' }),
makeWorkspace({
id: 'ws-connection',
folderPath: '/remote/direct',
projectGroupId: 'group-connection'
}),
makeWorkspace({
id: 'ws-unlinked',
folderPath: '/folders/unlinked',
projectGroupId: 'group-orphan'
})
]
const projects: Project[] = [
{
id: 'project-1',
displayName: 'App',
badgeColor: '#000000',
sourceRepoIds: ['repo-local', 'repo-nested'],
createdAt: 1,
updatedAt: 1
},
{
id: 'project-2',
displayName: 'Folder',
badgeColor: '#000000',
sourceRepoIds: ['repo-folder'],
createdAt: 1,
updatedAt: 1
}
]
return { repos, projects, projectGroups, folderWorkspaces, workspaceDir: '/workspaces' }
}
beforeEach(() => {
invalidateAuthorizedRootsCache()
vi.mocked(buildProjectGroupChildIndex).mockClear()
vi.mocked(getProjectGroupSubtreeIds).mockClear()
})
describe('getAllowedRoots', () => {
it('produces the same roots as the pre-change implementation', () => {
const { store } = makeCountingStore(makeMixedFixture())
expect(getAllowedRoots(store)).toEqual(referenceAllowedRoots(store))
})
it('reads the store once and indexes project groups once per build', () => {
const fixture = makeMixedFixture()
const { store, counts } = makeCountingStore(fixture)
getAllowedRoots(store)
expect.soft(counts.getRepos).toBe(1)
expect.soft(counts.getProjectGroups).toBe(1)
expect.soft(counts.getFolderWorkspaces).toBe(1)
// Batched runtime resolution scans the project list once, not once per local repo.
expect.soft(counts.getProjects).toBe(1)
// The per-scope subtree walk no longer rebuilds the parent->children index.
expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(1)
expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled()
})
})
describe('resolveAuthorizedPath allowed-root reuse', () => {
let repoRoot: string
let outsideRoot: string
let store: Store
let counts: StoreCallCounts
beforeEach(async () => {
repoRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-allowed-roots-'))
outsideRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-outside-'))
const fixture = makeMixedFixture()
fixture.repos = [makeRepo({ id: 'repo-local', path: repoRoot }), ...fixture.repos]
fixture.projects[0]!.sourceRepoIds = ['repo-local']
;({ store, counts } = makeCountingStore(fixture))
})
afterEach(async () => {
await rm(repoRoot, { recursive: true, force: true })
await rm(outsideRoot, { recursive: true, force: true })
})
it('builds the allowed-root list once per call across repeated reads', async () => {
const dirPath = join(repoRoot, 'src')
await mkdir(dirPath)
await writeFile(join(dirPath, 'index.ts'), 'export {}\n')
const callCount = 5
for (let index = 0; index < callCount; index += 1) {
await resolveAuthorizedPath(dirPath, store)
await resolveAuthorizedPath(join(dirPath, 'index.ts'), store)
}
const buildCount = callCount * 2
// One build per authorization, not one per raw-path check plus one per realpath check.
expect.soft(counts.getFolderWorkspaces).toBe(buildCount)
expect.soft(counts.getRepos).toBe(buildCount)
expect.soft(counts.getProjects).toBe(buildCount)
expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(buildCount)
expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled()
})
// Why (both symlink cases): creating a symlink on Windows needs elevation or
// Developer Mode, so these would fail EPERM in setup rather than exercise the
// escape check. Every non-symlink case still runs there.
it.skipIf(process.platform === 'win32')(
'still refuses a symlink that escapes every allowed root',
async () => {
const secret = join(outsideRoot, 'secret.txt')
await writeFile(secret, 'secret\n')
const escape = join(repoRoot, 'escape.txt')
await symlink(secret, escape)
await expect(resolveAuthorizedPath(escape, store)).rejects.toThrow('Access denied')
expect(vi.mocked(listRepoWorktreeGraph)).toHaveBeenCalled()
}
)
it('builds no allowed-root list at all for a granted external path', async () => {
const external = join(outsideRoot, 'external.md')
await writeFile(external, 'notes\n')
authorizeExternalPath(external)
counts.getRepos = 0
counts.getProjects = 0
counts.getFolderWorkspaces = 0
for (let index = 0; index < 5; index += 1) {
await expect(resolveAuthorizedPath(external, store)).resolves.toBe(external)
}
// The grant answers on its own; hoisting the snapshot must not turn zero builds into one per read.
expect.soft(counts.getRepos).toBe(0)
expect.soft(counts.getProjects).toBe(0)
expect.soft(counts.getFolderWorkspaces).toBe(0)
expect.soft(vi.mocked(buildProjectGroupChildIndex)).not.toHaveBeenCalled()
})
it.skipIf(process.platform === 'win32')(
'still refuses a directory symlink that escapes every allowed root',
async () => {
const outsideDir = join(outsideRoot, 'nested')
await mkdir(outsideDir)
await writeFile(join(outsideDir, 'file.txt'), 'secret\n')
const escape = join(repoRoot, 'escape-dir')
await symlink(outsideDir, escape)
await expect(resolveAuthorizedPath(join(escape, 'file.txt'), store)).rejects.toThrow(
'Access denied'
)
}
)
})
+44 -15
View File
@@ -1,9 +1,16 @@
import { resolve } from 'node:path'
import type { Store } from '../persistence'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
import { getWorktreeMirrorDistro } from '../project-runtime-git-options'
import {
getWorktreeMirrorDistroForRuntime,
resolveLocalProjectRuntimesForRepos
} from '../project-runtime-git-options'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
import {
buildProjectGroupChildIndex,
collectProjectGroupSubtreeIds,
type ProjectGroupChildIndex
} from '../../shared/project-groups'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
@@ -11,18 +18,22 @@ import type { Repo } from '../../shared/repo-types'
type FolderScopeStore = Pick<Store, 'getRepos'> &
Partial<Pick<Store, 'getProjectGroups' | 'getFolderWorkspaces'>>
// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths.
function filterLocalRepos(repos: readonly Repo[]): Repo[] {
return repos.filter((repo) => !repo.connectionId)
}
export function getLocalRepos(store: Store) {
// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths.
return store.getRepos().filter((repo) => !repo.connectionId)
return filterLocalRepos(store.getRepos())
}
function getFolderScopeCandidateRepos(
folderPath: string,
projectGroupId: string,
projectGroups: readonly ProjectGroup[],
childGroupIndex: ProjectGroupChildIndex,
repos: readonly Repo[]
): Repo[] {
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
const groupIds = collectProjectGroupSubtreeIds(childGroupIndex, projectGroupId)
return repos.filter(
(repo) =>
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
@@ -34,13 +45,18 @@ function isRemoteOnlyFolderScope(
folderPath: string,
projectGroupId: string,
connectionId: string | null | undefined,
projectGroups: readonly ProjectGroup[],
childGroupIndex: ProjectGroupChildIndex,
repos: readonly Repo[]
): boolean {
if (connectionId) {
return true
}
const candidates = getFolderScopeCandidateRepos(folderPath, projectGroupId, projectGroups, repos)
const candidates = getFolderScopeCandidateRepos(
folderPath,
projectGroupId,
childGroupIndex,
repos
)
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
}
@@ -55,16 +71,22 @@ function getFolderWorkspaceConnectionId(
)
}
function getLocalFolderScopeRoots(store: Store): string[] {
function getLocalFolderScopeRoots(store: Store, repos: readonly Repo[]): string[] {
const scopeStore = store as FolderScopeStore
const repos = scopeStore.getRepos()
// Why: many filesystem tests use narrow Store doubles; folder scopes are additive.
const projectGroups = scopeStore.getProjectGroups?.() ?? []
const childGroupIndex = buildProjectGroupChildIndex(projectGroups)
const roots: string[] = []
for (const group of projectGroups) {
if (
group.parentPath &&
!isRemoteOnlyFolderScope(group.parentPath, group.id, group.connectionId, projectGroups, repos)
!isRemoteOnlyFolderScope(
group.parentPath,
group.id,
group.connectionId,
childGroupIndex,
repos
)
) {
roots.push(resolve(group.parentPath))
}
@@ -75,7 +97,7 @@ function getLocalFolderScopeRoots(store: Store): string[] {
workspace.folderPath,
workspace.projectGroupId,
getFolderWorkspaceConnectionId(workspace, projectGroups),
projectGroups,
childGroupIndex,
repos
)
) {
@@ -86,16 +108,19 @@ function getLocalFolderScopeRoots(store: Store): string[] {
}
export function getAllowedRoots(store: Store): string[] {
const localRepos = getLocalRepos(store)
// Why one read: `getRepos` rehydrates every repo, and this runs twice per filesystem IPC.
const repos = store.getRepos()
const localRepos = filterLocalRepos(repos)
const settings = store.getSettings()
const roots = [
...localRepos.map((repo) => resolve(repo.path)),
...getLocalFolderScopeRoots(store)
...getLocalFolderScopeRoots(store, repos)
]
if (settings.workspaceDir) {
if (localRepos.length === 0) {
roots.push(resolve(settings.workspaceDir))
} else {
const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(store, localRepos)
for (const repo of localRepos) {
roots.push(
resolve(
@@ -104,7 +129,11 @@ export function getAllowedRoots(store: Store): string[] {
// Why enriched here too: placement has to agree with the create
// flow, or renderer file access is denied for a worktree Orca
// just put on the WSL side.
getWorktreePathSettings(repo, settings, getWorktreeMirrorDistro(store, repo))
getWorktreePathSettings(
repo,
settings,
getWorktreeMirrorDistroForRuntime(projectRuntimeByRepoId.get(repo.id))
)
)
)
)
+51 -14
View File
@@ -43,7 +43,24 @@ export function authorizeExternalPath(targetPath: string): void {
} catch {}
}
export function isPathAllowed(targetPath: string, store: Store): boolean {
/**
* One allowed-root list shared by every check in a single authorization.
*
* Lazy so a path already covered by an external grant still builds nothing at all, the way it did
* before the list was hoisted out of the individual checks.
*/
type AllowedRootsSnapshot = { get: () => readonly string[] }
function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot {
let roots: readonly string[] | undefined
return { get: () => (roots ??= getAllowedRoots(store)) }
}
export function isPathAllowed(
targetPath: string,
store: Store,
allowedRoots?: AllowedRootsSnapshot
): boolean {
const resolvedTarget = resolve(targetPath)
if (authorizedExternalPaths.has(resolvedTarget)) {
return true
@@ -53,7 +70,9 @@ export function isPathAllowed(targetPath: string, store: Store): boolean {
return true
}
}
return getAllowedRoots(store).some((root) => isDescendantOrEqual(resolvedTarget, root))
return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) =>
isDescendantOrEqual(resolvedTarget, root)
)
}
export type ResolveAuthorizedPathOptions = {
@@ -69,7 +88,10 @@ export async function resolveAuthorizedPath(
options: ResolveAuthorizedPathOptions = {}
): Promise<string> {
const resolvedTarget = resolve(targetPath)
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store))) {
// Why: the roots depend only on store state, not on the candidate path, so one snapshot serves
// every authorization below; each candidate is still checked against it in full.
const allowedRoots = createAllowedRootsSnapshot(store)
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
@@ -80,14 +102,15 @@ export async function resolveAuthorizedPath(
realParent = await realpath(dirname(resolvedTarget))
} catch (error) {
if (isENOENT(error)) {
return resolveAuthorizedMissingPath(resolvedTarget, store)
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
}
throw error
}
const candidateTarget = resolve(realParent, basename(resolvedTarget))
if (
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
canonicalSourcePath: resolvedTarget
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
@@ -100,7 +123,8 @@ export async function resolveAuthorizedPath(
const realTarget = resolve(await realpath(resolvedTarget))
if (
!(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, {
canonicalSourcePath: resolvedTarget
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
@@ -110,11 +134,15 @@ export async function resolveAuthorizedPath(
if (!isENOENT(error)) {
throw error
}
return resolveAuthorizedMissingPath(resolvedTarget, store)
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
}
}
async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store): Promise<string> {
async function resolveAuthorizedMissingPath(
resolvedTarget: string,
store: Store,
allowedRoots: AllowedRootsSnapshot
): Promise<string> {
let existingAncestor = resolvedTarget
const missingSegments: string[] = []
@@ -124,7 +152,8 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store
const candidateTarget = resolve(realAncestor, ...missingSegments)
if (
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
canonicalSourcePath: resolvedTarget
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
@@ -148,9 +177,9 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store
async function isPathAllowedIncludingRegisteredWorktrees(
targetPath: string,
store: Store,
options: { canonicalSourcePath?: string } = {}
options: { canonicalSourcePath?: string; allowedRoots?: AllowedRootsSnapshot } = {}
): Promise<boolean> {
if (isPathAllowed(targetPath, store)) {
if (isPathAllowed(targetPath, store, options.allowedRoots)) {
return true
}
@@ -158,7 +187,14 @@ async function isPathAllowedIncludingRegisteredWorktrees(
return true
}
if (await isPathAllowedByCanonicalAllowedRoot(targetPath, options.canonicalSourcePath, store)) {
if (
await isPathAllowedByCanonicalAllowedRoot(
targetPath,
options.canonicalSourcePath,
store,
options.allowedRoots
)
) {
return true
}
@@ -178,12 +214,13 @@ async function isPathAllowedIncludingRegisteredWorktrees(
async function isPathAllowedByCanonicalAllowedRoot(
targetPath: string,
sourcePath: string | undefined,
store: Store
store: Store,
allowedRoots?: AllowedRootsSnapshot
): Promise<boolean> {
if (!sourcePath) {
return false
}
for (const root of getAllowedRoots(store)) {
for (const root of allowedRoots?.get() ?? getAllowedRoots(store)) {
const resolvedRoot = resolve(root)
if (!isDescendantOrEqual(sourcePath, resolvedRoot)) {
continue
@@ -0,0 +1,15 @@
import { BrowserWindow } from 'electron'
import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles'
export function broadcastOrcaProfileAuthStatusChanged(): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed()) {
continue
}
try {
window.webContents.send(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL)
} catch {
// A renderer can disappear between isDestroyed() and send().
}
}
}
+8
View File
@@ -45,6 +45,8 @@ import {
signOutCurrentOrcaProfile
} from '../orca-profiles/profile-cloud-service'
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
import { onOrcaCloudSessionInvalidated } from '../orca-profiles/profile-cloud-session-invalidation'
import { broadcastOrcaProfileAuthStatusChanged } from './orca-profile-auth-status-broadcast'
type RegisterOrcaProfileHandlersOptions = {
onBeforeRelaunch?: () => void | Promise<void>
@@ -178,6 +180,12 @@ export function registerOrcaProfileHandlers(
getCurrentOrcaProfileAuthStatus(getProfileUserDataPath())
)
// Why: a background refresh can revoke the session with no renderer request in
// flight, so push the change instead of waiting for the next pane to ask.
// Why not options.onAuthMutation: that hook drives the relay coordinator, which
// is the caller that just failed the refresh — re-entering it here would be a loop.
onOrcaCloudSessionInvalidated(broadcastOrcaProfileAuthStatusChanged)
ipcMain.handle(
'orcaProfiles:createLocal',
(_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => {

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