mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 00:02:43 +00:00
Merge remote-tracking branch 'origin/main' into orchestration-v3
# Conflicts: # config/scripts/skill-description-length.test.mjs # resources/skills/current-manifest.json # resources/skills/snapshot-registry.json # skill-guides/orchestration.md # skills/orchestration/SKILL.md # src/cli/bundled-skill-guides.ts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// The relay's copy of the ConPTY teardown release, and the guard that keeps it in lockstep with the
|
||||
// desktop's own node-pty patch. pnpm patches do not cross the SSH boundary, so a relay runs the tree
|
||||
// `npm install` put there; the desktop had this fix and the relay did not, and every terminal on a
|
||||
// Windows SSH host leaked one File handle for the life of the relay process.
|
||||
//
|
||||
// The ORDER of the conin release is the fix. Releasing it at the top of the branch -- what the
|
||||
// desktop patch does -- was measured at 3x WORSE than shipping nothing (File +2/terminal and a new
|
||||
// Process +1/terminal); releasing it after the console-list fork and the native kill is flat.
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
assertPatchedNodePtyWindowsTeardown,
|
||||
patchNodePtyWindowsTeardown
|
||||
} = require('../relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs')
|
||||
const projectDir = resolve(import.meta.dirname, '..', '..')
|
||||
const cleanupDirs = []
|
||||
|
||||
const PATCHED_FILES = ['windowsPtyAgent.js', 'windowsTerminal.js']
|
||||
|
||||
/** The hunks config/patches/node-pty@1.1.0.patch adds to the installed desktop tree. */
|
||||
const DESKTOP_HUNKS = {
|
||||
'windowsPtyAgent.js': [
|
||||
[
|
||||
[
|
||||
' this._inSocket.readable = false;',
|
||||
' // The non-DLL path previously only flipped `readable`, leaving the',
|
||||
' // conin PipeWrap alive until the host exited (#947).',
|
||||
' this._inSocket.destroy();',
|
||||
' this._outSocket.readable = false;',
|
||||
''
|
||||
].join('\n'),
|
||||
[
|
||||
' this._inSocket.readable = false;',
|
||||
' this._outSocket.readable = false;',
|
||||
''
|
||||
].join('\n')
|
||||
],
|
||||
// The useConptyDll branch, which only the DESKTOP runs -- the relay takes the
|
||||
// non-DLL branch above, where the dispose is already unconditional. Listed here
|
||||
// so un-applying still yields published; the relay asset needs no counterpart.
|
||||
[
|
||||
[
|
||||
' // Orca: dispose unconditionally, as the non-DLL branch above does.',
|
||||
" // Waiting for another 'data' event leaks the conout worker on every",
|
||||
' // self-exiting shell, because no more data ever arrives (F24).',
|
||||
' this._conoutSocketWorker.dispose();',
|
||||
''
|
||||
].join('\n'),
|
||||
[
|
||||
" this._outSocket.on('data', function () {",
|
||||
' _this._conoutSocketWorker.dispose();',
|
||||
' });',
|
||||
''
|
||||
].join('\n')
|
||||
]
|
||||
],
|
||||
'windowsTerminal.js': [
|
||||
[
|
||||
' // Attach before readiness so a broken ConPTY output pipe cannot be unhandled.',
|
||||
null
|
||||
],
|
||||
[' // A ConPTY input-pipe error must retire only this terminal.', null]
|
||||
]
|
||||
}
|
||||
|
||||
function desktopPath(file) {
|
||||
return join(projectDir, 'node_modules', 'node-pty', 'lib', file)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of cleanupDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Windows SSH relay node-pty ConPTY teardown patch', () => {
|
||||
// Why reconstruct rather than vendor upstream: the installed tree IS the published file plus the
|
||||
// desktop's hunks, so un-applying them yields upstream exactly -- and pinning that against this
|
||||
// asset's own hashes is what fails loudly if either side of the pair moves.
|
||||
it('takes the desktop error listeners verbatim', () => {
|
||||
const fixture = writeNodePtyFixture('1.1.0')
|
||||
patchNodePtyWindowsTeardown(fixture.root)
|
||||
|
||||
expect(readFileSync(join(fixture.libDir, 'windowsTerminal.js'), 'utf8')).toBe(
|
||||
readFileSync(desktopPath('windowsTerminal.js'), 'utf8')
|
||||
)
|
||||
})
|
||||
|
||||
// The one hunk that must NOT match the desktop, and the reason is measured, not stylistic:
|
||||
// releasing conin before `_getConsoleProcessList()` forks aborts teardown partway.
|
||||
it('releases conin after the console-list fork, not before it like the desktop patch', () => {
|
||||
const fixture = writeNodePtyFixture('1.1.0')
|
||||
patchNodePtyWindowsTeardown(fixture.root)
|
||||
const patched = readFileSync(join(fixture.libDir, 'windowsPtyAgent.js'), 'utf8')
|
||||
|
||||
const branch = patched.slice(
|
||||
patched.indexOf('if (!this._useConptyDll) {'),
|
||||
patched.indexOf('else {', patched.indexOf('if (!this._useConptyDll) {'))
|
||||
)
|
||||
expect(branch).toContain('this._inSocket.destroy();')
|
||||
expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan(
|
||||
branch.indexOf('this._conoutSocketWorker.dispose();')
|
||||
)
|
||||
expect(branch.indexOf('this._inSocket.destroy();')).toBeGreaterThan(
|
||||
branch.indexOf('this._getConsoleProcessList()')
|
||||
)
|
||||
// Pinned so a future "sync the relay asset to config/patches" cannot copy the regression back.
|
||||
expect(patched).not.toBe(readFileSync(desktopPath('windowsPtyAgent.js'), 'utf8'))
|
||||
})
|
||||
|
||||
it('installs and verifies idempotently', () => {
|
||||
const fixture = writeNodePtyFixture('1.1.0')
|
||||
|
||||
patchNodePtyWindowsTeardown(fixture.root)
|
||||
const once = PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))
|
||||
for (const file of PATCHED_FILES) {
|
||||
expect(existsSync(`${join(fixture.libDir, file)}.orca-patch-${process.pid}`)).toBe(false)
|
||||
}
|
||||
expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow()
|
||||
|
||||
patchNodePtyWindowsTeardown(fixture.root)
|
||||
expect(PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))).toEqual(
|
||||
once
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a different package version or unexpected source', () => {
|
||||
const wrongVersion = writeNodePtyFixture('1.2.0-beta.11')
|
||||
expect(() => patchNodePtyWindowsTeardown(wrongVersion.root)).toThrow('expected 1.1.0')
|
||||
|
||||
for (const file of PATCHED_FILES) {
|
||||
const drifted = writeNodePtyFixture('1.1.0')
|
||||
const path = join(drifted.libDir, file)
|
||||
writeFileSync(path, `${readFileSync(path, 'utf8')}\n// drift`)
|
||||
expect(() => patchNodePtyWindowsTeardown(drifted.root)).toThrow('unexpected node-pty')
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a half-applied tree, so one file cannot pass for both', () => {
|
||||
for (const file of PATCHED_FILES) {
|
||||
const partial = writeNodePtyFixture('1.1.0')
|
||||
const fixture = writeNodePtyFixture('1.1.0')
|
||||
patchNodePtyWindowsTeardown(fixture.root)
|
||||
writeFileSync(join(partial.libDir, file), readFileSync(join(fixture.libDir, file), 'utf8'))
|
||||
expect(() => assertPatchedNodePtyWindowsTeardown(partial.root)).toThrow('is not installed')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** A published node-pty tree, rebuilt by un-applying the desktop hunks from the installed one. */
|
||||
function writeNodePtyFixture(version) {
|
||||
const root = mkdtempSync(join(projectDir, '.node-pty-teardown-patch-test-'))
|
||||
cleanupDirs.push(root)
|
||||
const libDir = join(root, 'node_modules', 'node-pty', 'lib')
|
||||
mkdirSync(libDir, { recursive: true })
|
||||
writeFileSync(join(root, 'node_modules', 'node-pty', 'package.json'), JSON.stringify({ version }))
|
||||
for (const file of PATCHED_FILES) {
|
||||
const desktop = readFileSync(desktopPath(file), 'utf8')
|
||||
for (const [marker] of DESKTOP_HUNKS[file]) {
|
||||
expect(desktop).toContain(marker)
|
||||
}
|
||||
writeFileSync(join(libDir, file), unapplyDesktopHunks(file, desktop))
|
||||
}
|
||||
return { root, libDir }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse of the published-to-desktop transform.
|
||||
*
|
||||
* `windowsTerminal.js` is taken verbatim from the desktop, so the asset's own replacement table is
|
||||
* the transform and reversing it is exact. `windowsPtyAgent.js` deliberately diverges, so its
|
||||
* published form is rebuilt from the desktop hunk instead -- which is also what makes this file the
|
||||
* place that notices if the desktop hunk itself ever moves.
|
||||
*/
|
||||
function unapplyDesktopHunks(file, desktop) {
|
||||
if (file === 'windowsPtyAgent.js') {
|
||||
let published = desktop
|
||||
for (const [patched, original] of DESKTOP_HUNKS[file]) {
|
||||
expect(published.split(patched).length - 1).toBe(1)
|
||||
published = published.replace(patched, original)
|
||||
}
|
||||
return published
|
||||
}
|
||||
const asset = readFileSync(
|
||||
join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'),
|
||||
'utf8'
|
||||
)
|
||||
const { PATCH_TARGETS } = loadPatchTargets(asset)
|
||||
const target = PATCH_TARGETS.find((entry) => entry.relativePath.at(-1) === file)
|
||||
expect(target).toBeDefined()
|
||||
let published = desktop
|
||||
for (const [from, to] of target.replacements.toReversed()) {
|
||||
expect(published.split(to).length - 1).toBe(1)
|
||||
published = published.replace(to, from)
|
||||
}
|
||||
return published
|
||||
}
|
||||
|
||||
function loadPatchTargets(assetSource) {
|
||||
const module = { exports: {} }
|
||||
const factory = new Function(
|
||||
'module',
|
||||
'exports',
|
||||
'require',
|
||||
`${assetSource}\nmodule.exports.PATCH_TARGETS = PATCH_TARGETS`
|
||||
)
|
||||
factory(module, module.exports, require)
|
||||
return module.exports
|
||||
}
|
||||
@@ -1,36 +1,39 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
// Why: the Agent Skills spec caps `description` at 1,024 characters after YAML folding, and
|
||||
// conforming installers reject a skill over it (#17935). Every skills/*/SKILL.md must fit.
|
||||
const SKILLS_ROOT = resolve(import.meta.dirname, '../../skills')
|
||||
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(skillText) {
|
||||
const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(skillText)?.[1] ?? ''
|
||||
return parse(frontmatter)?.description ?? ''
|
||||
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', () => {
|
||||
it('stay within the Agent Skills 1024-character limit', () => {
|
||||
const skills = readdirSync(SKILLS_ROOT, { withFileTypes: true }).filter((entry) =>
|
||||
entry.isDirectory()
|
||||
)
|
||||
expect(skills.length).toBeGreaterThan(0)
|
||||
const skillNames = readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
|
||||
const violations = skills.flatMap((entry) => {
|
||||
const description = readDescription(
|
||||
readFileSync(join(SKILLS_ROOT, entry.name, 'SKILL.md'), 'utf8')
|
||||
)
|
||||
if (!description.trim()) {
|
||||
return [`${entry.name}: missing description`]
|
||||
}
|
||||
return description.length > MAX_DESCRIPTION_LENGTH
|
||||
? [`${entry.name}: ${description.length} chars (limit ${MAX_DESCRIPTION_LENGTH})`]
|
||||
: []
|
||||
})
|
||||
expect(violations).toEqual([])
|
||||
it('discovers the bundled skills', () => {
|
||||
expect(skillNames).toContain('orchestration')
|
||||
})
|
||||
|
||||
it.each(skillNames)('%s keeps description within the Agent Skills spec limit', (name) => {
|
||||
const description = readDescription(name)
|
||||
|
||||
expect(typeof description, `${name}: description must be a string`).toBe('string')
|
||||
expect(description.trim().length, `${name}: description is empty`).toBeGreaterThan(0)
|
||||
expect(
|
||||
description.length,
|
||||
`${name}: description is ${description.length} chars`
|
||||
).toBeLessThanOrEqual(MAX_DESCRIPTION_LENGTH)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,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 |
@@ -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.
|
||||
|
||||
@@ -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)를 참고하세요.
|
||||
|
||||
@@ -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 收集哪些匿名使用数据以及如何退出。
|
||||
|
||||
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:
|
||||
|
||||
@@ -157,7 +157,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
|
||||
@@ -12195,7 +12195,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
|
||||
|
||||
|
||||
@@ -170,7 +170,10 @@ export function installPtyInspectIpcHandlers(deps: {
|
||||
|
||||
ipcMain.handle(
|
||||
'pty:inspectProcess',
|
||||
async (_event, args: { id: string; expectedIncarnationId?: string }) => {
|
||||
async (
|
||||
_event,
|
||||
args: { id: string; expectedIncarnationId?: string; scanChildProcesses?: boolean }
|
||||
) => {
|
||||
// Why: same routing hazard as pty:hasPty — an unroutable id must read as client-only unverifiable, not as a local-provider answer or a raised IPC error.
|
||||
if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) {
|
||||
return clientOnlyUnverifiableInspection('terminal_gone')
|
||||
@@ -182,10 +185,14 @@ export function installPtyInspectIpcHandlers(deps: {
|
||||
if (!hasPtyProviderForInspection(args.id)) {
|
||||
return clientOnlyUnverifiableInspection('terminal_gone')
|
||||
}
|
||||
return args.expectedIncarnationId
|
||||
? inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id, {
|
||||
expectedIncarnationId: args.expectedIncarnationId
|
||||
})
|
||||
const options = {
|
||||
...(args.expectedIncarnationId
|
||||
? { expectedIncarnationId: args.expectedIncarnationId }
|
||||
: {}),
|
||||
...(args.scanChildProcesses === true ? { scanChildProcesses: true } : {})
|
||||
}
|
||||
return Object.keys(options).length > 0
|
||||
? inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id, options)
|
||||
: inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { sshProviders } from '../provider/registry'
|
||||
import { listProcessesWithHostScopeFromRuntimeController } from './inventory-operations'
|
||||
import type { PtyRuntimeControllerDeps } from './controller-deps'
|
||||
|
||||
/**
|
||||
* `hostScopeCensusIsComplete` discounts a `runtime:` host in `omittedHostIds` on the strength of
|
||||
* one fact about this process: it has no paired-runtime PTY provider, so it never queried that
|
||||
* host and never owed it coverage. This file pins the producer side of that fact.
|
||||
*
|
||||
* What it catches: a new branch here that spells a queried host `runtime:`. Every id this
|
||||
* function emits is built by `toSshExecutionHostId` or is `LOCAL_EXECUTION_HOST_ID`, so a third
|
||||
* shape is the observable form of "a runtime host can now answer an inventory" — at which point
|
||||
* the client predicate would start calling a genuine gap complete.
|
||||
*
|
||||
* What it does NOT catch, so do not lean on it: a runtime-backed transport registered under an
|
||||
* SSH connection id still reports as `ssh:` and passes, which is fine — the predicate only
|
||||
* discounts the `runtime:` spelling. The consolidation moving the SSH path onto orcad is expected
|
||||
* to look exactly like that. The other route into `queriedHostIds` is separately fenced to
|
||||
* `kind === 'ssh'` in `orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts`.
|
||||
*/
|
||||
describe('the hosts a PTY inventory can report having queried', () => {
|
||||
afterEach(() => {
|
||||
sshProviders.clear()
|
||||
})
|
||||
|
||||
it('emits only local and ssh spellings, never a paired-runtime one', async () => {
|
||||
const listProcesses = vi.fn(async () => [])
|
||||
sshProviders.set('box-1', { listProcesses } as never)
|
||||
// A connection id shaped like an environment uuid still has to come back `ssh:`; the spelling
|
||||
// is what the gate keys on, so a `runtime:` id appearing here is the breakage that matters.
|
||||
sshProviders.set('a2478221-1d5c-4603-b8bf-b6b728eac9df', { listProcesses } as never)
|
||||
|
||||
const { hostIds } = await listProcessesWithHostScopeFromRuntimeController({
|
||||
runtime: null
|
||||
} as unknown as PtyRuntimeControllerDeps)
|
||||
|
||||
expect(hostIds).toContain('ssh:a2478221-1d5c-4603-b8bf-b6b728eac9df')
|
||||
expect(new Set(hostIds.map((hostId) => parseExecutionHostId(hostId)?.kind))).toEqual(
|
||||
new Set(['local', 'ssh'])
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a provider that threw rather than reporting its host as queried', async () => {
|
||||
sshProviders.set('box-live', { listProcesses: vi.fn(async () => []) } as never)
|
||||
sshProviders.set('box-down', {
|
||||
listProcesses: vi.fn(async () => {
|
||||
throw new Error('relay unavailable')
|
||||
})
|
||||
} as never)
|
||||
|
||||
const { hostIds } = await listProcessesWithHostScopeFromRuntimeController({
|
||||
runtime: { markPtyLivenessUnverifiable: vi.fn() }
|
||||
} as unknown as PtyRuntimeControllerDeps)
|
||||
|
||||
expect(hostIds).toContain('ssh:box-live')
|
||||
expect(hostIds).not.toContain('ssh:box-down')
|
||||
})
|
||||
})
|
||||
@@ -26,8 +26,7 @@ import {
|
||||
type DetectedWorktreeSideEffectToken
|
||||
} from './detected-worktree-scan-cache'
|
||||
import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics'
|
||||
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta'
|
||||
|
||||
export async function listDetectedWorktreesForCapturedRepo(
|
||||
store: Store,
|
||||
@@ -40,9 +39,7 @@ export async function listDetectedWorktreesForCapturedRepo(
|
||||
providerAbort?.signal.aborted
|
||||
? ({ providerAbortStatus: providerAbort.status() } as const)
|
||||
: undefined
|
||||
const allMeta = isFolderRepo(repo)
|
||||
? undefined
|
||||
: readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
|
||||
const allMeta = isFolderRepo(repo) ? undefined : readAllWorktreeMetaForRepo(store, repo)
|
||||
// Why: only the disconnected fallbacks read this, so keep parseWorktreeId over the whole host snapshot
|
||||
// off the connected path entirely.
|
||||
let cachedSshWorktreeMetaIndex: SshWorktreeMetaIndex | undefined
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
warnOnce
|
||||
} from './worktree-listing-diagnostics'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
|
||||
import {
|
||||
readAllWorktreeMetaForHost,
|
||||
readAllWorktreeMetaForRepo
|
||||
} from '../../../persistence/host-qualified-worktree-meta'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
|
||||
const WORKTREE_LIST_ALL_CONCURRENCY = 8
|
||||
@@ -174,9 +177,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
if (!repo) {
|
||||
return []
|
||||
}
|
||||
const allMeta = repo.connectionId
|
||||
? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
|
||||
: undefined
|
||||
const allMeta = repo.connectionId ? readAllWorktreeMetaForRepo(store, repo) : undefined
|
||||
const sshWorktreeMetaIndex = repo.connectionId
|
||||
? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {}))
|
||||
: new Map()
|
||||
@@ -226,7 +227,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
})
|
||||
}
|
||||
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
|
||||
const metadata = allMeta ?? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
|
||||
const metadata = allMeta ?? readAllWorktreeMetaForRepo(store, repo)
|
||||
return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata)
|
||||
.filter((worktree) => worktree.visible)
|
||||
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata))
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../sh
|
||||
import type { Store } from '../../../persistence/loading-store/store'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
readWorktreeMetaForHost,
|
||||
readWorktreeMetaForRepo,
|
||||
writeWorktreeMetaForHost
|
||||
} from '../../../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership'
|
||||
@@ -159,7 +159,7 @@ export function buildDetectedGitWorktrees(
|
||||
const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined
|
||||
const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {})
|
||||
const meta =
|
||||
readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) ??
|
||||
readWorktreeMetaForRepo(store, worktreeId, repo) ??
|
||||
getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
const detected = toDetectedWorktree({
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
readWorktreeMetaForHost,
|
||||
readWorktreeMetaForRepo,
|
||||
writeWorktreeMetaForHost
|
||||
} from '../../../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership'
|
||||
@@ -44,7 +44,7 @@ export function resolveWorktreeMetaWithDiscoveryBackfill(
|
||||
// Why: the locator-keyed row is only a stand-in for a missing snapshot, so don't read it when we have one.
|
||||
const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined
|
||||
const existing =
|
||||
readWorktreeMetaForHost(store, worktreeId, executionHostId) ??
|
||||
readWorktreeMetaForRepo(store, worktreeId, repo) ??
|
||||
getRepoOwnedWorktreeMeta(
|
||||
repo,
|
||||
worktreeId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
|
||||
/**
|
||||
@@ -52,6 +53,26 @@ export function readWorktreeMetaForHost(
|
||||
return store.getWorktreeMetaForHost?.(worktreeId, executionHostId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same two reads keyed off a repo row, so the resolve-then-read pair lives in one place. Four
|
||||
* call sites had open-coded it identically, which is the shape that lets one copy drift from the
|
||||
* rest (F7/F8).
|
||||
*/
|
||||
export function readAllWorktreeMetaForRepo(
|
||||
store: Pick<HostQualifiedWorktreeMetaStore, 'getAllWorktreeMeta' | 'getAllWorktreeMetaForHost'>,
|
||||
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
|
||||
): Record<string, WorktreeMeta> {
|
||||
return readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
|
||||
}
|
||||
|
||||
export function readWorktreeMetaForRepo(
|
||||
store: Pick<HostQualifiedWorktreeMetaStore, 'getWorktreeMetaForHost'>,
|
||||
worktreeId: string,
|
||||
repo: Pick<Repo, 'connectionId' | 'executionHostId'>
|
||||
): WorktreeMeta | undefined {
|
||||
return readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo))
|
||||
}
|
||||
|
||||
export function writeWorktreeMetaForHost(
|
||||
store: Pick<HostQualifiedWorktreeMetaStore, 'setWorktreeMeta' | 'setWorktreeMetaForHost'>,
|
||||
worktreeId: string,
|
||||
|
||||
@@ -11,14 +11,24 @@ export type PtyProcessInspection = TerminalProcessInspection
|
||||
type CompletionSensitivePtyProvider = IPtyProvider & {
|
||||
inspectProcess?: (
|
||||
id: string,
|
||||
options?: { expectedIncarnationId?: PtyIncarnationId }
|
||||
options?: PtyProcessInspectionOptions
|
||||
) => Promise<PtyProcessInspection>
|
||||
}
|
||||
|
||||
/**
|
||||
* `scanChildProcesses` marks a read whose answer decides something once, rather than a poll that
|
||||
* self-corrects on its next tick. Only hosts where the child answer costs a process-table read
|
||||
* act on it; everywhere else the answer was already captured.
|
||||
*/
|
||||
export type PtyProcessInspectionOptions = {
|
||||
expectedIncarnationId?: PtyIncarnationId
|
||||
scanChildProcesses?: boolean
|
||||
}
|
||||
|
||||
export async function inspectPtyProviderProcess(
|
||||
provider: IPtyProvider,
|
||||
ptyId: string,
|
||||
options?: { expectedIncarnationId?: PtyIncarnationId }
|
||||
options?: PtyProcessInspectionOptions
|
||||
): Promise<PtyProcessInspection> {
|
||||
if (provider.hasPty?.(ptyId) === false) {
|
||||
throw new Error('terminal_gone')
|
||||
@@ -37,7 +47,7 @@ export async function inspectPtyProviderProcess(
|
||||
export async function inspectPtyProviderProcessForRenderer(
|
||||
provider: IPtyProvider,
|
||||
ptyId: string,
|
||||
options?: { expectedIncarnationId?: PtyIncarnationId }
|
||||
options?: PtyProcessInspectionOptions
|
||||
): Promise<PtyProcessInspection> {
|
||||
try {
|
||||
return await inspectPtyProviderProcess(provider, ptyId, options)
|
||||
|
||||
@@ -56,13 +56,15 @@ export function createSshPtyProviderRpcOperations({ mux, toRelayPtyId }: SshPtyP
|
||||
// Guarded by ssh-pty-inspect-observation-identity.test.ts; #17525 removes the poll.
|
||||
inspectProcess: async (
|
||||
id: string,
|
||||
options?: { expectedIncarnationId?: string }
|
||||
options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean }
|
||||
): Promise<PtyProcessInspection> => {
|
||||
return (await mux.request('pty.inspectProcess', {
|
||||
id: toRelayPtyId(id),
|
||||
...(options?.expectedIncarnationId
|
||||
? { expectedIncarnationId: options.expectedIncarnationId }
|
||||
: {})
|
||||
: {}),
|
||||
// Additive request member: an older relay ignores it and answers as it always did.
|
||||
...(options?.scanChildProcesses === true ? { scanChildProcesses: true } : {})
|
||||
})) as PtyProcessInspection
|
||||
},
|
||||
serialize: async (ids: string[]): Promise<string> => {
|
||||
|
||||
@@ -63,7 +63,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
this.rpcOperations.getForegroundProcess(id)
|
||||
inspectProcess = (
|
||||
id: string,
|
||||
options?: { expectedIncarnationId?: string }
|
||||
options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean }
|
||||
): Promise<PtyProcessInspection> => this.rpcOperations.inspectProcess(id, options)
|
||||
serialize = (ids: string[]): Promise<string> => this.rpcOperations.serialize(ids)
|
||||
revive = (state: string): Promise<void> => this.rpcOperations.revive(state)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* A shell that exits by itself must still close its pseudoconsole.
|
||||
*
|
||||
* `ClosePseudoConsole` is the only thing that reaps a ConPTY's console host —
|
||||
* Orca's own job-ownership patch says so, because `CreatePseudoConsole` spawns
|
||||
* that host before the per-pty job exists and it is therefore not a job member.
|
||||
* Upstream node-pty calls it from exactly one place, `PtyKill`, which begins by
|
||||
* looking the baton up by id — and the exit watcher in `SetupExitCallback`
|
||||
* erased the baton the moment the shell died. So on the self-exit path (typing
|
||||
* `exit`, which is how panes usually close) that lookup missed, `PtyKill` did
|
||||
* nothing at all, and the pseudoconsole was never closed.
|
||||
*
|
||||
* There is a SECOND, independent defect on the same path: the `useConptyDll`
|
||||
* branch of `WindowsPtyAgent.kill()` disposed the conout worker only from an
|
||||
* `_outSocket.on('data')` handler, and no more data arrives once the shell has
|
||||
* gone — so that worker leaked too. The non-DLL branch beside it already
|
||||
* disposed unconditionally. The desktop always sets `useConptyDll`, so it hit
|
||||
* both; the relay sets neither and hit only the first.
|
||||
*
|
||||
* Measured on Windows 11 / awin, 20 cycles, handles bucketed by NT object type,
|
||||
* totals before -> after:
|
||||
*
|
||||
* self-exit, relay spawn 225 -> 285 becomes 219 -> 219 FLAT
|
||||
* self-exit, desktop spawn 239 -> 439 becomes 222 -> 222 FLAT
|
||||
* explicit kill, relay spawn 225 -> 285 becomes 219 -> 219 FLAT
|
||||
* explicit kill, desktop spawn 235 -> 395 becomes 219 -> 219 FLAT
|
||||
*
|
||||
* Neither fix alone is enough on the desktop: the pseudoconsole close is worth
|
||||
* +1 Process +1 File per terminal, the dispose +2 Thread +4 File.
|
||||
*
|
||||
* WHY THIS IS A PATCH-CONTENT PIN AND NOT A BEHAVIOURAL TEST: the defect is
|
||||
* only observable as a per-NT-type handle count, which needs
|
||||
* `NtQuerySystemInformation(SystemExtendedHandleInformation)`. Nothing in the
|
||||
* repo can read that, and the cheaper Windows-observable proxies do not
|
||||
* discriminate — the console host process is reaped either way (the leak is a
|
||||
* handle to an already-exited object, not an orphaned process), and the
|
||||
* `\\.\pipe\conpty-*` entries disappear either way. Both were measured and
|
||||
* rejected as assertions rather than assumed. So this pins the mechanism
|
||||
* instead, which is the real risk: a future resync of the vendored patch
|
||||
* silently dropping the hunk.
|
||||
*/
|
||||
|
||||
const PATCH = readFileSync(join(__dirname, '../../../config/patches/node-pty@1.1.0.patch'), 'utf8')
|
||||
|
||||
/**
|
||||
* Just the `PtyKill` hunk. Several markers below also occur in the `PtyConnect`
|
||||
* hunk above it, and a bare `indexOf` on the whole patch silently matched the
|
||||
* wrong one — an assertion that then held regardless of what `PtyKill` did.
|
||||
*/
|
||||
const ptyKillHunk = (() => {
|
||||
// Anchored on the hunk header's function context rather than its line
|
||||
// numbers, which shift whenever anything above it in the patch changes.
|
||||
const header = /^@@ .* @@ static Napi::Value PtyKill\(.*$/m.exec(PATCH)
|
||||
if (!header) {
|
||||
throw new Error('no PtyKill hunk in config/patches/node-pty@1.1.0.patch')
|
||||
}
|
||||
const from = header.index
|
||||
const next = PATCH.indexOf('\n@@ ', from + 1)
|
||||
return PATCH.slice(from, next === -1 ? undefined : next)
|
||||
})()
|
||||
|
||||
/**
|
||||
* `indexOf` that throws instead of returning -1. A missing marker must fail the
|
||||
* assertion that depends on it, not quietly make a slice or comparison vacuous.
|
||||
*/
|
||||
function indexIn(haystack: string, marker: string): number {
|
||||
const at = haystack.indexOf(marker)
|
||||
if (at === -1) {
|
||||
throw new Error(`marker not found in the PtyKill hunk: ${marker}`)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
describe('node-pty patch: pseudoconsole close on the self-exit path', () => {
|
||||
it('does not let the exit watcher free the baton while the close is still owed', () => {
|
||||
// Pinned as one block: the erase must stay INSIDE the consoleClosed guard.
|
||||
// Upstream ran it unconditionally, which is the line that caused the leak,
|
||||
// and a resync that re-flattens this is the failure mode worth catching.
|
||||
expect(PATCH).toContain(
|
||||
[
|
||||
'+ baton->shellExited = true;',
|
||||
'+ if (baton->consoleClosed) {',
|
||||
'+ const bool removed = remove_pty_baton(baton->id);',
|
||||
'+ assert(removed);',
|
||||
'+ (void)removed;',
|
||||
'+ }'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('closes the pseudoconsole from PtyKill even after the shell has exited', () => {
|
||||
// hpc is copied out under the lock, so the close survives the baton's removal.
|
||||
expect(PATCH).toContain('+ hpc = handle->hpc;')
|
||||
expect(PATCH).toContain('+ pfnClosePseudoConsole(hpc);')
|
||||
})
|
||||
|
||||
it('resolves the ConPTY DLL before it claims the close', () => {
|
||||
// LoadConptyDll throws when conpty.dll is missing. Throwing after
|
||||
// consoleClosed was set would strand the pseudoconsole for good: the retry
|
||||
// finds the work claimed and does nothing.
|
||||
//
|
||||
// Anchored inside PtyKill, not by a bare indexOf: the identical line also
|
||||
// appears in the PtyConnect hunk, earlier in the file, and matching that one
|
||||
// made this assertion pass no matter where PtyKill resolved the DLL.
|
||||
const dllResolve = indexIn(
|
||||
ptyKillHunk,
|
||||
'+ HANDLE hLibrary = LoadConptyDll(info, useConptyDll);'
|
||||
)
|
||||
const claim = indexIn(ptyKillHunk, '+ handle->consoleClosed = true;')
|
||||
expect(dllResolve).toBeLessThan(claim)
|
||||
})
|
||||
|
||||
it('reaches hShell only under the null check the watcher can trip', () => {
|
||||
// Pinned as one block. The watcher nulls hShell on exit, and upstream
|
||||
// dereferenced it unconditionally; every remaining use — the duplication and
|
||||
// the failure fallback below it — must stay inside this guard.
|
||||
const start = indexIn(ptyKillHunk, '+ if (useConptyDll && handle->hShell != nullptr) {')
|
||||
const end = indexIn(ptyKillHunk, '+ if (handle->shellExited) {')
|
||||
const guarded = ptyKillHunk.slice(start, end)
|
||||
expect(guarded).toContain('DuplicateHandle(GetCurrentProcess(), handle->hShell')
|
||||
expect(guarded).toContain('TerminateProcess(handle->hShell, 1);')
|
||||
// No ADDED line outside that guard may terminate through hShell. Removed
|
||||
// (`-`) lines still carry upstream's unguarded call, which is the point.
|
||||
const strayAdds = PATCH.replace(guarded, '')
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('+') && line.includes('TerminateProcess(handle->hShell'))
|
||||
expect(strayAdds).toEqual([])
|
||||
})
|
||||
|
||||
it('frees the baton from PtyKill when the shell has already exited', () => {
|
||||
// The other half of the two-sided handshake. Without it a self-exit followed
|
||||
// by kill() — the ordinary pane close — leaks one baton and one entry in the
|
||||
// vector get_pty_baton scans linearly, forever.
|
||||
expect(ptyKillHunk).toContain(
|
||||
[
|
||||
'+ if (handle->shellExited) {',
|
||||
'+ const bool removed = remove_pty_baton(id);',
|
||||
'+ assert(removed);',
|
||||
'+ (void)removed;'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('still kills the shell when DuplicateHandle fails', () => {
|
||||
// A null hShellDup is indistinguishable from the self-exit case, so a
|
||||
// swallowed failure would leave the shell running after its pane closed —
|
||||
// a worse outcome than the leak this patch exists to fix.
|
||||
expect(PATCH).toContain(
|
||||
[
|
||||
'+ hShellDup = nullptr;',
|
||||
'+ TerminateProcess(handle->hShell, 1);',
|
||||
'+ }'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the close idempotent so a second kill cannot double-close', () => {
|
||||
expect(PATCH).toContain('+ if (handle != nullptr && !handle->consoleClosed) {')
|
||||
expect(PATCH).toContain('+ handle->consoleClosed = true;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('node-pty patch: conout worker disposal on the self-exit path', () => {
|
||||
// The desktop's larger half: 8 of its 10 leaked handles per terminal.
|
||||
it('disposes the conout worker unconditionally in the useConptyDll branch', () => {
|
||||
expect(PATCH).toContain('+ this._conoutSocketWorker.dispose();')
|
||||
// The data handler is what never fired once the shell had gone.
|
||||
expect(PATCH).toContain("- this._outSocket.on('data', function () {")
|
||||
expect(PATCH).toContain('- _this._conoutSocketWorker.dispose();')
|
||||
})
|
||||
|
||||
it('applies the same change to the TypeScript source the patch also carries', () => {
|
||||
expect(PATCH).toContain('+ this._conoutSocketWorker.dispose();')
|
||||
expect(PATCH).toContain("- this._outSocket.on('data', () => {")
|
||||
})
|
||||
})
|
||||
@@ -153,7 +153,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
|
||||
|
||||
async inspectTerminalProcess(
|
||||
terminalSelector: string,
|
||||
options?: { expectedIncarnationId?: string }
|
||||
options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean }
|
||||
): Promise<PtyProcessInspection> {
|
||||
const leaf = this.resolveLiveLeafForHandle(terminalSelector)
|
||||
if (!leaf?.ptyId || !this.ptyController) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// The host half of the same contract: an RPC schema silently strips keys it does not declare, which
|
||||
// is exactly what forward compatibility needs and exactly how a caller's option can vanish inside
|
||||
// one version. `scanChildProcesses` has to be declared here, and only here -- the sibling handle
|
||||
// methods have no use for it and must keep refusing it.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ZodType } from 'zod'
|
||||
import { TERMINAL_QUERY_METHODS } from './terminal-query-methods'
|
||||
import { TerminalHandle, TerminalInspectProcess } from './unary-schemas'
|
||||
|
||||
/** The method as registered, so a schema swap on the definition cannot pass unseen. */
|
||||
function inspectProcessMethod() {
|
||||
const method = TERMINAL_QUERY_METHODS.find((entry) => entry.name === 'terminal.inspectProcess')
|
||||
if (!method) {
|
||||
throw new Error('terminal.inspectProcess is not registered')
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
async function callRegisteredHandler(
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ terminal: string; options: unknown }> {
|
||||
const method = inspectProcessMethod()
|
||||
const parsed = (method.params as ZodType).parse(params)
|
||||
const inspectTerminalProcess = vi.fn(async () => ({
|
||||
foregroundProcess: null,
|
||||
hasChildProcesses: false
|
||||
}))
|
||||
await method.handler(parsed, { runtime: { inspectTerminalProcess } } as never, undefined as never)
|
||||
const [terminal, options] = inspectTerminalProcess.mock.calls[0] as unknown as [string, unknown]
|
||||
return { terminal, options }
|
||||
}
|
||||
|
||||
describe('terminal.inspectProcess registration', () => {
|
||||
// The half the schema test alone cannot see: pointing the method back at the shared handle schema
|
||||
// compiles, parses, and silently drops the option. This exercises the registered definition.
|
||||
it('carries scanChildProcesses from the wire into the runtime call', async () => {
|
||||
await expect(
|
||||
callRegisteredHandler({ terminal: 'term_1', scanChildProcesses: true })
|
||||
).resolves.toEqual({ terminal: 'term_1', options: { scanChildProcesses: true } })
|
||||
})
|
||||
|
||||
it('carries it alongside the incarnation fence', async () => {
|
||||
await expect(
|
||||
callRegisteredHandler({
|
||||
terminal: 'term_1',
|
||||
expectedIncarnationId: 'inc-1',
|
||||
scanChildProcesses: true
|
||||
})
|
||||
).resolves.toEqual({
|
||||
terminal: 'term_1',
|
||||
options: { expectedIncarnationId: 'inc-1', scanChildProcesses: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the legacy one-argument shape for a bare poll', async () => {
|
||||
await expect(callRegisteredHandler({ terminal: 'term_1' })).resolves.toEqual({
|
||||
terminal: 'term_1',
|
||||
options: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal.inspectProcess params', () => {
|
||||
it('preserves scanChildProcesses', () => {
|
||||
expect(TerminalInspectProcess.parse({ terminal: 'term_1', scanChildProcesses: true })).toEqual({
|
||||
terminal: 'term_1',
|
||||
scanChildProcesses: true
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves it alongside the incarnation fence', () => {
|
||||
expect(
|
||||
TerminalInspectProcess.parse({
|
||||
terminal: 'term_1',
|
||||
expectedIncarnationId: 'inc-1',
|
||||
scanChildProcesses: true
|
||||
})
|
||||
).toEqual({ terminal: 'term_1', expectedIncarnationId: 'inc-1', scanChildProcesses: true })
|
||||
})
|
||||
|
||||
it('leaves it absent for a polling caller', () => {
|
||||
expect(TerminalInspectProcess.parse({ terminal: 'term_1' })).toEqual({ terminal: 'term_1' })
|
||||
})
|
||||
|
||||
// The shape that produced the bug, pinned so nobody "simplifies" the method back onto the shared
|
||||
// handle schema: TerminalHandle drops the option on the floor without complaining.
|
||||
it('shows why the shared handle schema could not carry it', () => {
|
||||
expect(TerminalHandle.parse({ terminal: 'term_1', scanChildProcesses: true })).toEqual({
|
||||
terminal: 'term_1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineMethod, type RpcAnyMethod } from '../../core'
|
||||
import {
|
||||
TerminalHandle,
|
||||
TerminalInspectProcess,
|
||||
TerminalListParams,
|
||||
TerminalRead,
|
||||
TerminalRecoverPane,
|
||||
@@ -65,15 +66,21 @@ export const TERMINAL_QUERY_METHODS: RpcAnyMethod[] = [
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.inspectProcess',
|
||||
params: TerminalHandle,
|
||||
handler: async (params, { runtime }) => ({
|
||||
process: await runtime.inspectTerminalProcess(
|
||||
params.terminal,
|
||||
params.expectedIncarnationId
|
||||
params: TerminalInspectProcess,
|
||||
handler: async (params, { runtime }) => {
|
||||
const options = {
|
||||
...(params.expectedIncarnationId
|
||||
? { expectedIncarnationId: params.expectedIncarnationId }
|
||||
: undefined
|
||||
)
|
||||
})
|
||||
: {}),
|
||||
...(params.scanChildProcesses === true ? { scanChildProcesses: true } : {})
|
||||
}
|
||||
return {
|
||||
process: await runtime.inspectTerminalProcess(
|
||||
params.terminal,
|
||||
Object.keys(options).length > 0 ? options : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.isRunningAgent',
|
||||
|
||||
@@ -13,6 +13,17 @@ export const TerminalFocus = TerminalHandle.extend({
|
||||
navigation: z.enum(['caller', 'host']).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* `terminal.inspectProcess` carries one member the sibling handle methods must not: whether the
|
||||
* caller's answer decides something once, which is what licenses the host to pay for a process-table
|
||||
* read. Extended rather than added to `TerminalHandle` so `clearBuffer`/`agentStatus`/`isRunningAgent`
|
||||
* keep refusing an option they have no use for.
|
||||
*/
|
||||
export const TerminalInspectProcess = TerminalHandle.extend({
|
||||
// Additive request member understood by newer hosts; legacy hosts safely ignore it.
|
||||
scanChildProcesses: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const TerminalListParams = z.object({
|
||||
worktree: OptionalString,
|
||||
limit: OptionalFiniteNumber,
|
||||
|
||||
@@ -40,14 +40,18 @@ function metadata(overrides: Partial<WorktreeMeta> = {}): WorktreeMeta {
|
||||
}
|
||||
}
|
||||
|
||||
function queries(store: RuntimeStore): RuntimeManagedWorktreeQueries {
|
||||
function queries(
|
||||
store: RuntimeStore,
|
||||
overrides: Partial<ConstructorParameters<typeof RuntimeManagedWorktreeQueries>[0]> = {}
|
||||
): RuntimeManagedWorktreeQueries {
|
||||
return new RuntimeManagedWorktreeQueries({
|
||||
getStore: () => store,
|
||||
listResolved: async () => [],
|
||||
resolveRepo: async () => store.getRepos()[0]!,
|
||||
selectRepos: () => store.getRepos(),
|
||||
scanRepo: async () => ({ ok: true, worktrees: [] }),
|
||||
listKnownHostIds: () => []
|
||||
listKnownHostIds: () => [],
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,3 +109,115 @@ describe('RuntimeManagedWorktreeQueries.listDetected', () => {
|
||||
expect(legacy.worktrees[0]).not.toHaveProperty('visibilitySource')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RuntimeManagedWorktreeQueries.list host scope', () => {
|
||||
// Measured on hardware before this fix, same runtime and same refusing SSH host in the same
|
||||
// second: the UNSCOPED listing reported `omittedHostIds: ["local","ssh:ssh-scope-refused"]` with
|
||||
// `--host` selectors, while the SCOPED listing reported `{"hostIds":[],"omittedHostIds":[]}`.
|
||||
// A listing that covered nothing, reporting no gaps, is indistinguishable from a repo that
|
||||
// genuinely has no worktrees -- the thing docs/reference/ssh-execution-boundary.md forbids.
|
||||
function sshStore(): RuntimeStore {
|
||||
const repo = folderRepo({
|
||||
id: 'repo-ssh',
|
||||
kind: 'git',
|
||||
connectionId: 'conn-1',
|
||||
path: '/home/dev/app'
|
||||
})
|
||||
return {
|
||||
getRepos: () => [repo],
|
||||
getRepo: () => repo,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getSettings: () => settings
|
||||
} as unknown as RuntimeStore
|
||||
}
|
||||
|
||||
it('names the scoped repo host as omitted when the listing covered nothing', async () => {
|
||||
const result = await queries(sshStore()).list('repo-ssh', 50)
|
||||
|
||||
expect(result.totalCount).toBe(0)
|
||||
expect(result.hostScope).toEqual({
|
||||
hostIds: [],
|
||||
omittedHostIds: ['ssh:conn-1']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not report the scoped host as omitted once it contributes rows', async () => {
|
||||
const store = sshStore()
|
||||
const result = await queries(store, {
|
||||
listResolved: async () =>
|
||||
[
|
||||
{
|
||||
id: 'repo-ssh::/home/dev/app',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/home/dev/app',
|
||||
hostId: 'ssh:conn-1'
|
||||
}
|
||||
] as never
|
||||
}).list('repo-ssh', 50)
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['ssh:conn-1'])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual([])
|
||||
})
|
||||
|
||||
// The caller scoped the listing, so the hosts they excluded must not come back as gaps.
|
||||
it('never names a host the caller scoped out', async () => {
|
||||
const scoped = await queries(sshStore(), {
|
||||
listKnownHostIds: () => ['local', 'ssh:other', 'runtime:elsewhere'] as never
|
||||
}).list('repo-ssh', 50)
|
||||
|
||||
expect(scoped.hostScope?.omittedHostIds).toEqual(['ssh:conn-1'])
|
||||
})
|
||||
|
||||
// `getRepoExecutionHostId` derives the host from two spellings, and a scoped listing that named
|
||||
// the wrong one would be worse than naming none. These pin both.
|
||||
it('names the local host for a scoped local repo', async () => {
|
||||
const repo = folderRepo({ id: 'repo-local', kind: 'git', path: '/workspace/local' })
|
||||
const store = {
|
||||
getRepos: () => [repo],
|
||||
getRepo: () => repo,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getSettings: () => settings
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const result = await queries(store).list('repo-local', 50)
|
||||
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['local'])
|
||||
})
|
||||
|
||||
it('prefers executionHostId over connectionId for the scoped host', async () => {
|
||||
const repo = folderRepo({
|
||||
id: 'repo-runtime',
|
||||
kind: 'git',
|
||||
connectionId: 'conn-legacy',
|
||||
executionHostId: 'runtime:env-1',
|
||||
path: '/workspace/runtime'
|
||||
})
|
||||
const store = {
|
||||
getRepos: () => [repo],
|
||||
getRepo: () => repo,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getSettings: () => settings
|
||||
} as unknown as RuntimeStore
|
||||
|
||||
const result = await queries(store).list('repo-runtime', 50)
|
||||
|
||||
expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-1'])
|
||||
})
|
||||
|
||||
it('still reports every configured host when the listing is unscoped', async () => {
|
||||
const unscoped = await queries(sshStore(), {
|
||||
listKnownHostIds: () => ['local', 'ssh:conn-1'] as never
|
||||
}).list(undefined, 50)
|
||||
|
||||
expect(unscoped.hostScope?.omittedHostIds).toEqual(['local', 'ssh:conn-1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DetectedWorktreeListResult, Worktree } from '../../shared/worktree
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { RuntimeWorktreeListResult } from '../../shared/runtime-types'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { buildWorktreeListingPage } from './worktree-listing-host-scope'
|
||||
import { buildWorktreeListingPage, listingKnownHostIds } from './worktree-listing-host-scope'
|
||||
import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
@@ -80,7 +80,7 @@ export class RuntimeManagedWorktreeQueries {
|
||||
throw new Error('invalid_limit')
|
||||
}
|
||||
const resolved = await this.deps.listResolved()
|
||||
const repoId = repoSelector ? (await this.deps.resolveRepo(repoSelector)).id : null
|
||||
const scopedRepo = repoSelector ? await this.deps.resolveRepo(repoSelector) : null
|
||||
const pathsByRepo = new Map<string, string[]>()
|
||||
for (const worktree of resolved) {
|
||||
const paths = pathsByRepo.get(worktree.repoId) ?? []
|
||||
@@ -100,12 +100,12 @@ export class RuntimeManagedWorktreeQueries {
|
||||
)
|
||||
const worktrees = resolved.filter(
|
||||
(worktree) =>
|
||||
(!repoId || worktree.repoId === repoId) &&
|
||||
(!scopedRepo || worktree.repoId === scopedRepo.id) &&
|
||||
this.isVisible(worktree, matchers.get(worktree.repoId), sourceDefaultsSupported)
|
||||
)
|
||||
// Why: a `--repo` listing was scoped by the caller, so naming every configured host as
|
||||
// omitted would report a gap the caller deliberately excluded.
|
||||
return buildWorktreeListingPage(worktrees, limit, repoId ? [] : this.deps.listKnownHostIds())
|
||||
// See `listingKnownHostIds`: a scoped listing must still name the host it was asked about.
|
||||
const knownHostIds = listingKnownHostIds(scopedRepo, () => this.deps.listKnownHostIds())
|
||||
return buildWorktreeListingPage(worktrees, limit, knownHostIds)
|
||||
}
|
||||
|
||||
resolveRepoForConnection(selector: string, connectionId?: string | null): Promise<Repo> {
|
||||
|
||||
@@ -109,7 +109,7 @@ export type RuntimePtyController = {
|
||||
getForegroundProcess(ptyId: string): Promise<string | null>
|
||||
inspectProcess?(
|
||||
ptyId: string,
|
||||
options?: { expectedIncarnationId?: PtyIncarnationId }
|
||||
options?: { expectedIncarnationId?: PtyIncarnationId; scanChildProcesses?: boolean }
|
||||
): Promise<PtyProcessInspection>
|
||||
confirmForegroundProcess?(ptyId: string): Promise<string | null>
|
||||
confirmShellForeground?(ptyId: string): Promise<boolean>
|
||||
|
||||
@@ -17,7 +17,10 @@ export type WorktreeHostRouting<T extends LaunchHostRepo> =
|
||||
| { kind: 'resolved'; hostId: ExecutionHostId; repo: T | null }
|
||||
/** No row carries this repo id and the worktree names no host — nothing ever named a host. */
|
||||
| { kind: 'unowned' }
|
||||
/** Rival rows disagree about the host; guessing one is the cross-host leak. */
|
||||
/**
|
||||
* No single trustworthy host: rival rows disagree, or the resolved row named one that cannot be
|
||||
* parsed. Guessing is the cross-host leak in both cases.
|
||||
*/
|
||||
| { kind: 'ambiguous' }
|
||||
|
||||
/**
|
||||
@@ -33,7 +36,10 @@ export function resolveWorktreeHostRouting<T extends LaunchHostRepo & ExecutionH
|
||||
): WorktreeHostRouting<T> {
|
||||
const resolution = resolveWorktreeExecutionHost(createRepoRowExecutionHostLookup(repos), worktree)
|
||||
if (resolution.kind === 'unresolved') {
|
||||
return resolution.reason === 'ambiguous' ? { kind: 'ambiguous' } : { kind: 'unowned' }
|
||||
// Only `unknown` — nothing anywhere carries the id — becomes `unowned`, which callers dispose of
|
||||
// as a plain local folder. `malformed` is a row that declared a host and named an unparseable
|
||||
// one, so it joins `ambiguous`: guessing is the cross-host leak either way.
|
||||
return resolution.reason === 'unknown' ? { kind: 'unowned' } : { kind: 'ambiguous' }
|
||||
}
|
||||
return { kind: 'resolved', hostId: resolution.hostId, repo: resolution.owner }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { selectHostBalancedPage } from '../../shared/host-balanced-listing-page'
|
||||
import type { RuntimeListingHostScope } from '../../shared/runtime-listing-host-scope'
|
||||
|
||||
@@ -62,3 +63,28 @@ export function buildWorktreeListingHostScope(args: {
|
||||
}
|
||||
return { hostIds: [...covered].sort(), omittedHostIds: [...omitted].sort() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which hosts a listing claims to have been looking at.
|
||||
*
|
||||
* A `--repo` listing was scoped by the caller, so naming every configured host would report gaps
|
||||
* the caller deliberately excluded. Naming NONE — which is what a scoped listing did before — means
|
||||
* the scope can never report a gap at all, for any host kind, because `covered` and `omitted` are
|
||||
* both derived from the returned rows plus this list. A scoped listing whose scan did not succeed
|
||||
* then answers `{hostIds: [], omittedHostIds: []}`: byte-identical to a repo that genuinely has no
|
||||
* worktrees, which is the one thing docs/reference/ssh-execution-boundary.md forbids a listing from
|
||||
* implying.
|
||||
*
|
||||
* Measured before the fix, on one runtime with one refusing SSH host, in the same second: the
|
||||
* unscoped listing reported `omittedHostIds: ["local", "ssh:<target>"]` while the scoped listing
|
||||
* reported `[]`.
|
||||
*
|
||||
* Naming the single host the caller asked about costs nothing when rows come back — it lands in
|
||||
* `covered`, so it is never reported omitted — and is the whole answer when they do not.
|
||||
*/
|
||||
export function listingKnownHostIds(
|
||||
scopedRepo: Repo | null,
|
||||
listKnownHostIds: () => Iterable<ExecutionHostId>
|
||||
): Iterable<ExecutionHostId> {
|
||||
return scopedRepo ? [getRepoExecutionHostId(scopedRepo)] : listKnownHostIds()
|
||||
}
|
||||
|
||||
@@ -743,6 +743,7 @@ function uploadStageNamespaceIfSupported(
|
||||
|
||||
const NODE_PTY_VERSION = '1.1.0'
|
||||
const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
|
||||
const NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME = 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'
|
||||
const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs'
|
||||
const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:'
|
||||
/**
|
||||
@@ -791,7 +792,8 @@ function nativeDepsProbeJs(successToken: string): string {
|
||||
// Why: node-pty's Windows wrapper defers conpty.node until first spawn, so require("node-pty") alone can't prove the binding is healthy.
|
||||
const loadNodePty =
|
||||
'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty");' +
|
||||
`if(process.platform==="win32"){require("./${NODE_PTY_CONSOLE_LIST_PATCH_FILENAME}").assertPatchedNodePtyConsoleListAgent(process.cwd())}`
|
||||
`if(process.platform==="win32"){require("./${NODE_PTY_CONSOLE_LIST_PATCH_FILENAME}").assertPatchedNodePtyConsoleListAgent(process.cwd());` +
|
||||
`require("./${NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME}").assertPatchedNodePtyWindowsTeardown(process.cwd())}`
|
||||
return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()`
|
||||
}
|
||||
|
||||
@@ -1327,10 +1329,16 @@ async function applyNodePtyMasterCloexecPatch(
|
||||
nodePath: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<NodePtyMasterCloexecOutcome> {
|
||||
// Both Unix relay platforms leak, by different bugs: Linux inherits the master through forkpty()'s
|
||||
// no-O_CLOEXEC path, macOS orphans one throwaway /dev/ptmx fd per spawn in pty_posix_spawn. Only
|
||||
// Windows, which has no fds, is short-circuited -- and answering 'fixed' from a gate that ran
|
||||
// nothing is exactly how a leaking darwin tree got published to the shared cache.
|
||||
// Both Unix relay platforms leak the pty master, by different bugs: Linux inherits it through
|
||||
// forkpty()'s no-O_CLOEXEC path, macOS orphans one throwaway /dev/ptmx fd per spawn in
|
||||
// pty_posix_spawn. Windows is short-circuited because it has no fds for a master to leak into --
|
||||
// and answering 'fixed' from a gate that ran nothing is exactly how a leaking darwin tree got
|
||||
// published to the shared cache.
|
||||
//
|
||||
// What 'fixed' means here is exactly "this tree does not leak the pty MASTER", which is the only
|
||||
// thing the shared native-deps cache keys on. It is NOT a statement that a Windows relay leaks
|
||||
// nothing: it leaked one Windows File handle per terminal until the ConPTY teardown patch above,
|
||||
// by a mechanism that has nothing to do with fds. Read this gate as scoped to its own question.
|
||||
if (isWindowsRemoteHost(hostPlatform) || isWindowsRelayPlatform(platform)) {
|
||||
return 'fixed'
|
||||
}
|
||||
@@ -1530,8 +1538,11 @@ async function rebuildNativeDeps(
|
||||
}
|
||||
|
||||
function windowsNodePtyPatchCommand(nodePath: string): string {
|
||||
// Why: pnpm patches do not cross the SSH boundary; apply the version-checked fallback to the remote npm package.
|
||||
return `& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`
|
||||
// Why: pnpm patches do not cross the SSH boundary; apply the version-checked fallbacks to the remote npm package.
|
||||
return [
|
||||
`& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`,
|
||||
`& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_WINDOWS_TEARDOWN_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`
|
||||
].join('; ')
|
||||
}
|
||||
|
||||
async function makeNodePtySpawnHelperExecutable(
|
||||
|
||||
@@ -118,8 +118,10 @@ export function terminatePtyJob(proc: IPty): JobTerminationOutcome {
|
||||
/**
|
||||
* Pids still alive in a PTY's tree, or null when there is no answer.
|
||||
*
|
||||
* Measured on Windows 11: once the shell exits, node-pty drops its handle
|
||||
* record and closes the job, so a terminated tree reports **null**, not `[]`.
|
||||
* Measured on Windows 11: once the shell exits, node-pty closes the job, so a
|
||||
* terminated tree reports **null**, not `[]`. (Its handle record now outlives
|
||||
* the shell until `kill()` runs — see config/patches/node-pty@1.1.0.patch — but
|
||||
* the nulled job handle is what makes the answer null either way.)
|
||||
* Null therefore means "unverifiable" in the sense of
|
||||
* docs/reference/ssh-execution-boundary.md — this build has no job support,
|
||||
* the terminal is not a ConPTY, or it is no longer tracked. It is never
|
||||
|
||||
@@ -112,7 +112,7 @@ export type PtyApi = {
|
||||
getForegroundProcess: (id: string) => Promise<string | null>
|
||||
inspectProcess: (
|
||||
id: string,
|
||||
options?: { expectedIncarnationId?: string }
|
||||
options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean }
|
||||
) => Promise<TerminalProcessInspection>
|
||||
confirmForegroundProcess: (id: string) => Promise<string | null>
|
||||
getCwd: (id: string) => Promise<string>
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TerminalProcessInspection } from '../../shared/terminal-process-in
|
||||
export const ptyStreamAndSerializationApi = {
|
||||
inspectProcess: (
|
||||
id: string,
|
||||
options?: { expectedIncarnationId?: string }
|
||||
options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean }
|
||||
): Promise<TerminalProcessInspection> =>
|
||||
ipcRenderer.invoke('pty:inspectProcess', { id, ...options }),
|
||||
confirmForegroundProcess: (id: string): Promise<string | null> =>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Whether anything is running under a pane's shell.
|
||||
*
|
||||
* Split out of `pty-shell-utils` because it is a distinct question from "what is in front" and
|
||||
* carries its own platform reasoning, its own cost budget, and the verdict vocabulary from
|
||||
* docs/reference/ssh-execution-boundary.md.
|
||||
*/
|
||||
import { queryWindowsPaneProcessInventory } from '../main/providers/windows-foreground-process-rows'
|
||||
import { getProcessTableIndex } from '../shared/process-table-index'
|
||||
import {
|
||||
getFreshProcessTableSnapshot,
|
||||
getProcessTableSnapshot
|
||||
} from '../shared/process-table-snapshot-reader'
|
||||
import type { PtyChildProcessVerdict } from '../shared/terminal-process-inspection'
|
||||
import { isProcessAlive } from './pty-shell-utils'
|
||||
|
||||
/**
|
||||
* Check whether a process has child processes.
|
||||
*
|
||||
* Why the shared snapshot and not `pgrep -P`: this answers one field of
|
||||
* `pty.inspectProcess`, which every tracked pane polls on a 750ms/2000ms
|
||||
* cadence, and the fork was neither cached nor coalesced. procps-ng opens six
|
||||
* procfs files per process to resolve a ppid — including a `/proc/<pid>/ctty`
|
||||
* that never exists on Linux — so one call cost O(host process count) syscalls,
|
||||
* ~4k opens per pgrep on a 690-process host, at up to 8 forks/sec (#13537).
|
||||
* `getForegroundProcessName` in the same RPC already captured the TTL-cached
|
||||
* `ps` table, whose index carries the parent/child map, so the answer is free.
|
||||
*
|
||||
* `fresh` opts out of that TTL. A poll can read a 500ms-old table because its
|
||||
* next tick corrects it, but a close or cleanup decision acts on the answer
|
||||
* once and destructively — a child that started inside the TTL would be killed
|
||||
* with no confirmation. `pgrep` scanned per call, so anything that decides
|
||||
* has to keep scanning per call.
|
||||
*/
|
||||
export async function inspectPtyChildProcesses(
|
||||
pid: number,
|
||||
options?: { fresh?: boolean }
|
||||
): Promise<PtyChildProcessVerdict> {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows has no `ps`, but it does have a process table, and the pane walk over it already
|
||||
// exists for the foreground reader. Answering `false` from nothing was the older shape: a
|
||||
// hardcoded negative is indistinguishable from a measurement, and every close guard reads it
|
||||
// as "nothing is running here".
|
||||
//
|
||||
// Deliberately the TTL-cached table even when `fresh` is asked for: on a relay without the
|
||||
// native binding this falls back to the CIM scan, whose own 1.36s runtime is longer than the
|
||||
// 500ms TTL a fresh read would be refreshing, so a "fresh" answer is not meaningfully fresher
|
||||
// while N sequential ones are an N x 1.36s stall.
|
||||
const inventory = await queryWindowsPaneProcessInventory(pid)
|
||||
if (inventory) {
|
||||
return inventory.candidates.length > 0 ? 'children' : 'no-children'
|
||||
}
|
||||
// A null inventory is an unreadable table OR a snapshot that never showed the root, and
|
||||
// neither of those looked at the pane. The one answer available without the table is a root
|
||||
// the kernel says is gone: nothing runs under a shell that does not exist.
|
||||
return isProcessAlive(pid) ? 'unverifiable' : 'no-children'
|
||||
}
|
||||
try {
|
||||
const rows = options?.fresh
|
||||
? await getFreshProcessTableSnapshot()
|
||||
: await getProcessTableSnapshot()
|
||||
return (getProcessTableIndex(rows).childrenByPpid.get(pid)?.length ?? 0) > 0
|
||||
? 'children'
|
||||
: 'no-children'
|
||||
} catch {
|
||||
return 'unverifiable'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The boolean the wire has always carried. `unverifiable` keeps spelling itself `false` here on
|
||||
* purpose: this value reaches clients too old to know the third answer, and it is read both as
|
||||
* "busy, do not close" and as "the agent has taken over, safe to type into", so no single mapping
|
||||
* of `unverifiable` is safe for both. Callers that can act on the distinction read the verdict.
|
||||
*/
|
||||
export async function processHasChildren(
|
||||
pid: number,
|
||||
options?: { fresh?: boolean }
|
||||
): Promise<boolean> {
|
||||
return (await inspectPtyChildProcesses(pid, options)) === 'children'
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as ptyChildProcessInspection from './pty-child-process-inspection'
|
||||
import * as ptyShellUtils from './pty-shell-utils'
|
||||
import * as processTableSnapshotReader from '../shared/process-table-snapshot-reader'
|
||||
|
||||
@@ -92,7 +93,7 @@ describe('PtyHandler', () => {
|
||||
})
|
||||
|
||||
it('rescans the process table for a close decision but not for a poll', async () => {
|
||||
const hasChildren = vi.mocked(ptyShellUtils.processHasChildren)
|
||||
const hasChildren = vi.mocked(ptyChildProcessInspection.processHasChildren)
|
||||
const snapshot = vi
|
||||
.spyOn(processTableSnapshotReader, 'getStrictProcessTableSnapshotWithAge')
|
||||
.mockResolvedValue({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { vi } from 'vitest'
|
||||
import type { Mock } from 'vitest'
|
||||
import * as ptyShellUtils from './pty-shell-utils'
|
||||
import * as ptyChildProcessInspection from './pty-child-process-inspection'
|
||||
import { PtyHandler } from './pty-handler'
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
|
||||
@@ -115,7 +115,7 @@ export function beginPtyHandlerTest(mocks: PtyHandlerTestMocks): {
|
||||
notifyOutput: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
})
|
||||
vi.spyOn(ptyShellUtils, 'processHasChildren').mockResolvedValue(false)
|
||||
vi.spyOn(ptyChildProcessInspection, 'processHasChildren').mockResolvedValue(false)
|
||||
|
||||
mockPtySpawn.mockReturnValue({ ...mockPtyInstance })
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Regression guard for the Windows SSH child-process answer. The relay used to return a hardcoded
|
||||
// `false` here, which every close guard reads as "nothing is running in this pane" -- so a Windows
|
||||
// SSH pane running a build closed with no prompt. The answer now comes from the process table, and
|
||||
// the one thing it may never do again is fabricate a negative.
|
||||
//
|
||||
// The second contract is cost. `pty.inspectProcess` is the polled path (750ms/2000ms per tracked
|
||||
// pane) and a relay host has no `@vscode/windows-process-tree`, so its table read falls back to a
|
||||
// 1.36s CIM scan. Polling that would reinstate the fork storm the shared table exists to prevent,
|
||||
// so only a caller whose answer decides something asks for the scan.
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({
|
||||
mockPtySpawn: vi.fn(),
|
||||
mockCreateShellPromptReadinessProbe: vi.fn(),
|
||||
mockPtyInstance: {
|
||||
pid: process.pid,
|
||||
process: 'xterm-256color',
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({ spawn: mockPtySpawn }))
|
||||
|
||||
vi.mock('../main/pty/posix-pty-process-groups', () => ({
|
||||
forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback())
|
||||
}))
|
||||
|
||||
vi.mock('../main/shell-prompt-readiness-probe', () => ({
|
||||
createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe
|
||||
}))
|
||||
|
||||
import * as ptyChildProcessInspection from './pty-child-process-inspection'
|
||||
import type { PtyHandler } from './pty-handler'
|
||||
import {
|
||||
beginPtyHandlerTest,
|
||||
createPtyRequestHelpers,
|
||||
endPtyHandlerTest
|
||||
} from './pty-handler-test-harness'
|
||||
import type { MockDispatcher } from './pty-handler-test-harness'
|
||||
|
||||
type Inspection = {
|
||||
foregroundProcess: string | null
|
||||
hasChildProcesses: boolean
|
||||
childProcessEvidence?: string
|
||||
}
|
||||
|
||||
describe('PtyHandler Windows child-process evidence', () => {
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: PtyHandler
|
||||
let originalPlatform: PropertyDescriptor | undefined
|
||||
let inspectChildren: ReturnType<typeof vi.spyOn>
|
||||
|
||||
const { spawnPty } = createPtyRequestHelpers(() => dispatcher)
|
||||
|
||||
/** Spawn under the harness's POSIX platform, then answer as the Windows relay would. */
|
||||
async function spawnThenBecomeWindows(): Promise<string> {
|
||||
const { id } = await spawnPty()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
return id
|
||||
}
|
||||
|
||||
async function inspect(params: Record<string, unknown>): Promise<Inspection> {
|
||||
return (await dispatcher.callRequest('pty.inspectProcess', params)) as Inspection
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({
|
||||
mockPtySpawn,
|
||||
mockPtyInstance,
|
||||
mockCreateShellPromptReadinessProbe
|
||||
}))
|
||||
inspectChildren = vi
|
||||
.spyOn(ptyChildProcessInspection, 'inspectPtyChildProcesses')
|
||||
.mockResolvedValue('no-children')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await endPtyHandlerTest(handler, originalPlatform)
|
||||
})
|
||||
|
||||
it('publishes what the host observed when the caller pays for the scan', async () => {
|
||||
const id = await spawnThenBecomeWindows()
|
||||
inspectChildren.mockResolvedValue('children')
|
||||
|
||||
const result = await inspect({ id, scanChildProcesses: true })
|
||||
|
||||
expect(inspectChildren).toHaveBeenCalledWith(mockPtyInstance.pid)
|
||||
expect(result.childProcessEvidence).toBe('children')
|
||||
expect(result.hasChildProcesses).toBe(true)
|
||||
})
|
||||
|
||||
it('reports an observed-empty pane as no-children, not merely false', async () => {
|
||||
const id = await spawnThenBecomeWindows()
|
||||
inspectChildren.mockResolvedValue('no-children')
|
||||
|
||||
const result = await inspect({ id, scanChildProcesses: true })
|
||||
|
||||
// Asserted alongside the value so the case fails if the answer stops coming from a real read.
|
||||
expect(inspectChildren).toHaveBeenCalledWith(mockPtyInstance.pid)
|
||||
expect(result.childProcessEvidence).toBe('no-children')
|
||||
expect(result.hasChildProcesses).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the compatibility boolean false when the host could not observe the pane', async () => {
|
||||
const id = await spawnThenBecomeWindows()
|
||||
inspectChildren.mockResolvedValue('unverifiable')
|
||||
|
||||
const result = await inspect({ id, scanChildProcesses: true })
|
||||
|
||||
expect(result.childProcessEvidence).toBe('unverifiable')
|
||||
// Clients too old to read the verdict also read `true` as "an agent took the PTY, safe to
|
||||
// type into it", so `unverifiable` must not be promoted to `true` on the shared boolean.
|
||||
expect(result.hasChildProcesses).toBe(false)
|
||||
})
|
||||
|
||||
it('never reads the process table for a poll, and says so instead of guessing', async () => {
|
||||
const id = await spawnThenBecomeWindows()
|
||||
|
||||
const result = await inspect({ id })
|
||||
|
||||
expect(inspectChildren).not.toHaveBeenCalled()
|
||||
expect(result.childProcessEvidence).toBe('unverifiable')
|
||||
expect(result.hasChildProcesses).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -10,11 +10,11 @@ import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import {
|
||||
resolveDefaultShell,
|
||||
resolveProcessCwd,
|
||||
processHasChildren,
|
||||
getForegroundProcessName,
|
||||
isProcessAlive,
|
||||
listShellProfiles
|
||||
} from './pty-shell-utils'
|
||||
import { inspectPtyChildProcesses, processHasChildren } from './pty-child-process-inspection'
|
||||
import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch'
|
||||
import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces'
|
||||
import { addWslEnvKeys } from '../shared/wsl-env'
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import { isTuiAgent } from '../shared/tui-agent-config'
|
||||
import type { TuiAgent } from '../shared/tui-agent'
|
||||
import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups'
|
||||
import type { PtyChildProcessVerdict } from '../shared/terminal-process-inspection'
|
||||
import { terminatePtyJob } from '../main/windows/windows-pty-job'
|
||||
import { stripInheritedBuildModeEnv } from '../main/pty/build-mode-env'
|
||||
import { stripLegacyTerminalShimEnv } from '../main/pty/legacy-terminal-shim-dir'
|
||||
@@ -2610,6 +2611,7 @@ export class PtyHandler {
|
||||
private async inspectProcess(params: Record<string, unknown>): Promise<{
|
||||
foregroundProcess: string | null
|
||||
hasChildProcesses: boolean
|
||||
childProcessEvidence?: PtyChildProcessVerdict
|
||||
foregroundProcessEvidence?: RemoteForegroundEvidence
|
||||
}> {
|
||||
pruneRetiredPtyIncarnations(this.retiredIncarnations)
|
||||
@@ -2716,14 +2718,28 @@ export class PtyHandler {
|
||||
evidence?.verdict === 'live'
|
||||
? (evidence.processName ?? managed.pty.process) || null
|
||||
: managed.pty.process || null
|
||||
// Derive child liveness from the same capture; do not fork a second process-table probe for
|
||||
// each field/pane in an event burst.
|
||||
//
|
||||
// Why Windows is gated on the caller asking: this is the one field whose Windows answer costs
|
||||
// a process-table read, and `inspectProcess` is the polled path (750ms/2000ms per tracked
|
||||
// pane). A relay host has no `@vscode/windows-process-tree`, so the read falls back to the
|
||||
// 1.36s CIM scan, and polling that would reinstate exactly the fork storm the shared table
|
||||
// exists to prevent (#15209, #15036). Close and cleanup decisions ask for the scan by name;
|
||||
// a poll gets the honest `unverifiable` instead of a fabricated negative.
|
||||
const childProcessEvidence: PtyChildProcessVerdict = rows
|
||||
? rows.some((row) => row.ppid === managed.pty.pid)
|
||||
? 'children'
|
||||
: 'no-children'
|
||||
: process.platform === 'win32' && params.scanChildProcesses !== true
|
||||
? 'unverifiable'
|
||||
: await inspectPtyChildProcesses(managed.pty.pid)
|
||||
return {
|
||||
foregroundProcess,
|
||||
// Derive child liveness from the same capture; do not fork a second
|
||||
// process-table probe for each field/pane in an event burst. Windows
|
||||
// has no evidence capture, so preserve the compatibility child probe.
|
||||
hasChildProcesses: rows
|
||||
? rows.some((row) => row.ppid === managed.pty.pid)
|
||||
: await processHasChildren(managed.pty.pid),
|
||||
// `unverifiable` keeps spelling itself `false` on the compatibility field, which is what
|
||||
// every client too old to read the verdict receives.
|
||||
hasChildProcesses: childProcessEvidence === 'children',
|
||||
childProcessEvidence,
|
||||
...(evidence ? { foregroundProcessEvidence: evidence } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ vi.mock('child_process', () => ({
|
||||
import { resetWindowsProcessRowsSnapshotForTests } from '../main/providers/windows-foreground-process-rows'
|
||||
import { __setWindowsProcessTreeLoaderForTests } from '../main/windows/windows-process-table'
|
||||
import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot-reader'
|
||||
import { inspectPtyChildProcesses, processHasChildren } from './pty-child-process-inspection'
|
||||
import {
|
||||
getForegroundProcessName,
|
||||
isProcessAlive,
|
||||
processHasChildren,
|
||||
resolveDefaultCwd,
|
||||
resolveWindowsDefaultShell
|
||||
} from './pty-shell-utils'
|
||||
@@ -42,6 +42,14 @@ function mockExecFile(
|
||||
* Feed the native Windows snapshot. A real snapshot always contains the
|
||||
* querying process, and the reader rejects a table without it.
|
||||
*/
|
||||
/** A native reader that answers, but with no snapshot -- an unreadable table, not an empty one. */
|
||||
function mockUnreadableWindowsProcessTable(): void {
|
||||
__setWindowsProcessTreeLoaderForTests(() => ({
|
||||
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 },
|
||||
getAllProcesses: (cb: (value: undefined) => void) => cb(undefined)
|
||||
}))
|
||||
}
|
||||
|
||||
function mockWindowsProcessTable(
|
||||
rows: { pid: number; ppid: number; name: string; commandLine?: string }[]
|
||||
): void {
|
||||
@@ -594,19 +602,79 @@ describe('processHasChildren', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports no children when the process table is unreadable', async () => {
|
||||
it('reports an unreadable POSIX table as unverifiable, and still spells it false on the wire', async () => {
|
||||
await withProcessPlatform('linux', async () => {
|
||||
mockExecFile(() => new Error('ps table unavailable'))
|
||||
|
||||
await expect(inspectPtyChildProcesses(100)).resolves.toBe('unverifiable')
|
||||
await expect(processHasChildren(100)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('spawns nothing on Windows, where the answer was always false', async () => {
|
||||
describe('inspectPtyChildProcesses on Windows', () => {
|
||||
// Why this describe exists: the relay used to `return false` here unconditionally, and a
|
||||
// hardcoded negative is indistinguishable from a measurement. Every close guard reads it as
|
||||
// "nothing is running here", so a Windows SSH pane running a build closed with no prompt.
|
||||
it('walks the process table rather than answering from nothing', async () => {
|
||||
await withProcessPlatform('win32', async () => {
|
||||
await expect(processHasChildren(100)).resolves.toBe(false)
|
||||
mockWindowsProcessTable([
|
||||
{ pid: 100, ppid: 99, name: 'cmd.exe', commandLine: 'cmd.exe' },
|
||||
{ pid: 101, ppid: 100, name: 'PING.EXE', commandLine: 'ping -n 40 127.0.0.1' }
|
||||
])
|
||||
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
await expect(inspectPtyChildProcesses(100)).resolves.toBe('children')
|
||||
await expect(processHasChildren(100)).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('finds a grandchild the shell backgrounded, not just direct children', async () => {
|
||||
await withProcessPlatform('win32', async () => {
|
||||
mockWindowsProcessTable([
|
||||
{ pid: 100, ppid: 99, name: 'cmd.exe', commandLine: 'cmd.exe' },
|
||||
{ pid: 101, ppid: 100, name: 'node.exe', commandLine: 'node build.js' },
|
||||
{ pid: 102, ppid: 101, name: 'tsc.exe', commandLine: 'tsc --watch' }
|
||||
])
|
||||
|
||||
await expect(inspectPtyChildProcesses(102)).resolves.toBe('no-children')
|
||||
await expect(inspectPtyChildProcesses(101)).resolves.toBe('children')
|
||||
})
|
||||
})
|
||||
|
||||
it('separates an observed-empty shell from a table it could not read', async () => {
|
||||
await withProcessPlatform('win32', async () => {
|
||||
mockWindowsProcessTable([{ pid: 100, ppid: 99, name: 'cmd.exe', commandLine: 'cmd.exe' }])
|
||||
await expect(inspectPtyChildProcesses(100)).resolves.toBe('no-children')
|
||||
|
||||
resetWindowsProcessRowsSnapshotForTests()
|
||||
mockUnreadableWindowsProcessTable()
|
||||
const alive = vi.spyOn(process, 'kill').mockReturnValue(true as never)
|
||||
try {
|
||||
await expect(inspectPtyChildProcesses(100)).resolves.toBe('unverifiable')
|
||||
// The compatibility boolean keeps spelling unverifiable `false`: it reaches clients that
|
||||
// cannot read the verdict, and they read `true` as "an agent took the PTY, safe to type".
|
||||
await expect(processHasChildren(100)).resolves.toBe(false)
|
||||
} finally {
|
||||
alive.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not read a missing shell as unverifiable when the kernel says it is gone', async () => {
|
||||
await withProcessPlatform('win32', async () => {
|
||||
// The root is absent from the snapshot, which on its own cannot distinguish a filtered
|
||||
// table from an exited shell. Only ESRCH settles it.
|
||||
mockWindowsProcessTable([{ pid: 900, ppid: 1, name: 'explorer.exe' }])
|
||||
const gone = vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
const error = new Error('no such process') as NodeJS.ErrnoException
|
||||
error.code = 'ESRCH'
|
||||
throw error
|
||||
})
|
||||
try {
|
||||
await expect(inspectPtyChildProcesses(100)).resolves.toBe('no-children')
|
||||
} finally {
|
||||
gone.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
import { getFirstCommandToken } from '../shared/command-token-scanner'
|
||||
import { getProcessTableIndex, type ProcessTableIndex } from '../shared/process-table-index'
|
||||
import { PS_MAX_BUFFER_BYTES, type ProcessTableRow } from '../shared/process-table-snapshot'
|
||||
import {
|
||||
getFreshProcessTableSnapshot,
|
||||
getProcessTableSnapshot
|
||||
} from '../shared/process-table-snapshot-reader'
|
||||
import { getProcessTableSnapshot } from '../shared/process-table-snapshot-reader'
|
||||
import { selectForegroundProcessCandidate } from '../shared/foreground-process-selection'
|
||||
import {
|
||||
resolveOuterWrapperForegroundProcess,
|
||||
@@ -169,43 +166,6 @@ export async function resolveProcessCwd(pid: number, fallbackCwd: string): Promi
|
||||
return fallbackCwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a process has child processes.
|
||||
*
|
||||
* Why the shared snapshot and not `pgrep -P`: this answers one field of
|
||||
* `pty.inspectProcess`, which every tracked pane polls on a 750ms/2000ms
|
||||
* cadence, and the fork was neither cached nor coalesced. procps-ng opens six
|
||||
* procfs files per process to resolve a ppid — including a `/proc/<pid>/ctty`
|
||||
* that never exists on Linux — so one call cost O(host process count) syscalls,
|
||||
* ~4k opens per pgrep on a 690-process host, at up to 8 forks/sec (#13537).
|
||||
* `getForegroundProcessName` in the same RPC already captured the TTL-cached
|
||||
* `ps` table, whose index carries the parent/child map, so the answer is free.
|
||||
*
|
||||
* `fresh` opts out of that TTL. A poll can read a 500ms-old table because its
|
||||
* next tick corrects it, but a close or cleanup decision acts on the answer
|
||||
* once and destructively — a child that started inside the TTL would be killed
|
||||
* with no confirmation. `pgrep` scanned per call, so anything that decides
|
||||
* has to keep scanning per call.
|
||||
*/
|
||||
export async function processHasChildren(
|
||||
pid: number,
|
||||
options?: { fresh?: boolean }
|
||||
): Promise<boolean> {
|
||||
// Windows has no `ps`; the previous `pgrep` fork always failed here too, so
|
||||
// this keeps the same answer without spawning anything to reach it.
|
||||
if (process.platform === 'win32') {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const rows = options?.fresh
|
||||
? await getFreshProcessTableSnapshot()
|
||||
: await getProcessTableSnapshot()
|
||||
return (getProcessTableIndex(rows).childrenByPpid.get(pid)?.length ?? 0) > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no
|
||||
// such process") proves the pid is gone; EPERM means it exists but is
|
||||
// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so
|
||||
|
||||
@@ -451,7 +451,7 @@ describe('palette live status', () => {
|
||||
expect(dotLabels()).toEqual(['Needs permission'])
|
||||
})
|
||||
|
||||
it('cuts the pip out of the dialog surface, and out of accent when selected', async () => {
|
||||
it('keeps the attention glyph knockout popover-colored when its row is selected', async () => {
|
||||
setAgentState('working')
|
||||
await act(async () => {
|
||||
testRoot.render(
|
||||
@@ -469,18 +469,11 @@ describe('palette live status', () => {
|
||||
</PaletteLiveStatusProvider>
|
||||
)
|
||||
})
|
||||
const pip = testContainer.querySelector<HTMLElement>('[aria-hidden="true"].rounded-full')
|
||||
const pip = testContainer.querySelector<HTMLElement>('[aria-hidden="true"]')
|
||||
expect(pip).not.toBeNull()
|
||||
// Why popover and not background: the CommandDialog surface is --popover (#171717 dark), while
|
||||
// --background is the app canvas (#0a0a0a) — the mismatch punched a dark halo through each row.
|
||||
expect(pip?.className).toContain('bg-popover')
|
||||
expect(pip?.className).toContain('ring-popover')
|
||||
expect(pip?.className).not.toContain('bg-background')
|
||||
expect(pip?.className).toContain(
|
||||
'group-data-[selected=true]:bg-[var(--jump-palette-selection-surface)]'
|
||||
)
|
||||
expect(pip?.className).toContain(
|
||||
'group-data-[selected=true]:ring-[var(--jump-palette-selection-surface)]'
|
||||
)
|
||||
expect(pip?.className).toContain('rounded-full')
|
||||
expect(pip?.className).not.toContain('group-data-[selected=true]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
buildExplicitEntriesByTabId,
|
||||
type TabPaneInputSources
|
||||
} from '@/components/sidebar/smart-attention'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
|
||||
import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state'
|
||||
import {
|
||||
@@ -255,15 +254,8 @@ export function PaletteRecentTabStatusDot({
|
||||
<span className="relative inline-flex size-3.5 shrink-0 items-center justify-center">
|
||||
{fallback}
|
||||
<span
|
||||
className={cn(
|
||||
// Why popover, not background: the dialog surface is --popover (#171717 in dark), while
|
||||
// --background is the app canvas (#0a0a0a) — using it punched a dark halo through every
|
||||
// dark-mode row. Selected rows use --jump-palette-selection-surface so the cutout tracks
|
||||
// the stronger keyboard highlight from main.css.
|
||||
'pointer-events-none absolute -right-0.5 -bottom-0.5 flex items-center justify-center rounded-full',
|
||||
'bg-popover ring-2 ring-popover',
|
||||
'group-data-[selected=true]:bg-[var(--jump-palette-selection-surface)] group-data-[selected=true]:ring-[var(--jump-palette-selection-surface)]'
|
||||
)}
|
||||
// The popover-colored knockout separates the glyph from its icon without inheriting row selection.
|
||||
className="pointer-events-none absolute -right-0.5 -bottom-0.5 flex items-center justify-center rounded-full bg-popover ring-2 ring-popover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<RecentTabAttentionBadgeGlyph badge={badge} />
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { createRef } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { TabStripScrollIndicator } from './TabStripScrollIndicator'
|
||||
import type { TabStripScrollMetrics } from './tab-strip-scroll-metrics'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const OVERFLOW_METRICS: TabStripScrollMetrics = {
|
||||
hasOverflow: true,
|
||||
canScrollStart: false,
|
||||
canScrollEnd: true,
|
||||
thumbSizeFraction: 0.4,
|
||||
thumbOffsetFraction: 0
|
||||
}
|
||||
|
||||
const NO_OVERFLOW_METRICS: TabStripScrollMetrics = {
|
||||
hasOverflow: false,
|
||||
canScrollStart: false,
|
||||
canScrollEnd: false,
|
||||
thumbSizeFraction: 1,
|
||||
thumbOffsetFraction: 0
|
||||
}
|
||||
|
||||
describe('TabStripScrollIndicator', () => {
|
||||
it('renders null when there is no overflow', () => {
|
||||
const { container } = render(<TabStripScrollIndicator metrics={NO_OVERFLOW_METRICS} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders under the tabs with bottom-0 and idle 2px height', () => {
|
||||
const { getByTestId } = render(<TabStripScrollIndicator metrics={OVERFLOW_METRICS} />)
|
||||
const indicator = getByTestId('tab-strip-scroll-indicator')
|
||||
expect(indicator).toBeTruthy()
|
||||
expect(indicator.className).toContain('bottom-0')
|
||||
expect(indicator.className).toContain('h-[2px]')
|
||||
expect(indicator.className).toContain('z-[12]')
|
||||
expect(indicator.className).toContain('opacity-0')
|
||||
expect(indicator.className).toContain('group-hover/tab-strip:opacity-100')
|
||||
|
||||
const thumb = getByTestId('tab-strip-scroll-thumb')
|
||||
expect(thumb).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expands to 3px and becomes opaque on pointer hover, restores on leave', () => {
|
||||
const { getByTestId } = render(<TabStripScrollIndicator metrics={OVERFLOW_METRICS} />)
|
||||
const indicator = getByTestId('tab-strip-scroll-indicator')
|
||||
expect(indicator.className).toContain('h-[2px]')
|
||||
expect(indicator.className).toContain('opacity-0')
|
||||
|
||||
fireEvent.pointerEnter(indicator)
|
||||
expect(indicator.className).toContain('h-[3px]')
|
||||
expect(indicator.className).toContain('opacity-100')
|
||||
|
||||
fireEvent.pointerLeave(indicator)
|
||||
expect(indicator.className).toContain('h-[2px]')
|
||||
expect(indicator.className).toContain('opacity-0')
|
||||
})
|
||||
|
||||
it('applies pointer-events-none when disabled', () => {
|
||||
const { getByTestId } = render(
|
||||
<TabStripScrollIndicator metrics={OVERFLOW_METRICS} disabled={true} />
|
||||
)
|
||||
const indicator = getByTestId('tab-strip-scroll-indicator')
|
||||
expect(indicator.className).toContain('pointer-events-none')
|
||||
expect(indicator.className).not.toContain('group-hover/tab-strip:pointer-events-auto')
|
||||
})
|
||||
|
||||
it('stays hidden and unexpanded on hover when disabled', () => {
|
||||
const { getByTestId } = render(
|
||||
<TabStripScrollIndicator metrics={OVERFLOW_METRICS} disabled={true} />
|
||||
)
|
||||
const indicator = getByTestId('tab-strip-scroll-indicator')
|
||||
fireEvent.pointerEnter(indicator)
|
||||
expect(indicator.className).toContain('opacity-0')
|
||||
expect(indicator.className).not.toContain('opacity-100')
|
||||
expect(indicator.className).toContain('h-[2px]')
|
||||
})
|
||||
|
||||
it('does not forward wheel events when disabled', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.scrollLeft = 0
|
||||
const scrollContainerRef = createRef<HTMLElement>()
|
||||
;(scrollContainerRef as React.MutableRefObject<HTMLElement>).current = scrollContainer
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TabStripScrollIndicator
|
||||
metrics={OVERFLOW_METRICS}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
disabled={true}
|
||||
/>
|
||||
)
|
||||
fireEvent.wheel(getByTestId('tab-strip-scroll-indicator'), { deltaX: 40, deltaY: 0 })
|
||||
|
||||
expect(scrollContainer.scrollLeft).toBe(0)
|
||||
})
|
||||
|
||||
it('forwards wheel events to scrollContainer', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.scrollLeft = 0
|
||||
const scrollContainerRef = createRef<HTMLElement>()
|
||||
;(scrollContainerRef as React.MutableRefObject<HTMLElement>).current = scrollContainer
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TabStripScrollIndicator metrics={OVERFLOW_METRICS} scrollContainerRef={scrollContainerRef} />
|
||||
)
|
||||
const indicator = getByTestId('tab-strip-scroll-indicator')
|
||||
fireEvent.wheel(indicator, { deltaX: 40, deltaY: 0 })
|
||||
|
||||
expect(scrollContainer.scrollLeft).toBe(40)
|
||||
})
|
||||
|
||||
it('handles track click and smooth scrolls container', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
Object.defineProperty(scrollContainer, 'scrollWidth', { value: 1000, configurable: true })
|
||||
Object.defineProperty(scrollContainer, 'clientWidth', { value: 400, configurable: true })
|
||||
scrollContainer.scrollLeft = 0
|
||||
const scrollToMock = vi.fn()
|
||||
scrollContainer.scrollTo = scrollToMock
|
||||
|
||||
const scrollContainerRef = createRef<HTMLElement>()
|
||||
;(scrollContainerRef as React.MutableRefObject<HTMLElement>).current = scrollContainer
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TabStripScrollIndicator metrics={OVERFLOW_METRICS} scrollContainerRef={scrollContainerRef} />
|
||||
)
|
||||
const indicator = getByTestId('tab-strip-scroll-indicator')
|
||||
Object.defineProperty(indicator, 'clientWidth', { value: 400, configurable: true })
|
||||
vi.spyOn(indicator, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 100,
|
||||
right: 500,
|
||||
top: 30,
|
||||
bottom: 33,
|
||||
width: 400,
|
||||
height: 3,
|
||||
x: 100,
|
||||
y: 30,
|
||||
toJSON: () => {}
|
||||
} as DOMRect)
|
||||
|
||||
// Click track at clientX = 300 (offset 200 within 400px track)
|
||||
fireEvent.pointerDown(indicator, { button: 0, clientX: 300 })
|
||||
expect(scrollToMock).toHaveBeenCalledTimes(1)
|
||||
expect(scrollToMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
behavior: 'smooth'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('scrolls container when dragging thumb', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
Object.defineProperty(scrollContainer, 'scrollWidth', { value: 1000, configurable: true })
|
||||
Object.defineProperty(scrollContainer, 'clientWidth', { value: 400, configurable: true })
|
||||
scrollContainer.scrollLeft = 0
|
||||
|
||||
const scrollContainerRef = createRef<HTMLElement>()
|
||||
;(scrollContainerRef as React.MutableRefObject<HTMLElement>).current = scrollContainer
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TabStripScrollIndicator
|
||||
metrics={{
|
||||
...OVERFLOW_METRICS,
|
||||
thumbSizeFraction: 0.4
|
||||
}}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
)
|
||||
const indicator = getByTestId('tab-strip-scroll-indicator')
|
||||
Object.defineProperty(indicator, 'clientWidth', { value: 400, configurable: true })
|
||||
const thumb = getByTestId('tab-strip-scroll-thumb')
|
||||
|
||||
// Start drag on thumb
|
||||
fireEvent.pointerDown(thumb, { button: 0, clientX: 50 })
|
||||
expect(indicator.className).toContain('h-[3px]')
|
||||
|
||||
// Move pointer by 60px
|
||||
fireEvent(window, new MouseEvent('pointermove', { clientX: 110 }))
|
||||
expect(scrollContainer.scrollLeft).toBeGreaterThan(0)
|
||||
|
||||
// Release drag
|
||||
fireEvent(window, new MouseEvent('pointerup'))
|
||||
expect(indicator.className).toContain('h-[2px]')
|
||||
})
|
||||
|
||||
it('cancels an active thumb drag when it becomes disabled', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
Object.defineProperty(scrollContainer, 'scrollWidth', { value: 1000, configurable: true })
|
||||
Object.defineProperty(scrollContainer, 'clientWidth', { value: 400, configurable: true })
|
||||
scrollContainer.scrollLeft = 0
|
||||
|
||||
const scrollContainerRef = createRef<HTMLElement>()
|
||||
;(scrollContainerRef as React.MutableRefObject<HTMLElement>).current = scrollContainer
|
||||
|
||||
const { getByTestId, rerender } = render(
|
||||
<TabStripScrollIndicator metrics={OVERFLOW_METRICS} scrollContainerRef={scrollContainerRef} />
|
||||
)
|
||||
const indicator = getByTestId('tab-strip-scroll-indicator')
|
||||
Object.defineProperty(indicator, 'clientWidth', { value: 400, configurable: true })
|
||||
|
||||
fireEvent.pointerDown(getByTestId('tab-strip-scroll-thumb'), { button: 0, clientX: 50 })
|
||||
expect(document.body.style.userSelect).toBe('none')
|
||||
|
||||
rerender(
|
||||
<TabStripScrollIndicator
|
||||
metrics={OVERFLOW_METRICS}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
disabled={true}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.body.style.userSelect).toBe('')
|
||||
expect(document.body.style.cursor).toBe('')
|
||||
|
||||
fireEvent(window, new MouseEvent('pointermove', { clientX: 300 }))
|
||||
expect(scrollContainer.scrollLeft).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
computeTabStripThumbLayout,
|
||||
type TabStripScrollMetrics,
|
||||
@@ -7,13 +7,24 @@ import {
|
||||
|
||||
const EMPTY_THUMB_LAYOUT: TabStripThumbLayout = { widthPx: 0, leftPx: 0 }
|
||||
|
||||
export function TabStripScrollIndicator({
|
||||
metrics
|
||||
}: {
|
||||
export type TabStripScrollIndicatorProps = {
|
||||
metrics: TabStripScrollMetrics
|
||||
}): React.JSX.Element | null {
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function TabStripScrollIndicator({
|
||||
metrics,
|
||||
scrollContainerRef,
|
||||
disabled = false
|
||||
}: TabStripScrollIndicatorProps): React.JSX.Element | null {
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const cleanupDragRef = useRef<(() => void) | null>(null)
|
||||
const [thumbLayout, setThumbLayout] = useState<TabStripThumbLayout>(EMPTY_THUMB_LAYOUT)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isScrolling, setIsScrolling] = useState(false)
|
||||
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const remeasureThumb = useCallback((): void => {
|
||||
const track = trackRef.current
|
||||
@@ -37,23 +48,185 @@ export function TabStripScrollIndicator({
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [remeasureThumb])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef?.current
|
||||
if (!scrollContainer) {
|
||||
return
|
||||
}
|
||||
const handleScroll = (): void => {
|
||||
setIsScrolling(true)
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current)
|
||||
}
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
setIsScrolling(false)
|
||||
}, 800)
|
||||
}
|
||||
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll)
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [scrollContainerRef])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupDragRef.current?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: hiding the indicator mid-drag would otherwise leave window listeners and body cursor/user-select stuck.
|
||||
useEffect(() => {
|
||||
if (disabled || !metrics.hasOverflow) {
|
||||
cleanupDragRef.current?.()
|
||||
}
|
||||
}, [disabled, metrics.hasOverflow])
|
||||
|
||||
const handleThumbPointerDown = (e: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (e.button !== 0 || disabled) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const scrollContainer = scrollContainerRef?.current
|
||||
const track = trackRef.current
|
||||
if (!scrollContainer || !track) {
|
||||
return
|
||||
}
|
||||
|
||||
const startX = e.clientX
|
||||
const startScrollLeft = scrollContainer.scrollLeft
|
||||
const trackWidth = track.clientWidth
|
||||
const maxLeft = Math.max(1, trackWidth - thumbLayout.widthPx)
|
||||
const maxScrollLeft = Math.max(0, scrollContainer.scrollWidth - scrollContainer.clientWidth)
|
||||
|
||||
if (maxScrollLeft <= 0 || maxLeft <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragging(true)
|
||||
const prevUserSelect = document.body.style.userSelect
|
||||
const prevCursor = document.body.style.cursor
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'grabbing'
|
||||
|
||||
const onPointerMove = (moveEvent: PointerEvent): void => {
|
||||
const deltaX = moveEvent.clientX - startX
|
||||
const scrollDelta = (deltaX / maxLeft) * maxScrollLeft
|
||||
scrollContainer.scrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(maxScrollLeft, startScrollLeft + scrollDelta)
|
||||
)
|
||||
}
|
||||
|
||||
const cleanup = (): void => {
|
||||
setIsDragging(false)
|
||||
document.body.style.userSelect = prevUserSelect
|
||||
document.body.style.cursor = prevCursor
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', cleanup)
|
||||
window.removeEventListener('pointercancel', cleanup)
|
||||
cleanupDragRef.current = null
|
||||
}
|
||||
|
||||
cleanupDragRef.current = cleanup
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', cleanup)
|
||||
window.addEventListener('pointercancel', cleanup)
|
||||
}
|
||||
|
||||
const handleTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (e.button !== 0 || disabled) {
|
||||
return
|
||||
}
|
||||
if (e.target !== trackRef.current) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const scrollContainer = scrollContainerRef?.current
|
||||
const track = trackRef.current
|
||||
if (!scrollContainer || !track) {
|
||||
return
|
||||
}
|
||||
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
const clickX = e.clientX - trackRect.left
|
||||
const trackWidth = track.clientWidth
|
||||
const thumbWidth = thumbLayout.widthPx
|
||||
const maxLeft = Math.max(1, trackWidth - thumbWidth)
|
||||
const maxScrollLeft = Math.max(0, scrollContainer.scrollWidth - scrollContainer.clientWidth)
|
||||
|
||||
if (maxScrollLeft <= 0 || maxLeft <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetThumbLeft = Math.max(0, Math.min(maxLeft, clickX - thumbWidth / 2))
|
||||
const targetScrollLeft = (targetThumbLeft / maxLeft) * maxScrollLeft
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
left: targetScrollLeft,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
const handleWheel = (e: React.WheelEvent<HTMLDivElement>): void => {
|
||||
const scrollContainer = scrollContainerRef?.current
|
||||
if (!scrollContainer || disabled) {
|
||||
return
|
||||
}
|
||||
// Why: forward wheel events to tab container so scrolling over the indicator scrolls the strip.
|
||||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY
|
||||
scrollContainer.scrollLeft += delta
|
||||
}
|
||||
|
||||
if (!metrics.hasOverflow) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: during a tab drag the indicator fully yields — no reveal, no pointer/wheel interaction.
|
||||
const isExpanded = !disabled && (isHovered || isDragging)
|
||||
const isVisible = !disabled && (isHovered || isDragging || isScrolling)
|
||||
|
||||
return (
|
||||
// Why: top rail keeps the active tab's bottom underline unobstructed.
|
||||
// Why: under-tab position matches editor tab scrollbar conventions, auto-hiding when idle so it never mimics an underline.
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-[1] h-px bg-muted-foreground/10"
|
||||
data-testid="tab-strip-scroll-indicator"
|
||||
className={`absolute inset-x-0 bottom-0 z-[12] select-none transition-[height,background-color,opacity] duration-150 ease-out ${
|
||||
isExpanded ? 'h-[3px] bg-muted-foreground/10 cursor-pointer' : 'h-[2px] bg-transparent'
|
||||
} ${
|
||||
disabled
|
||||
? 'opacity-0 pointer-events-none'
|
||||
: isVisible
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 group-hover/tab-strip:opacity-100 pointer-events-none group-hover/tab-strip:pointer-events-auto'
|
||||
}`}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
onWheel={handleWheel}
|
||||
// Why: decorative rail — keyboard/AT users scroll the strip with the adjacent labelled scroll buttons.
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 h-full rounded-full bg-muted-foreground/40 transition-[left,width] duration-75 ease-out"
|
||||
data-testid="tab-strip-scroll-thumb"
|
||||
className={`absolute bottom-0 h-full rounded-full transition-colors duration-150 ease-out ${
|
||||
isDragging
|
||||
? 'bg-foreground/40 cursor-grabbing'
|
||||
: isHovered
|
||||
? 'bg-muted-foreground/50 cursor-grab'
|
||||
: 'bg-muted-foreground/25 cursor-default'
|
||||
}`}
|
||||
style={{
|
||||
width: `${thumbLayout.widthPx}px`,
|
||||
left: `${thumbLayout.leftPx}px`
|
||||
}}
|
||||
onPointerDown={handleThumbPointerDown}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ export function getDropIndicatorClasses(dropIndicator: DropIndicator): string {
|
||||
// flips the strip between "fits exactly" and "overflows by 1px", which jitters
|
||||
// every tab by 1px because the browser preserves scrollLeft near the end.
|
||||
export const ACTIVE_TAB_INDICATOR_CLASSES =
|
||||
'pointer-events-none absolute inset-x-0 bottom-0 h-[2px] bg-[color-mix(in_srgb,var(--foreground)_60%,var(--card))] z-10'
|
||||
'pointer-events-none absolute inset-x-0 bottom-0 h-[2px] bg-[color-mix(in_srgb,var(--foreground)_60%,var(--card))] z-20'
|
||||
|
||||
export function getTabRootStateClasses(isActive: boolean): string {
|
||||
return isActive
|
||||
|
||||
@@ -157,7 +157,7 @@ export function renderTabBarSurface({
|
||||
<SortableContext items={sortableIds}>
|
||||
{/* Why: no-drag lets tab interactions work inside the titlebar's drag region (outer container stays window-draggable). */}
|
||||
<div
|
||||
className="relative flex min-h-0 min-w-0 max-w-full flex-[0_1_auto]"
|
||||
className="group/tab-strip relative flex min-h-0 min-w-0 max-w-full flex-[0_1_auto]"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
@@ -181,7 +181,11 @@ export function renderTabBarSurface({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<TabStripScrollIndicator metrics={tabStripOverflowState} />
|
||||
<TabStripScrollIndicator
|
||||
metrics={tabStripOverflowState}
|
||||
scrollContainerRef={tabStripRef}
|
||||
disabled={tabStripDragScroll.isTabDragActive}
|
||||
/>
|
||||
</div>
|
||||
</SortableContext>
|
||||
{tabStripOverflowState.hasOverflow ? (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { closeWebRuntimeTerminal } from '@/runtime/web-runtime-session'
|
||||
import { resolveLeafCloseCopyKind } from '../terminal/terminal-close-copy-kind'
|
||||
import { RUNNING_CLOSE_PROBE_TIMEOUT_MS } from '../terminal/running-terminal-close-guard'
|
||||
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
|
||||
import { probePtyRunningWork } from '../terminal/pty-running-work-probe'
|
||||
import {
|
||||
detachTerminalPaneToTab,
|
||||
isTerminalTabStripDropTarget,
|
||||
@@ -99,12 +99,14 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
|
||||
copyKind: getCloseDialogCopyKind(paneId)
|
||||
})
|
||||
const probeTimeout = setTimeout(() => decide(confirmClose), RUNNING_CLOSE_PROBE_TIMEOUT_MS)
|
||||
void inspectRuntimeTerminalProcess(settings, ptyId)
|
||||
.then((process) => {
|
||||
// Why the shared probe rather than a direct inspect: this is the same question the tab-close
|
||||
// guard asks, and the two must not drift on what an unanswered host means.
|
||||
void probePtyRunningWork(settings, [ptyId], { timeoutMs: RUNNING_CLOSE_PROBE_TIMEOUT_MS })
|
||||
.then((probes) => {
|
||||
clearTimeout(probeTimeout)
|
||||
decide(() => {
|
||||
if (
|
||||
!process.hasChildProcesses ||
|
||||
probes[0]?.verdict !== 'live' ||
|
||||
settings?.skipCloseTerminalWithRunningProcessConfirm
|
||||
) {
|
||||
executeClosePane(paneId)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// The probe is the one place an inspection becomes a verdict, so it is the one place that has to
|
||||
// know `hasChildProcesses` cannot hold the third answer. A host that could not read its own process
|
||||
// table spells that the same way as one that read it and found nothing; Windows relays spelled it
|
||||
// `false` unconditionally, which read here as `exited` -- an idle verdict for a pane nobody looked at.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { inspectRuntimeTerminalProcessMock } = vi.hoisted(() => ({
|
||||
inspectRuntimeTerminalProcessMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock
|
||||
}))
|
||||
|
||||
import { probePtyRunningWork } from './pty-running-work-probe'
|
||||
|
||||
const SETTINGS = { activeRuntimeEnvironmentId: null }
|
||||
|
||||
describe('probePtyRunningWork child-process evidence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('asks the host to pay for a scan, because these probes back destructive decisions', async () => {
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
foregroundProcess: 'cmd.exe',
|
||||
hasChildProcesses: false,
|
||||
childProcessEvidence: 'no-children'
|
||||
})
|
||||
|
||||
await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 })
|
||||
|
||||
expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(SETTINGS, 'pty-a', {
|
||||
scanChildProcesses: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a host that could not observe the pane as unverifiable, not exited', async () => {
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
foregroundProcess: 'cmd.exe',
|
||||
hasChildProcesses: false,
|
||||
childProcessEvidence: 'unverifiable'
|
||||
})
|
||||
|
||||
const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 })
|
||||
|
||||
expect(probe).toMatchObject({
|
||||
verdict: 'unverifiable',
|
||||
reason: 'host_child_processes_unobserved',
|
||||
timedOut: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an observed-empty pane as exited', async () => {
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
foregroundProcess: 'cmd.exe',
|
||||
hasChildProcesses: false,
|
||||
childProcessEvidence: 'no-children'
|
||||
})
|
||||
|
||||
const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 })
|
||||
|
||||
expect(probe?.verdict).toBe('exited')
|
||||
})
|
||||
|
||||
it('reports an observed child as live', async () => {
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
foregroundProcess: 'PING.EXE',
|
||||
hasChildProcesses: true,
|
||||
childProcessEvidence: 'children'
|
||||
})
|
||||
|
||||
const [probe] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 })
|
||||
|
||||
expect(probe?.verdict).toBe('live')
|
||||
})
|
||||
|
||||
it('keeps the boolean meaning for a host that never published the verdict', async () => {
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValueOnce({
|
||||
foregroundProcess: 'node',
|
||||
hasChildProcesses: true
|
||||
})
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValueOnce({
|
||||
foregroundProcess: 'bash',
|
||||
hasChildProcesses: false
|
||||
})
|
||||
|
||||
const [live] = await probePtyRunningWork(SETTINGS, ['pty-a'], { timeoutMs: 1000 })
|
||||
const [idle] = await probePtyRunningWork(SETTINGS, ['pty-b'], { timeoutMs: 1000 })
|
||||
|
||||
expect(live?.verdict).toBe('live')
|
||||
expect(idle?.verdict).toBe('exited')
|
||||
})
|
||||
})
|
||||
@@ -54,14 +54,31 @@ export async function probePtyRunningWork(
|
||||
return
|
||||
}
|
||||
try {
|
||||
const inspection = await inspectRuntimeTerminalProcess(settings, ptyId)
|
||||
// Why the flag: this probe backs decisions that act once and destructively, so it is worth
|
||||
// a host process-table read on platforms where the child question costs one. The polled
|
||||
// inspections deliberately do not ask for it.
|
||||
const inspection = await inspectRuntimeTerminalProcess(settings, ptyId, {
|
||||
scanChildProcesses: true
|
||||
})
|
||||
probe.timedOut = false
|
||||
if (isClientOnlyUnverifiableInspection(inspection)) {
|
||||
probe.verdict = 'unverifiable'
|
||||
probe.reason = inspection.reason
|
||||
return
|
||||
}
|
||||
probe.verdict = inspection.hasChildProcesses ? 'live' : 'exited'
|
||||
// `hasChildProcesses` cannot hold the third answer: a host that could not read its own
|
||||
// process table spells that the same way as one that read it and found nothing. Windows
|
||||
// relays spelled it `false` unconditionally, which read here as `exited`.
|
||||
if (inspection.childProcessEvidence === 'unverifiable') {
|
||||
probe.verdict = 'unverifiable'
|
||||
probe.reason = 'host_child_processes_unobserved'
|
||||
return
|
||||
}
|
||||
probe.verdict =
|
||||
(inspection.childProcessEvidence ??
|
||||
(inspection.hasChildProcesses ? 'children' : 'no-children')) === 'children'
|
||||
? 'live'
|
||||
: 'exited'
|
||||
delete probe.reason
|
||||
} catch {
|
||||
// Why: `inspectRuntimeTerminalProcess` already maps every failure it can classify onto a
|
||||
|
||||
@@ -111,7 +111,9 @@ describe('guardRunningTerminalClose', () => {
|
||||
guard(onClose)
|
||||
await settleProbe()
|
||||
|
||||
expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(expect.anything(), 'pty-a')
|
||||
expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(expect.anything(), 'pty-a', {
|
||||
scanChildProcesses: true
|
||||
})
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1' })
|
||||
})
|
||||
|
||||
+5
-2
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* Mouse close path: SortableTab (X onClick / onAuxClick button===1) -> onClose
|
||||
* -> Terminal.tsx handleCloseTab -> closeTerminalTab() -> running-process guard.
|
||||
* Keyboard path: Cmd+W -> TerminalPane.handleRequestClosePane -> inspectRuntimeTerminalProcess
|
||||
* Keyboard path: Cmd+W -> TerminalPane.handleRequestClosePane -> probePtyRunningWork
|
||||
* (split panes) or closeTerminalTab's guard (last pane) -> CloseTerminalDialog.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -99,8 +99,11 @@ describe('#10142 close confirmation policy is the same for keyboard and mouse',
|
||||
'utf8'
|
||||
)
|
||||
const handler = source.slice(source.indexOf('const handleRequestClosePane'))
|
||||
// The shared probe, not a direct inspect: the pane path asks the same question as the tab
|
||||
// guard, and routing both through one measurement is what stops them drifting on what an
|
||||
// unanswered host means.
|
||||
expect(handler.slice(0, handler.indexOf('useImperativeHandle'))).toContain(
|
||||
'inspectRuntimeTerminalProcess'
|
||||
'probePtyRunningWork'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
+3
-1
@@ -96,7 +96,9 @@ export function listResult(
|
||||
totalCount: terminals.length,
|
||||
truncated: options.truncated ?? false,
|
||||
...(options.hostScope === undefined
|
||||
? { hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] } }
|
||||
? // A host names the execution hosts it covered, not its own environment id: answering
|
||||
// `terminal.list` on a paired runtime reports `local` (verified against a live runtime).
|
||||
{ hostScope: { hostIds: ['local'], omittedHostIds: [] } }
|
||||
: { hostScope: options.hostScope })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeTerminalListResult } from '../../../shared/runtime-types'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import { hostScopeCensusIsComplete } from '../../../shared/runtime-listing-host-scope'
|
||||
|
||||
/**
|
||||
* Asks the host whether ANY terminal is live in an environment, for the one
|
||||
@@ -74,10 +75,12 @@ async function probeHost(
|
||||
if (response.ok === false || !isTerminalListResult(response.result)) {
|
||||
return 'unverifiable'
|
||||
}
|
||||
// An omitted execution host is an incomplete census. In particular, a relay
|
||||
// can list its local PTYs while an SSH child host is still starting up.
|
||||
// A host this listing owed coverage for and did not deliver leaves an incomplete census — a
|
||||
// relay can list its local PTYs while an SSH child host is still starting up. A peer runtime
|
||||
// is not such a host: it answers `--environment` for itself, and reading its disclosure entry
|
||||
// as a gap latched this probe forever (#18595).
|
||||
const hostScope = response.result.hostScope
|
||||
if (hostScope && hostScope.omittedHostIds.length > 0) {
|
||||
if (hostScope && !hostScopeCensusIsComplete(hostScope)) {
|
||||
return 'unverifiable'
|
||||
}
|
||||
const { terminals, totalCount } = response.result
|
||||
|
||||
@@ -335,6 +335,89 @@ describe('empty host inventory settling the session mirror', () => {
|
||||
expect(hasHostSessionMirrorHydrated(environmentId, WORKTREE)).toBe(true)
|
||||
})
|
||||
|
||||
// #18595, the reporter's shape: the answering host holds a mirrored row for a peer runtime, so
|
||||
// that peer is named in `omittedHostIds` for every listing it will ever give. Reading the
|
||||
// disclosure list as the gate made this probe permanently `unverifiable`, the mirror never
|
||||
// hydrated, and every remote pane parked forever.
|
||||
it('settles on a peer runtime it disclosed but never owed coverage for', async () => {
|
||||
const result = await probeHostLiveTerminals(
|
||||
'env-peer-disclosed',
|
||||
vi.fn(async () => ({
|
||||
id: 'peer-disclosed',
|
||||
ok: true as const,
|
||||
result: {
|
||||
terminals: [],
|
||||
totalCount: 0,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: ['runtime:env-peer'] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}))
|
||||
)
|
||||
|
||||
expect(result).toBe('none')
|
||||
})
|
||||
|
||||
it('reports a live terminal through a peer-runtime disclosure', async () => {
|
||||
const result = await probeHostLiveTerminals(
|
||||
'env-peer-live',
|
||||
vi.fn(async () => ({
|
||||
id: 'peer-live',
|
||||
ok: true as const,
|
||||
result: {
|
||||
terminals: [{ handle: 'terminal-1' }],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: ['runtime:env-peer'] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}))
|
||||
)
|
||||
|
||||
expect(result).toBe('live')
|
||||
})
|
||||
|
||||
it('stays unverifiable for an SSH host the answering runtime did owe coverage for', async () => {
|
||||
const result = await probeHostLiveTerminals(
|
||||
'env-ssh-gap',
|
||||
vi.fn(async () => ({
|
||||
id: 'ssh-gap',
|
||||
ok: true as const,
|
||||
result: {
|
||||
terminals: [],
|
||||
totalCount: 0,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}))
|
||||
)
|
||||
|
||||
expect(result).toBe('unverifiable')
|
||||
})
|
||||
|
||||
// A worktree-scoped listing whose target is a runtime host covers nothing and names `local`
|
||||
// among its omissions. `local` is what keeps this unverifiable — the point is that the
|
||||
// runtime-only rule does not rescue a listing that answered for nothing.
|
||||
it('stays unverifiable when the listing covered no host at all', async () => {
|
||||
const result = await probeHostLiveTerminals(
|
||||
'env-covered-nothing',
|
||||
vi.fn(async () => ({
|
||||
id: 'covered-nothing',
|
||||
ok: true as const,
|
||||
result: {
|
||||
terminals: [],
|
||||
totalCount: 0,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: [], omittedHostIds: ['local', 'runtime:env-peer'] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}))
|
||||
)
|
||||
|
||||
expect(result).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('rejects a present malformed host scope as unverifiable', async () => {
|
||||
const result = await probeHostLiveTerminals(
|
||||
'env-malformed-scope',
|
||||
|
||||
@@ -146,6 +146,58 @@ describe('runtime terminal owner routing', () => {
|
||||
expect(localHasChildren).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why these exist: the close guards ask the host to pay for a real child-process read, and the
|
||||
// environment path used to drop the option before it reached the wire. The host then declined to
|
||||
// scan and answered `unverifiable`, which the guard reads as running work -- a confirmation
|
||||
// dialog on every idle close of a remote Windows pane. Found by review on #18591.
|
||||
it('forwards scanChildProcesses to the PTY owning environment', async () => {
|
||||
await inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
{ scanChildProcesses: true }
|
||||
)
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'terminal.inspectProcess',
|
||||
params: { terminal: 'terminal-1', scanChildProcesses: true },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards scanChildProcesses alongside the incarnation fence', async () => {
|
||||
await inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1',
|
||||
{ expectedIncarnationId: 'incarnation-1', scanChildProcesses: true }
|
||||
)
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'terminal.inspectProcess',
|
||||
params: {
|
||||
terminal: 'terminal-1',
|
||||
expectedIncarnationId: 'incarnation-1',
|
||||
scanChildProcesses: true
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('omits scanChildProcesses when the caller is only polling', async () => {
|
||||
await inspectRuntimeTerminalProcess(
|
||||
{ activeRuntimeEnvironmentId: 'env-2' },
|
||||
'remote:env-1@@terminal-1'
|
||||
)
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'terminal.inspectProcess',
|
||||
params: { terminal: 'terminal-1' },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('maps an old host inspection to client-only unverifiable', async () => {
|
||||
runtimeCall.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -138,7 +138,7 @@ export function recordRuntimeTerminalInputForPtyId(ptyId: string, timestamp = Da
|
||||
export async function inspectRuntimeTerminalProcess(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
options?: { expectedIncarnationId?: string }
|
||||
options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean }
|
||||
): Promise<RuntimeTerminalProcessInspection> {
|
||||
const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId)
|
||||
const target = ownerEnvironmentId
|
||||
@@ -148,7 +148,7 @@ export async function inspectRuntimeTerminalProcess(
|
||||
const remote = isRemoteInspectionPtyId(ptyId)
|
||||
if (target.kind !== 'environment' || !terminal) {
|
||||
try {
|
||||
const result = await (options?.expectedIncarnationId
|
||||
const result = await (options
|
||||
? window.api.pty.inspectProcess(ptyId, options)
|
||||
: window.api.pty.inspectProcess(ptyId))
|
||||
return normalizeInspectionResult(result, remote)
|
||||
@@ -169,7 +169,11 @@ export async function inspectRuntimeTerminalProcess(
|
||||
terminal,
|
||||
...(options?.expectedIncarnationId
|
||||
? { expectedIncarnationId: options.expectedIncarnationId }
|
||||
: {})
|
||||
: {}),
|
||||
// Why forwarded: the close guards pass this so the host pays for a real child-process read.
|
||||
// Dropped here, the host declines to scan and answers `unverifiable`, which the guard reads
|
||||
// as running work -- a confirmation dialog on every idle close of a remote Windows pane.
|
||||
...(options?.scanChildProcesses === true ? { scanChildProcesses: true } : {})
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ENVIRONMENT_ID,
|
||||
listResult,
|
||||
makeSnapshot,
|
||||
makeState
|
||||
} from './__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures'
|
||||
import {
|
||||
clearWebSessionTerminalOrphanRecoveryForTests,
|
||||
recoverWebSessionTerminalOrphansBeforeApply
|
||||
} from './web-session-terminal-orphan-recovery'
|
||||
|
||||
/**
|
||||
* The second consumer of `hostScopeCensusIsComplete`. Pruning here needs two consecutive
|
||||
* authoritative inventories that omit the surface, so an incomplete census must hold the binding
|
||||
* open indefinitely — and a peer runtime named in `omittedHostIds` must not be read as one, or
|
||||
* the ghost binding is retained forever (#18595).
|
||||
*/
|
||||
const LEAVES = [{ leafId: 'leaf-1', handle: 'term-ghost' }]
|
||||
|
||||
/**
|
||||
* `listResult` substitutes a default scope when handed `undefined`, so the absent-scope case has
|
||||
* to drop the key itself — passing `undefined` through the fixture silently tests the default.
|
||||
*/
|
||||
async function recoverTwice(worktree: string, hostScope: Record<string, unknown> | null) {
|
||||
const state = makeState(worktree, LEAVES)
|
||||
const call = vi.fn(async ({ method }: { method: string }) => {
|
||||
if (method === 'terminal.list') {
|
||||
const listed = listResult(worktree, [])
|
||||
if (hostScope === null) {
|
||||
delete (listed as { hostScope?: unknown }).hostScope
|
||||
} else {
|
||||
listed.hostScope = hostScope as never
|
||||
}
|
||||
return { ok: true as const, result: listed }
|
||||
}
|
||||
return { ok: false as const, error: { code: 'conflict', message: 'unexpected' } }
|
||||
})
|
||||
await recoverWebSessionTerminalOrphansBeforeApply(
|
||||
state,
|
||||
makeSnapshot(worktree, 'epoch-1', LEAVES),
|
||||
ENVIRONMENT_ID,
|
||||
{ call: call as never }
|
||||
)
|
||||
return recoverWebSessionTerminalOrphansBeforeApply(
|
||||
state,
|
||||
makeSnapshot(worktree, 'epoch-2', LEAVES),
|
||||
ENVIRONMENT_ID,
|
||||
{ call: call as never }
|
||||
)
|
||||
}
|
||||
|
||||
describe('orphan recovery reads the host scope as coverage owed, not as disclosure', () => {
|
||||
beforeEach(() => {
|
||||
clearWebSessionTerminalOrphanRecoveryForTests()
|
||||
})
|
||||
|
||||
it('prunes a twice-absent binding when the only omission is a peer runtime', async () => {
|
||||
const settled = await recoverTwice('repo::peer-disclosed', {
|
||||
hostIds: ['local'],
|
||||
omittedHostIds: ['runtime:env-peer']
|
||||
})
|
||||
|
||||
expect(settled?.tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('retains the binding when an SSH host this runtime does query went unanswered', async () => {
|
||||
const settled = await recoverTwice('repo::ssh-gap', {
|
||||
hostIds: ['local'],
|
||||
omittedHostIds: ['ssh:box-1']
|
||||
})
|
||||
|
||||
expect(settled?.tabs).toEqual([
|
||||
expect.objectContaining({ leafId: 'leaf-1', terminal: 'term-ghost' })
|
||||
])
|
||||
})
|
||||
|
||||
it('retains the binding when the listing covered no host at all', async () => {
|
||||
const settled = await recoverTwice('repo::covered-nothing', {
|
||||
hostIds: [],
|
||||
omittedHostIds: ['local', 'runtime:env-peer']
|
||||
})
|
||||
|
||||
expect(settled?.tabs).toEqual([
|
||||
expect.objectContaining({ leafId: 'leaf-1', terminal: 'term-ghost' })
|
||||
])
|
||||
})
|
||||
|
||||
// Why: a host predating `hostScope` (v1.4.187) cannot say what it covered, and absence of the
|
||||
// claim is never the claim.
|
||||
it('retains the binding when the host is too old to publish a scope', async () => {
|
||||
const settled = await recoverTwice('repo::no-scope', null)
|
||||
|
||||
expect(settled?.tabs).toEqual([
|
||||
expect.objectContaining({ leafId: 'leaf-1', terminal: 'term-ghost' })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type RecoverySurface
|
||||
} from './web-session-terminal-orphan-recovery-surface'
|
||||
import { runInTerminalRecoveryRpcLane } from './web-session-terminal-orphan-recovery-rpc-lane'
|
||||
import { hostScopeCensusIsComplete } from '../../../shared/runtime-listing-host-scope'
|
||||
|
||||
type RuntimeCall = (args: {
|
||||
selector: string
|
||||
@@ -163,9 +164,9 @@ export async function resolveTerminalOrphanInventory(args: {
|
||||
listedByHandle.set(terminal.handle, terminal)
|
||||
}
|
||||
}
|
||||
// Older hosts omit hostScope entirely; an unscoped absence cannot prove a PTY exited.
|
||||
const hostScopeUnverifiable =
|
||||
listed.hostScope === undefined || listed.hostScope.omittedHostIds.length > 0
|
||||
// Older hosts omit hostScope entirely; an unscoped absence cannot prove a PTY exited. A peer
|
||||
// runtime named in `omittedHostIds` is disclosure rather than a gap this host owed (#18595).
|
||||
const hostScopeUnverifiable = !hostScopeCensusIsComplete(listed.hostScope)
|
||||
const dispositions = new Map<string, RecoveryDisposition>()
|
||||
const claims: RuntimeTerminalOrphanAdoptionClaim[] = []
|
||||
for (const surface of inventorySurfaces) {
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('web session pending terminal handle recovery', () => {
|
||||
topologyRevisions: { [WORKTREE_ID]: 4 },
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] }
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,7 +316,7 @@ describe('web session pending terminal handle recovery', () => {
|
||||
topologyRevisions: { [WORKTREE_ID]: 4 },
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] }
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,7 +394,7 @@ describe('web session pending terminal handle recovery', () => {
|
||||
topologyRevisions: { [WORKTREE_ID]: 4 },
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] }
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,7 +459,7 @@ describe('web session pending terminal handle recovery', () => {
|
||||
terminals: [],
|
||||
totalCount: 0,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] }
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] }
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -655,7 +655,7 @@ describe('web session pending terminal handle recovery', () => {
|
||||
terminals: [],
|
||||
totalCount: 0,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: [ENVIRONMENT_ID], omittedHostIds: [] }
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] }
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
getExecutionHostLabel,
|
||||
getLocalExecutionHostLabel,
|
||||
getRepoExecutionHostId,
|
||||
getRepoSshConnectionId,
|
||||
@@ -169,4 +170,16 @@ describe('execution host id delimiter invariant', () => {
|
||||
targetId: 'a|b'
|
||||
})
|
||||
})
|
||||
|
||||
// "All hosts" is the everything-scope. Answering with it for an id that names no host shows one
|
||||
// unroutable row as though it were on every host, which is the opposite of what it is.
|
||||
it('labels an id that names no host as one unknown host, not as every host', () => {
|
||||
for (const id of ['ssh:', 'ssh:a|b', 'ssh:%zz', 'runtime:', 'quantum:box'] as const) {
|
||||
expect(getExecutionHostLabel(id as never)).toBe('Unknown host')
|
||||
}
|
||||
expect(getExecutionHostLabel(null)).toBe('Unknown host')
|
||||
expect(getExecutionHostLabel(ALL_EXECUTION_HOSTS_SCOPE)).toBe('All hosts')
|
||||
expect(getExecutionHostLabel('ssh:box')).toBe('box')
|
||||
expect(getExecutionHostLabel('runtime:env-1')).toBe('env-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,13 +226,18 @@ export function getSettingsFocusedExecutionHostId(
|
||||
: LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
export function getExecutionHostLabel(id: ExecutionHostScope): string {
|
||||
export function getExecutionHostLabel(id: ExecutionHostScope | null | undefined): string {
|
||||
if (id === ALL_EXECUTION_HOSTS_SCOPE) {
|
||||
return 'All hosts'
|
||||
}
|
||||
const parsed = parseExecutionHostId(id)
|
||||
if (!parsed) {
|
||||
return 'All hosts'
|
||||
// Not "All hosts": an id that names no host is one *unknown* host, and answering with the
|
||||
// everything-scope label shows an unroutable row as though it were on every host.
|
||||
// Plain English like every other label in this module (`Local Mac`, `This computer`,
|
||||
// `All hosts`) — none of them resolve through the renderer's i18n catalog, so a lone
|
||||
// translated string here would read inconsistently.
|
||||
return 'Unknown host'
|
||||
}
|
||||
switch (parsed.kind) {
|
||||
case 'local':
|
||||
|
||||
@@ -174,6 +174,194 @@ describe('folder workspace execution host', () => {
|
||||
expect(resolveFolderWorkspaceHost(state({ repos: [] }), 'fw-1')).toEqual({ kind: 'local' })
|
||||
})
|
||||
|
||||
// SSH ownership has two spellings on a repo row. A row carrying only `executionHostId: 'ssh:*'`
|
||||
// has no `connectionId`, and reading the raw field counted it as a local repo — so a workspace
|
||||
// whose files live on an SSH host resolved `local`, which is an execute-here answer for a remote
|
||||
// path. These fire on well-formed rows; nothing malformed is involved.
|
||||
it('resolves a repo that names its SSH host only through executionHostId', () => {
|
||||
const resolved = resolveFolderWorkspaceHost(
|
||||
state({
|
||||
repos: [
|
||||
repo({
|
||||
id: 'repo-1',
|
||||
path: '/work/app/a',
|
||||
projectGroupId: 'group-1',
|
||||
executionHostId: 'ssh:box'
|
||||
})
|
||||
]
|
||||
}),
|
||||
'fw-1'
|
||||
)
|
||||
|
||||
expect(resolved).toEqual({ kind: 'ssh', targetId: 'box' })
|
||||
})
|
||||
|
||||
it('mixes such a repo with a local one as ambiguous rather than local', () => {
|
||||
const resolved = resolveFolderWorkspaceHost(
|
||||
state({
|
||||
repos: [
|
||||
repo({ id: 'repo-1', path: '/work/app/a', projectGroupId: 'group-1' }),
|
||||
repo({
|
||||
id: 'repo-2',
|
||||
path: '/work/app/b',
|
||||
projectGroupId: 'group-1',
|
||||
executionHostId: 'ssh:box'
|
||||
})
|
||||
]
|
||||
}),
|
||||
'fw-1'
|
||||
)
|
||||
|
||||
expect(resolved).toEqual({ kind: 'ambiguous' })
|
||||
})
|
||||
|
||||
it('matches a scope connection against such a repo instead of calling it ambiguous', () => {
|
||||
const resolved = resolveFolderWorkspaceHost(
|
||||
state({
|
||||
folderWorkspaces: [workspace({ connectionId: 'box' })],
|
||||
repos: [
|
||||
repo({
|
||||
id: 'repo-1',
|
||||
path: '/work/app/a',
|
||||
projectGroupId: 'group-1',
|
||||
executionHostId: 'ssh:box'
|
||||
})
|
||||
]
|
||||
}),
|
||||
'fw-1'
|
||||
)
|
||||
|
||||
expect(resolved).toEqual({ kind: 'ssh', targetId: 'box' })
|
||||
})
|
||||
|
||||
it('reads the target off the host, so a percent-encoded id decodes', () => {
|
||||
const resolved = resolveFolderWorkspaceHost(
|
||||
state({
|
||||
repos: [
|
||||
repo({
|
||||
id: 'repo-1',
|
||||
path: '/work/app/a',
|
||||
projectGroupId: 'group-1',
|
||||
executionHostId: `ssh:${encodeURIComponent('box 1')}`
|
||||
})
|
||||
]
|
||||
}),
|
||||
'fw-1'
|
||||
)
|
||||
|
||||
expect(resolved).toEqual({ kind: 'ssh', targetId: 'box 1' })
|
||||
})
|
||||
|
||||
// Deliberately unchanged: a `runtime:` row's nested SSH target is not this client's to dial, but
|
||||
// narrowing that here would be a second behaviour change riding on the SSH fix.
|
||||
it('leaves a runtime row contributing its nested connection exactly as before', () => {
|
||||
const resolved = resolveFolderWorkspaceHost(
|
||||
state({
|
||||
repos: [
|
||||
repo({
|
||||
id: 'repo-1',
|
||||
path: '/work/app/a',
|
||||
projectGroupId: 'group-1',
|
||||
executionHostId: 'runtime:env-1',
|
||||
connectionId: 'nested-box'
|
||||
})
|
||||
]
|
||||
}),
|
||||
'fw-1'
|
||||
)
|
||||
|
||||
expect(resolved).toEqual({ kind: 'ssh', targetId: 'nested-box' })
|
||||
})
|
||||
|
||||
it('still answers local for a runtime pin, which the type cannot express otherwise', () => {
|
||||
const resolved = resolveFolderWorkspaceHost(
|
||||
state({
|
||||
folderWorkspaces: [workspace({ executionHostId: 'runtime:env-1' })]
|
||||
}),
|
||||
'fw-1'
|
||||
)
|
||||
|
||||
expect(resolved).toEqual({ kind: 'local' })
|
||||
})
|
||||
|
||||
// The candidate FILTER decides which rows reach the resolver, and it read `repo.connectionId` raw
|
||||
// too — so an SSH-only repo outside the project-group subtree was dropped before any of the above
|
||||
// could classify it. Every test before this one uses a repo inside the subtree, which is never
|
||||
// filtered, so none of them could have caught it (found in review by CodeRabbit).
|
||||
describe('a repo matched only by path, outside the project-group subtree', () => {
|
||||
const sshOnlyPathRepo = repo({
|
||||
id: 'repo-path',
|
||||
path: '/work/app/nested',
|
||||
executionHostId: 'ssh:box'
|
||||
})
|
||||
|
||||
it('survives the scope-connection filter instead of being dropped as connectionless', () => {
|
||||
const scoped = state({
|
||||
folderWorkspaces: [workspace({ connectionId: 'box' })],
|
||||
repos: [sshOnlyPathRepo]
|
||||
})
|
||||
|
||||
expect(findFolderWorkspaceCandidateRepos(scoped, 'fw-1')).toEqual([sshOnlyPathRepo])
|
||||
expect(resolveFolderWorkspaceHost(scoped, 'fw-1')).toEqual({ kind: 'ssh', targetId: 'box' })
|
||||
})
|
||||
|
||||
// Pins the resolver, not the filter: under the old raw read BOTH rows came back connectionless,
|
||||
// so they matched each other by accident and this case survived the filter either way. The
|
||||
// legacy-vs-unified pairing below is the one that discriminates.
|
||||
it('survives the group-connection filter when the group is on that same SSH host', () => {
|
||||
const scoped = state({
|
||||
repos: [
|
||||
repo({
|
||||
id: 'repo-group',
|
||||
path: '/work/app/group',
|
||||
projectGroupId: 'group-1',
|
||||
executionHostId: 'ssh:box'
|
||||
}),
|
||||
sshOnlyPathRepo
|
||||
]
|
||||
})
|
||||
|
||||
expect(findFolderWorkspaceCandidateRepos(scoped, 'fw-1')).toHaveLength(2)
|
||||
expect(resolveFolderWorkspaceHost(scoped, 'fw-1')).toEqual({ kind: 'ssh', targetId: 'box' })
|
||||
})
|
||||
|
||||
// Both sides of the group comparison are resolved, so the legacy spelling on one side and the
|
||||
// unified spelling on the other still match.
|
||||
it('matches a legacy-spelled group repo against a unified-spelled path repo', () => {
|
||||
const scoped = state({
|
||||
repos: [
|
||||
repo({
|
||||
id: 'repo-group',
|
||||
path: '/work/app/group',
|
||||
projectGroupId: 'group-1',
|
||||
connectionId: 'box'
|
||||
}),
|
||||
sshOnlyPathRepo
|
||||
]
|
||||
})
|
||||
|
||||
expect(findFolderWorkspaceCandidateRepos(scoped, 'fw-1')).toHaveLength(2)
|
||||
expect(resolveFolderWorkspaceHost(scoped, 'fw-1')).toEqual({ kind: 'ssh', targetId: 'box' })
|
||||
})
|
||||
|
||||
// A `runtime:` row's nested target is still read from the raw field, so it matches a scope
|
||||
// connection exactly as it does today. Pinned so the carve-out stays a decision.
|
||||
it('leaves a runtime row matching the scope connection through its nested target', () => {
|
||||
const runtimePathRepo = repo({
|
||||
id: 'repo-path',
|
||||
path: '/work/app/nested',
|
||||
executionHostId: 'runtime:env-1',
|
||||
connectionId: 'box'
|
||||
})
|
||||
const scoped = state({
|
||||
folderWorkspaces: [workspace({ connectionId: 'box' })],
|
||||
repos: [runtimePathRepo]
|
||||
})
|
||||
|
||||
expect(findFolderWorkspaceCandidateRepos(scoped, 'fw-1')).toEqual([runtimePathRepo])
|
||||
})
|
||||
})
|
||||
|
||||
it('reads each repository membership once while collecting candidates', () => {
|
||||
let membershipReads = 0
|
||||
const repos = Array.from({ length: 32 }, (_, index) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ProjectGroup } from './project-group-types'
|
||||
import type { Repo } from './repo-types'
|
||||
import { isPathInsideOrEqual } from './cross-platform-path'
|
||||
import { getProjectGroupSubtreeIds } from './project-groups'
|
||||
import { parseExecutionHostId } from './execution-host'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from './execution-host'
|
||||
|
||||
export type FolderWorkspaceHostState = {
|
||||
folderWorkspaces: readonly FolderWorkspace[]
|
||||
@@ -36,6 +36,24 @@ export function normalizeConnectionId(value: string | null | undefined): string
|
||||
return value?.trim() || null
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH target whose filesystem holds this repo's files, or `null` for anything else.
|
||||
*
|
||||
* SSH ownership has two spellings on a repo row — the legacy `connectionId` field and the unified
|
||||
* `executionHostId` — so reading the raw field sees only one of them and a row carrying only
|
||||
* `executionHostId: 'ssh:<target>'` reads as if it had no connection at all. Every comparison in
|
||||
* this file goes through here: the candidate filters decide which rows reach the resolver, so
|
||||
* reading raw in either place drops the row before the resolver can classify it.
|
||||
*
|
||||
* A non-SSH host falls back to the raw field so a `runtime:` row keeps contributing its nested
|
||||
* target exactly as it does today. That target is not this client's to dial, but changing it is a
|
||||
* separate defect with its own reasoning — see the note in `resolveFolderWorkspaceHost`.
|
||||
*/
|
||||
function getRepoScopeConnectionId(repo: Repo): string | null {
|
||||
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
|
||||
return host?.kind === 'ssh' ? host.targetId : normalizeConnectionId(repo.connectionId)
|
||||
}
|
||||
|
||||
function getFolderScopeCandidateRepos(args: {
|
||||
folderPath: string
|
||||
projectGroupId: string
|
||||
@@ -59,18 +77,18 @@ function getFolderScopeCandidateRepos(args: {
|
||||
if (args.connectionId) {
|
||||
return [
|
||||
...groupRepos,
|
||||
...pathRepos.filter((repo) => normalizeConnectionId(repo.connectionId) === args.connectionId)
|
||||
...pathRepos.filter((repo) => getRepoScopeConnectionId(repo) === args.connectionId)
|
||||
]
|
||||
}
|
||||
if (groupRepos.length === 0) {
|
||||
return pathRepos
|
||||
}
|
||||
const groupConnectionIds = new Set(
|
||||
groupRepos.map((repo) => normalizeConnectionId(repo.connectionId))
|
||||
)
|
||||
// Both sides resolved: comparing a resolved path repo against a raw group read would reintroduce
|
||||
// the same mismatch from the other direction.
|
||||
const groupConnectionIds = new Set(groupRepos.map(getRepoScopeConnectionId))
|
||||
return [
|
||||
...groupRepos,
|
||||
...pathRepos.filter((repo) => groupConnectionIds.has(normalizeConnectionId(repo.connectionId)))
|
||||
...pathRepos.filter((repo) => groupConnectionIds.has(getRepoScopeConnectionId(repo)))
|
||||
]
|
||||
}
|
||||
|
||||
@@ -102,6 +120,12 @@ export function resolveFolderWorkspaceHost(
|
||||
}
|
||||
const explicitHost = parseExecutionHostId(workspace.executionHostId)
|
||||
if (explicitHost) {
|
||||
// A `runtime:` workspace deliberately answers `local`, and `FolderWorkspaceHost` has no runtime
|
||||
// variant to answer with instead. That omission is known: a runtime environment's own server
|
||||
// normalizes its work to `local`, and the nested SSH target on such a row is addressable only as
|
||||
// the pair (environmentId, targetId) — handing it to this client's SSH table would dial a
|
||||
// same-named box in the wrong namespace. Widening the type is its own change, not an oversight
|
||||
// here.
|
||||
return explicitHost.kind === 'ssh'
|
||||
? { kind: 'ssh', targetId: explicitHost.targetId }
|
||||
: { kind: 'local' }
|
||||
@@ -114,7 +138,7 @@ export function resolveFolderWorkspaceHost(
|
||||
let hasLocalRepo = false
|
||||
const connectionIds = new Set<string>()
|
||||
for (const repo of candidateRepos) {
|
||||
const connectionId = normalizeConnectionId(repo.connectionId)
|
||||
const connectionId = getRepoScopeConnectionId(repo)
|
||||
if (connectionId) {
|
||||
connectionIds.add(connectionId)
|
||||
} else {
|
||||
|
||||
@@ -57,6 +57,10 @@ export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [
|
||||
// title request with no title and no error.
|
||||
{ filename: 'wsl-transcript-fs-process-entry.js' },
|
||||
{ filename: 'node-pty-1.1.0-console-list-agent-patch.cjs', windowsOnly: true },
|
||||
// The ConPTY teardown release the desktop's own node-pty patch already carries; pnpm patches do
|
||||
// not cross the SSH boundary, so a relay ran the unpatched npm tree and leaked one Windows File
|
||||
// handle per terminal for the life of the relay process.
|
||||
{ filename: 'node-pty-1.1.0-windows-pty-teardown-patch.cjs', windowsOnly: true },
|
||||
// Only Linux relays run it, but it ships everywhere: the manifest's only
|
||||
// platform axis is Windows, and a second one would buy nothing but a fork in
|
||||
// the hash. Its presence is what moves a host to a fresh relay directory, and
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hostScopeCensusIsComplete } from './runtime-listing-host-scope'
|
||||
|
||||
/**
|
||||
* The gate and the disclosure list answer different questions off the same field. These pin the
|
||||
* cases where they disagree — which is every case that mattered in #18595.
|
||||
*/
|
||||
describe('hostScopeCensusIsComplete', () => {
|
||||
it('reads a peer runtime as disclosure, not as coverage this host owed', () => {
|
||||
expect(
|
||||
hostScopeCensusIsComplete({ hostIds: ['local'], omittedHostIds: ['runtime:env-7'] })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('still reads an SSH host as a gap, because this runtime does query those', () => {
|
||||
expect(hostScopeCensusIsComplete({ hostIds: ['local'], omittedHostIds: ['ssh:box-1'] })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('reads a mixed omission as incomplete on the strength of the SSH host alone', () => {
|
||||
expect(
|
||||
hostScopeCensusIsComplete({
|
||||
hostIds: ['local'],
|
||||
omittedHostIds: ['runtime:env-7', 'ssh:box-1']
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('calls a clean census complete', () => {
|
||||
expect(hostScopeCensusIsComplete({ hostIds: ['local', 'ssh:box-1'], omittedHostIds: [] })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
// Defence in depth: `listKnownExecutionHostIds` always seeds `local`, so a real scope that
|
||||
// covered nothing also omits `local` and is refused by the runtime-only rule anyway. This
|
||||
// branch is what stops a scope that answered for no host from ever reading complete if that
|
||||
// ever stops holding.
|
||||
it('refuses a listing that covered no host at all', () => {
|
||||
expect(hostScopeCensusIsComplete({ hostIds: [], omittedHostIds: ['runtime:env-7'] })).toBe(
|
||||
false
|
||||
)
|
||||
expect(hostScopeCensusIsComplete({ hostIds: [], omittedHostIds: [] })).toBe(false)
|
||||
})
|
||||
|
||||
// Why: `hostScope` shipped in v1.4.187. An older host cannot say what it covered, and absence
|
||||
// of the claim is never the claim — this must stay the first branch.
|
||||
it('refuses a host too old to publish a scope', () => {
|
||||
expect(hostScopeCensusIsComplete(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: without this the runtime-only rule trusts a coverage claim it cannot read — the shape
|
||||
// `{hostIds: ['runtime:'], omittedHostIds: ['runtime:env-7']}` was `unverifiable` before the
|
||||
// gate changed and must not become `complete` on the strength of an unparseable id.
|
||||
it('refuses a coverage claim with no legible host in it', () => {
|
||||
expect(
|
||||
hostScopeCensusIsComplete({
|
||||
hostIds: ['runtime:' as never],
|
||||
omittedHostIds: ['runtime:env-7']
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
// Why "at least one legible" and not "all legible": a host that later gains a kind this client
|
||||
// cannot parse must not report an incomplete census forever — that is this bug in a new coat.
|
||||
it('accepts a coverage claim carrying one legible host beside an unreadable one', () => {
|
||||
expect(
|
||||
hostScopeCensusIsComplete({ hostIds: ['local', 'newkind:x' as never], omittedHostIds: [] })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses an unparseable host id rather than discounting it', () => {
|
||||
expect(
|
||||
hostScopeCensusIsComplete({
|
||||
hostIds: ['local'],
|
||||
omittedHostIds: ['runtime:' as never]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from './execution-host'
|
||||
|
||||
/**
|
||||
* What a bounded listing did and did not cover, by execution host. An absent scope means the
|
||||
@@ -10,3 +10,32 @@ export type RuntimeListingHostScope = {
|
||||
hostIds: ExecutionHostId[]
|
||||
omittedHostIds: ExecutionHostId[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the answering runtime enumerated every host its listing owed coverage for.
|
||||
*
|
||||
* `omittedHostIds` is a disclosure list and deliberately over-names — `omitted-host-scope-selectors.ts`
|
||||
* keeps ids for servers that are no longer paired so a caller can still see the gap. That makes it the
|
||||
* wrong input for a completeness gate, which needs "coverage owed and not delivered". The two jobs pull
|
||||
* in opposite directions, and reading the disclosure list as the gate latched every remote pane on any
|
||||
* client that had ever paired outward (#18595).
|
||||
*
|
||||
* A `runtime:` host is never owed coverage by the runtime answering: a paired runtime is a peer with its
|
||||
* own control plane, reached with `--environment`, and this runtime has no paired-runtime PTY provider to
|
||||
* have queried. Its terminals are its own answer to give, so its presence here is disclosure, not a gap.
|
||||
*/
|
||||
export function hostScopeCensusIsComplete(scope: RuntimeListingHostScope | undefined): boolean {
|
||||
// A host too old to publish a scope cannot claim one; absence is never completeness.
|
||||
if (scope === undefined) {
|
||||
return false
|
||||
}
|
||||
// A listing that covered no host proves nothing, and an unreadable coverage claim is not a
|
||||
// claim: `isTerminalListResult` checks only that `hostIds` is an array, so at least one covered
|
||||
// id has to be legible before the claim can be believed. Deliberately "at least one" rather than
|
||||
// "all": a host that later gains a kind this client cannot parse would otherwise report an
|
||||
// incomplete census forever, which is the bug this predicate exists to stop.
|
||||
if (!scope.hostIds.some((hostId) => parseExecutionHostId(hostId))) {
|
||||
return false
|
||||
}
|
||||
return scope.omittedHostIds.every((hostId) => parseExecutionHostId(hostId)?.kind === 'runtime')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { RemoteForegroundEvidence } from './foreground-process-evidence'
|
||||
|
||||
/**
|
||||
* What the execution host observed about processes running under a PTY's shell.
|
||||
*
|
||||
* Separate from `hasChildProcesses` because a boolean cannot hold the third answer. The host that
|
||||
* could not read its own process table and the host that read it and found nothing both had to
|
||||
* spell themselves `false`, and every close guard reads `false` as "nothing is running here".
|
||||
* Windows relays spelled it `false` unconditionally.
|
||||
*/
|
||||
export type PtyChildProcessVerdict = 'children' | 'no-children' | 'unverifiable'
|
||||
|
||||
/** Reasons the renderer could not observe the execution host. */
|
||||
export type ClientOnlyUnverifiableReason =
|
||||
| 'transport_loss'
|
||||
@@ -17,6 +27,7 @@ export type ClientOnlyUnverifiableInspection = {
|
||||
verdict: 'unverifiable'
|
||||
reason: string
|
||||
foregroundProcessEvidence?: never
|
||||
childProcessEvidence?: never
|
||||
authorityGeneration?: never
|
||||
observationEpoch?: never
|
||||
capturedAgeMs?: never
|
||||
@@ -30,6 +41,8 @@ export type HostProcessInspection = {
|
||||
hasChildProcesses: boolean
|
||||
/** Optional on old hosts; the renderer treats an omitted field as old-host unverifiable. */
|
||||
foregroundProcessEvidence?: RemoteForegroundEvidence
|
||||
/** Absent on hosts that predate the member, and on answers the host did not pay to observe. */
|
||||
childProcessEvidence?: PtyChildProcessVerdict
|
||||
verdict?: never
|
||||
reason?: never
|
||||
}
|
||||
|
||||
@@ -156,6 +156,40 @@ describe('resolveWorktreeExecutionHost', () => {
|
||||
it('reports an unknown owner distinctly from a conflicting one', () => {
|
||||
expect(resolve([], { repoId: 'r' })).toEqual({ kind: 'unresolved', reason: 'unknown' })
|
||||
})
|
||||
|
||||
// `unknown` is a verdict the launch path disposes of as a plain local folder, so a row that
|
||||
// declared a host and named an unparseable one must not share the word — it has to fail closed.
|
||||
it('reports a row naming an unparseable host distinctly from an unknown one', () => {
|
||||
for (const executionHostId of ['ssh:', 'ssh:a|b', 'ssh:%zz', 'runtime:', 'quantum:box']) {
|
||||
expect(resolve([{ id: 'r', executionHostId }], { repoId: 'r' })).toEqual({
|
||||
kind: 'unresolved',
|
||||
reason: 'malformed'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('does not recover a host from the connectionId such a row overrode', () => {
|
||||
expect(
|
||||
resolve([{ id: 'r', executionHostId: 'ssh:a|b', connectionId: 'openclaw' }], {
|
||||
repoId: 'r'
|
||||
})
|
||||
).toEqual({ kind: 'unresolved', reason: 'malformed' })
|
||||
})
|
||||
|
||||
it('still resolves every row that names a parseable host', () => {
|
||||
expect(resolve([{ id: 'r', executionHostId: 'ssh:box' }], { repoId: 'r' })).toMatchObject({
|
||||
kind: 'resolved',
|
||||
hostId: 'ssh:box'
|
||||
})
|
||||
expect(resolve([{ id: 'r', connectionId: 'box' }], { repoId: 'r' })).toMatchObject({
|
||||
kind: 'resolved',
|
||||
hostId: 'ssh:box'
|
||||
})
|
||||
expect(resolve([{ id: 'r' }], { repoId: 'r' })).toMatchObject({
|
||||
kind: 'resolved',
|
||||
hostId: 'local'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores an unparseable host id rather than treating it as a host', () => {
|
||||
|
||||
@@ -54,7 +54,29 @@ export type WorktreeExecutionHostResolution<T extends ExecutionHostOwnerRow> =
|
||||
/** Display metadata only. The decisions are `hostId` / `connectionId`. */
|
||||
owner: T | null
|
||||
}
|
||||
| { kind: 'unresolved'; reason: 'ambiguous' | 'unknown' }
|
||||
/**
|
||||
* Three reasons, not two, and deliberately not collapsed. `unknown` (nothing carries the id) is a
|
||||
* verdict the launch path may legitimately dispose of as a plain local folder; `malformed` (the
|
||||
* row named a host that cannot be parsed) must fail closed. A vocabulary that cannot express the
|
||||
* difference guarantees it is lost at the first caller that switches on it — the same shape as
|
||||
* #18006, where one word had to stand for two liveness situations.
|
||||
*/
|
||||
| { kind: 'unresolved'; reason: 'ambiguous' | 'unknown' | 'malformed' }
|
||||
|
||||
/**
|
||||
* The owner row's host, or `null` when the row names one that cannot be parsed.
|
||||
*
|
||||
* Module-private and deliberately not a second exported reading of a repo row: only this resolution
|
||||
* needs the distinction, because only this resolution is routing. `getRepoExecutionHostId` stays the
|
||||
* answer everywhere else — its fall-through to `local` is harmless for the grouping, label and index
|
||||
* callers that make up nearly all of its ~340 call sites, and is wrong only when the value decides
|
||||
* where work runs.
|
||||
*/
|
||||
function resolveOwnerRowHostId(row: ExecutionHostOwnerRow): ExecutionHostId | null {
|
||||
return row.executionHostId?.trim()
|
||||
? normalizeExecutionHostId(row.executionHostId)
|
||||
: getRepoExecutionHostId(row)
|
||||
}
|
||||
|
||||
export function resolveWorktreeExecutionHost<T extends ExecutionHostOwnerRow>(
|
||||
lookup: ExecutionHostOwnerLookup<T>,
|
||||
@@ -80,9 +102,13 @@ export function resolveWorktreeExecutionHost<T extends ExecutionHostOwnerRow>(
|
||||
if (match.kind !== 'resolved') {
|
||||
return { kind: 'unresolved', reason: match.kind === 'ambiguous' ? 'ambiguous' : 'unknown' }
|
||||
}
|
||||
const hostId = resolveOwnerRowHostId(match.owner)
|
||||
if (!hostId) {
|
||||
return { kind: 'unresolved', reason: 'malformed' }
|
||||
}
|
||||
return {
|
||||
kind: 'resolved',
|
||||
hostId: getRepoExecutionHostId(match.owner),
|
||||
hostId,
|
||||
connectionId: getRepoSshConnectionId(match.owner),
|
||||
owner: match.owner
|
||||
}
|
||||
@@ -115,12 +141,14 @@ export function createRepoRowExecutionHostLookup<T extends ExecutionHostOwnerRow
|
||||
if (!owner) {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
const ownerHostId = getRepoExecutionHostId(owner)
|
||||
return rows.some((repo) => getRepoExecutionHostId(repo) !== ownerHostId)
|
||||
const ownerHostId = resolveOwnerRowHostId(owner)
|
||||
return rows.some((repo) => resolveOwnerRowHostId(repo) !== ownerHostId)
|
||||
? { kind: 'ambiguous' }
|
||||
: { kind: 'resolved', owner }
|
||||
},
|
||||
// A row naming an unparseable host matches no host, which is what stops a worktree on a real
|
||||
// host from adopting it.
|
||||
byHost: (repoId, hostId) =>
|
||||
rowsFor(repoId).find((repo) => getRepoExecutionHostId(repo) === hostId) ?? null
|
||||
rowsFor(repoId).find((repo) => resolveOwnerRowHostId(repo) === hostId) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user