mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge origin/main into brennanb2025/claude-agent-sdk-structured-chat
# Conflicts: # src/relay/pty-handler.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" /> <img src="docs/assets/wechat-qr-group9.jpg" alt="WeChat group 9 QR code for the Orca community" width="160" />
|
||||
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & 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 })
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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">
|
||||
|
||||
@@ -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" /> <img src="../assets/wechat-qr-group9.jpg" alt="QR code WeChat groupe 9 de la communauté Orca" width="160" />
|
||||
|
||||
- **Feedback & idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
|
||||
- **Confidentialité :** Voir la [doc confidentialité & 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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" /> <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)를 참고하세요.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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&label=%E2%98%85&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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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" />
|
||||
<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).
|
||||
@@ -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" /> <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 收集哪些匿名使用数据以及如何退出。
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
Generated
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -0,0 +1,43 @@
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { rm } from './asar-transparent-fs'
|
||||
|
||||
// Why not an asar fixture here: plain Node has no asar shim to see through, so the archive case can
|
||||
// only be settled by the real binary — `host-tree-removal-asar.electron.test.ts` does that. What
|
||||
// this pins is the other half: outside Electron `original-fs` does not resolve, and the helper has
|
||||
// to degrade to `node:fs/promises` rather than throw at first use.
|
||||
const roots: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
for (const root of roots) {
|
||||
await rm(root, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
describe('asar-transparent rm', () => {
|
||||
it('removes a tree recursively where `original-fs` is unresolvable', async () => {
|
||||
expect(process.versions.electron).toBeUndefined()
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-asar-transparent-'))
|
||||
roots.push(root)
|
||||
const target = join(root, 'wt-1700000000000-abcdef01')
|
||||
await mkdir(join(target, 'nested'), { recursive: true })
|
||||
await writeFile(join(target, 'nested', 'file.txt'), 'x', 'utf8')
|
||||
|
||||
await expect(rm(target, { recursive: true, force: true })).resolves.toBeUndefined()
|
||||
|
||||
expect(existsSync(target)).toBe(false)
|
||||
expect(existsSync(root)).toBe(true)
|
||||
})
|
||||
|
||||
it('honours `force: false` rather than swallowing a missing path', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-asar-transparent-'))
|
||||
roots.push(root)
|
||||
|
||||
await expect(rm(join(root, 'absent'), { recursive: true })).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
// Why: Electron patches `fs` so a `*.asar` file reports `isDirectory() === true`, so Node's
|
||||
// recursive `rm` descends into the archive, tries to `rmdir` a real file, and fails the parent with
|
||||
// ENOTEMPTY. Every worktree that has ever run `pnpm install` carries at least one
|
||||
// (`node_modules/.pnpm/electron@…/…/Electron.app/Contents/Resources/default_app.asar`), so a
|
||||
// worktree removal aborts there deterministically — the residue is not a concurrent-writer race and
|
||||
// no amount of retrying clears it. `original-fs` is Electron's unpatched `fs`; unlike
|
||||
// `process.noAsar` it is scoped to this call rather than to the whole process, which matters because
|
||||
// a multi-GB removal runs for seconds while the main process may still be loading modules out of
|
||||
// `app.asar`. See `cli/appimage-payload-removal.ts` for the same bug at a call site short enough to
|
||||
// use the process-global flag.
|
||||
|
||||
import { rm as nodeRm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
type Rm = typeof nodeRm
|
||||
|
||||
let resolvedRm: Rm | undefined
|
||||
|
||||
function resolveRm(): Rm {
|
||||
try {
|
||||
// Why require and not an import: `original-fs` only exists inside Electron, so vitest, the
|
||||
// `orca` CLI and the plain-node entrypoints must resolve `node:fs/promises` instead — and there
|
||||
// the shim does not exist either, so plain `fs` is already asar-transparent.
|
||||
const originalFs = createRequire(__filename)('original-fs') as { promises?: { rm?: Rm } }
|
||||
return typeof originalFs.promises?.rm === 'function' ? originalFs.promises.rm : nodeRm
|
||||
} catch {
|
||||
return nodeRm
|
||||
}
|
||||
}
|
||||
|
||||
/** `fs.promises.rm` that sees a `*.asar` as the file it is rather than as a directory. */
|
||||
export const rm: Rm = (path, options) => {
|
||||
resolvedRm ??= resolveRm()
|
||||
return resolvedRm(path, options)
|
||||
}
|
||||
@@ -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
@@ -77,6 +77,13 @@ describe('getStatus', () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
})
|
||||
|
||||
/** `access` targets outside the git dir — i.e. working-tree probes, not conflict-marker reads. */
|
||||
function conflictFileProbes(): string[] {
|
||||
return accessMock.mock.calls
|
||||
.map(([target]) => String(target).replaceAll('\\', '/'))
|
||||
.filter((target) => !target.includes('/.git/'))
|
||||
}
|
||||
|
||||
it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => {
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
accessMock.mockImplementation(async (target: string) => {
|
||||
@@ -104,11 +111,12 @@ describe('getStatus', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('maps deleted conflicts to deleted when the working tree file is absent', async () => {
|
||||
// The 7th field of a `u` record is the working-tree mode; `000000` is how Git reports an absent path.
|
||||
it('maps deleted conflicts to deleted from the porcelain working-tree mode', async () => {
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'u UD N... 100644 100644 000000 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n'
|
||||
'u UD N... 100644 100644 000000 000000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n'
|
||||
})
|
||||
|
||||
const result = await getStatus('/repo')
|
||||
@@ -120,10 +128,12 @@ describe('getStatus', () => {
|
||||
conflictKind: 'deleted_by_them',
|
||||
conflictStatus: 'unresolved'
|
||||
})
|
||||
expect(conflictFileProbes()).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to modified when the working-tree probe fails for a non-absence reason', async () => {
|
||||
it('never re-probes the working tree for a conflict row, whatever the filesystem would say', async () => {
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
// Every probe fails ENOENT (beforeEach) or EIO — neither may reach the row's status.
|
||||
accessMock.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' }))
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout:
|
||||
@@ -134,19 +144,14 @@ describe('getStatus', () => {
|
||||
|
||||
expect(result.entries[0]?.status).toBe('modified')
|
||||
expect(result.entries[0]?.conflictKind).toBe('added_by_us')
|
||||
expect(conflictFileProbes()).toEqual([])
|
||||
})
|
||||
|
||||
// Why both cases normalize separators: git reports the worktree in the WSL guest namespace, and
|
||||
// the assertion is about which path is probed, not which separator this host's `path` emits.
|
||||
it('probes the conflict working tree through the distro spelling on Windows', async () => {
|
||||
it('resolves a WSL conflict row without crossing the 9p share', async () => {
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n')
|
||||
accessMock.mockImplementation(async (target: string) => {
|
||||
if (String(target).endsWith('new.ts')) {
|
||||
return undefined
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
})
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'u DU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n'
|
||||
@@ -156,10 +161,12 @@ describe('getStatus', () => {
|
||||
const result = await getStatus('/home/me/repo/feature', { wslDistro: 'Ubuntu' })
|
||||
|
||||
const probed = accessMock.mock.calls.map(([target]) => String(target).replaceAll('\\', '/'))
|
||||
expect(probed).toContain('//wsl.localhost/Ubuntu/home/me/repo/feature/src/new.ts')
|
||||
// No `\\wsl.localhost` round trip per conflict row: the porcelain `mW` field already answered.
|
||||
expect(probed).not.toContain('//wsl.localhost/Ubuntu/home/me/repo/feature/src/new.ts')
|
||||
expect(conflictFileProbes()).toEqual([])
|
||||
expect(result.entries[0]?.status).toBe('modified')
|
||||
expect(result.entries[0]?.conflictKind).toBe('deleted_by_us')
|
||||
// The conflict-marker probes travel the same way.
|
||||
// The conflict-marker probes still travel through the distro spelling.
|
||||
expect(
|
||||
probed.filter((target) =>
|
||||
target.startsWith('//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/feature/')
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire, isBuiltin } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { removeTreeSync } from '../shared/windows-transient-lock-removal'
|
||||
|
||||
/**
|
||||
* Why the real binary: Electron patches `fs` so a `*.asar` file reports `isDirectory() === true`, so
|
||||
* a recursive `rm` descends into the archive, `rmdir`s a real file, and fails the parent with
|
||||
* ENOTEMPTY. Plain Node has no such shim, so no in-process unit test can reproduce it — and every
|
||||
* worktree that has run `pnpm install` carries a `default_app.asar`, which is what stranded 267
|
||||
* trash entries on the reporting machine. This runs the shipped `removeHostTree` against a real
|
||||
* archive under the real binary.
|
||||
*/
|
||||
const requireFromTest = createRequire(import.meta.url)
|
||||
const electronBinary = requireFromTest('electron') as string
|
||||
const electronDist = join(dirname(requireFromTest.resolve('electron/package.json')), 'dist')
|
||||
const FIXTURE_ASAR = [
|
||||
join(electronDist, 'Electron.app/Contents/Resources/default_app.asar'),
|
||||
join(electronDist, 'resources/default_app.asar')
|
||||
].find((candidate) => existsSync(candidate))
|
||||
|
||||
// Mirrors the residue reported on the failing machine, down to the depth of the blocking leaf.
|
||||
const ENTRY_NAME = 'wt-1700000000000-abcdef01'
|
||||
const ASAR_PARENT = 'node_modules/.pnpm/electron/node_modules/electron/dist/App/Contents/Resources'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of roots) {
|
||||
try {
|
||||
removeTreeSync(root)
|
||||
} catch {
|
||||
// A fixture the shim strands is exactly what this file is about; never fail teardown on it.
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type ProbeResult = { failure: string | null; residue: string[] }
|
||||
|
||||
function buildDriver(bundlePath: string, target: string, resultPath: string): string {
|
||||
return [
|
||||
`const fs = require('node:fs')`,
|
||||
`const { removeHostTree } = require(${JSON.stringify(bundlePath)})`,
|
||||
// Why noAsar for the read-back: the shim would report the stranded archive as a directory here
|
||||
// too, so the residue listing has to be taken with real filesystem semantics.
|
||||
`const withoutAsar = (fn) => { const prev = process.noAsar; process.noAsar = true; try { return fn() } finally { process.noAsar = prev } }`,
|
||||
`;(async () => {`,
|
||||
` let failure = null`,
|
||||
` try { await removeHostTree(${JSON.stringify(target)}) } catch (error) { failure = error.code ?? String(error) }`,
|
||||
` const residue = withoutAsar(() => fs.existsSync(${JSON.stringify(target)})`,
|
||||
` ? fs.readdirSync(${JSON.stringify(target)}, { recursive: true }).map(String)`,
|
||||
` : [])`,
|
||||
` fs.writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({ failure, residue }))`,
|
||||
`})()`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function bundleHostTreeRemoval(outFile: string): Promise<void> {
|
||||
const { build } = await import('vite')
|
||||
const result = await build({
|
||||
root: process.cwd(),
|
||||
configFile: false,
|
||||
logLevel: 'error',
|
||||
build: {
|
||||
write: false,
|
||||
minify: false,
|
||||
ssr: true,
|
||||
rollupOptions: {
|
||||
input: 'src/main/host-tree-removal.ts',
|
||||
// Why mirror `isExternalMainModule` from electron.vite.config.ts exactly — CJS, and
|
||||
// `original-fs` deliberately *not* externalized: the shipped bundle does not list it either,
|
||||
// so if the archive-aware `rm` ever became a static import (or the bundler learned to fold
|
||||
// `createRequire(...)('original-fs')`) production would silently degrade to the shimmed `fs`
|
||||
// while a test that pre-externalized it kept passing.
|
||||
output: { format: 'cjs' },
|
||||
external: (id: string) => isBuiltin(id) || id === 'electron' || id.startsWith('electron/')
|
||||
}
|
||||
}
|
||||
})
|
||||
const output = (Array.isArray(result) ? result[0] : result) as { output: { code?: string }[] }
|
||||
const code = output.output[0]?.code
|
||||
expect(typeof code).toBe('string')
|
||||
writeFileSync(outFile, code as string, 'utf8')
|
||||
}
|
||||
|
||||
function buildStrandedTree(root: string): string {
|
||||
const target = join(root, ENTRY_NAME)
|
||||
const asarParent = join(target, ...ASAR_PARENT.split('/'))
|
||||
mkdirSync(asarParent, { recursive: true })
|
||||
copyFileSync(FIXTURE_ASAR as string, join(asarParent, 'default_app.asar'))
|
||||
writeFileSync(join(asarParent, 'plain.txt'), 'x', 'utf8')
|
||||
return target
|
||||
}
|
||||
|
||||
describe('removeHostTree against a tree holding an asar archive', () => {
|
||||
it.runIf(FIXTURE_ASAR)(
|
||||
'removes the whole tree under the real Electron binary',
|
||||
async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-host-tree-asar-'))
|
||||
roots.push(root)
|
||||
const bundlePath = join(root, 'host-tree-removal.cjs')
|
||||
await bundleHostTreeRemoval(bundlePath)
|
||||
const target = buildStrandedTree(root)
|
||||
const resultPath = join(root, 'result.json')
|
||||
const driverPath = join(root, 'driver.cjs')
|
||||
writeFileSync(driverPath, buildDriver(bundlePath, target, resultPath), 'utf8')
|
||||
|
||||
const run = spawnSync(electronBinary, [driverPath], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
timeout: 60_000
|
||||
})
|
||||
expect(run.status, run.stderr?.slice(-2000)).toBe(0)
|
||||
|
||||
const probe = JSON.parse(readFileSync(resultPath, 'utf8')) as ProbeResult
|
||||
// Without an asar-transparent `rm` this is `ENOTEMPTY` and the residue stops at the archive,
|
||||
// on every attempt, forever — it is not a race a retry can win.
|
||||
expect(probe).toEqual({ failure: null, residue: [] })
|
||||
},
|
||||
120_000
|
||||
)
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
// Why: every recursive host delete Orca performs (worktrees, terminal history, quarantined recovery
|
||||
// generations) hits the same Windows stickiness — AV/indexers/late handle releases surface transient
|
||||
// EBUSY/ENOTEMPTY/EPERM on a tree Node just emptied. One helper so no call site forgets the retries.
|
||||
// generations) hits the same two hazards, so one helper exists so no call site forgets either.
|
||||
// Windows stickiness — AV/indexers/late handle releases surface transient EBUSY/ENOTEMPTY/EPERM on a
|
||||
// tree Node just emptied — and Electron's asar shim, which strands any tree holding a `*.asar`
|
||||
// (see `asar-transparent-fs`).
|
||||
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { win32 } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { rm } from './asar-transparent-fs'
|
||||
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
|
||||
import { isWslUncPath } from '../shared/wsl-paths'
|
||||
import { transientLockRemovalOptions } from '../shared/windows-transient-lock-removal'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, type BrowserWindow } from 'electron'
|
||||
import { runAfterFirstWindowShown } from '../startup/first-window-deferral'
|
||||
import { reportSecretProtectionGap } from './secret-protection-report'
|
||||
|
||||
/**
|
||||
@@ -72,21 +72,5 @@ export function scheduleSecretProtectionGapReport({
|
||||
}
|
||||
}
|
||||
|
||||
let ran = false
|
||||
const run = (): void => {
|
||||
if (ran) {
|
||||
return
|
||||
}
|
||||
ran = true
|
||||
clearTimeout(fallback)
|
||||
// Why setImmediate: keep the blocking keyring probe off the event handler that
|
||||
// reveals the window, so the reveal paints first.
|
||||
setImmediate(report)
|
||||
}
|
||||
|
||||
const fallback = setTimeout(run, REPORT_FALLBACK_MS)
|
||||
fallback.unref?.()
|
||||
app.once('browser-window-created', (_event: Electron.Event, window: BrowserWindow) => {
|
||||
window.once('ready-to-show', run)
|
||||
})
|
||||
runAfterFirstWindowShown(report, REPORT_FALLBACK_MS)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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().
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user