mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
Merge current main to include the corrected git admission import
This commit is contained in:
@@ -53,23 +53,6 @@ describe('admin routes under a database that is briefly out of reach', () => {
|
||||
expect(response.headers.get('Retry-After')).toBe('7')
|
||||
expect(await response.json()).toEqual({ error: 'database_temporarily_unavailable' })
|
||||
})
|
||||
|
||||
it('answers admission-selector/status with a retryable 503 instead of a conflict', async () => {
|
||||
const inspectCellAdmissionSelector = vi.fn(async () => {
|
||||
throw poolTimeout()
|
||||
})
|
||||
const app = appWith({ inspectCellAdmissionSelector })
|
||||
|
||||
const response = await app.request(
|
||||
'/v1/admin/admission-selector/status',
|
||||
adminRequest({ v: 1, attemptId: '22222222-2222-4222-8222-222222222222' })
|
||||
)
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(response.headers.get('Retry-After')).toBe('5')
|
||||
expect(await response.json()).toEqual({ error: 'database_temporarily_unavailable' })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
function config(overrides: Partial<RelayConfig> = {}): RelayConfig {
|
||||
|
||||
@@ -1,53 +1,14 @@
|
||||
import pg from 'pg'
|
||||
import { afterAll, afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { RelayAssignmentStore } from '../assignment-store.js'
|
||||
import type { RelayCellConfig } from '../../config.js'
|
||||
import {
|
||||
openInMemoryRelayDatabase,
|
||||
openRelayDatabase,
|
||||
type RelayDatabase
|
||||
} from '../../database/database.js'
|
||||
import { openInMemoryRelayDatabase, type RelayDatabase } from '../../database/database.js'
|
||||
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const schema = 'relay_dead_cell_precheck_test'
|
||||
const CELLS: RelayCellConfig[] = [
|
||||
{ id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 },
|
||||
{ id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 }
|
||||
]
|
||||
|
||||
function scopedUrl(): string {
|
||||
const url = new URL(databaseUrl!)
|
||||
url.searchParams.set('options', `-c search_path=${schema}`)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function onAdmin(sql: string): Promise<void> {
|
||||
const client = new pg.Client({ connectionString: databaseUrl })
|
||||
await client.connect()
|
||||
try {
|
||||
await client.query(sql)
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
const dialects: [string, () => Promise<RelayDatabase>][] = [
|
||||
['sqlite', openInMemoryRelayDatabase],
|
||||
...(databaseUrl
|
||||
? [
|
||||
[
|
||||
'postgres',
|
||||
async () => {
|
||||
await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
await onAdmin(`CREATE SCHEMA ${schema}`)
|
||||
return await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' })
|
||||
}
|
||||
] as [string, () => Promise<RelayDatabase>]
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) => {
|
||||
describe('dead-cell evacuation pre-check', () => {
|
||||
let database: RelayDatabase | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -55,12 +16,8 @@ describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open)
|
||||
database = undefined
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
})
|
||||
|
||||
async function setup(now: () => number) {
|
||||
database = await open()
|
||||
database = await openInMemoryRelayDatabase()
|
||||
const store = new RelayAssignmentStore(database, now, {
|
||||
requireLiveCells: true,
|
||||
heartbeatTtlMs: 45_000
|
||||
|
||||
@@ -39,31 +39,10 @@ function admitted(result: DrainReturnGrant | DrainReturnDeferral): DrainReturnGr
|
||||
return result
|
||||
}
|
||||
|
||||
function deferred(result: DrainReturnGrant | DrainReturnDeferral): DrainReturnDeferral {
|
||||
if (result.kind !== 'deferred') throw new Error('expected a deferral')
|
||||
return result
|
||||
}
|
||||
|
||||
const host = (index: number): string => `host${String(index).padStart(12, '0')}`
|
||||
|
||||
describe('drain-return admission', () => {
|
||||
|
||||
it('answers a host’s own early retry with its interval, not a place behind the cohort', async () => {
|
||||
const { lane } = harness()
|
||||
admitted(await lane.acquire(host(0))).lease.release()
|
||||
|
||||
// A row-busy refusal says "retry in 1 s"; that redial is the host's own.
|
||||
const repeat = deferred(await lane.acquire(host(0)))
|
||||
expect(repeat).toMatchObject({
|
||||
reason: 'host-rate-limited',
|
||||
retryAfterSeconds: 2
|
||||
})
|
||||
|
||||
// And it booked nothing: the next overflow still gets the first slot.
|
||||
admitted(await lane.acquire(host(1)))
|
||||
expect(deferred(await lane.acquire(host(2))).retryAfterSeconds).toBe(2)
|
||||
})
|
||||
|
||||
it('lets a queued placement go before a queued drain return', async () => {
|
||||
const { lane, placement } = harness({ maxQueued: 1 })
|
||||
const drain = admitted(await lane.acquire(host(0)))
|
||||
|
||||
-51
@@ -64,25 +64,6 @@ describe('public assignment reconnect lane', () => {
|
||||
expect((await second).status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects on the fast lane when the verification probe fails transiently', async () => {
|
||||
const host = 'tttttttttttttttt'
|
||||
const assign = vi.fn()
|
||||
const resolve = vi.fn(async () => {
|
||||
throw Object.assign(new Error('deadlock detected'), { code: '40P01' })
|
||||
})
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: { assign, resolve } as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
const response = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true }))
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(response.headers.get('retry-after')).toBe('2')
|
||||
})
|
||||
|
||||
it('refuses a dial whose assignment row is busy at once, and frees the fast lane', async () => {
|
||||
const busy = 'bbbbbbbbbbbbbbbb'
|
||||
const next = 'nnnnnnnnnnnnnnnn'
|
||||
@@ -113,38 +94,6 @@ describe('public assignment reconnect lane', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('asks a busy resume to retry in a second', async () => {
|
||||
const host = 'rrrrrrrrrrrrrrrr'
|
||||
const app = createRelayApp(config(), {
|
||||
store: {
|
||||
resolveResume: vi.fn(async () => ({ userId: 'user-1', relayDeviceId: 'device-1' }))
|
||||
} as never,
|
||||
assignments: {
|
||||
assign: vi.fn(async () => {
|
||||
throw new RelayAssignmentRowBusyError()
|
||||
}),
|
||||
resolve: vi.fn(async () => null)
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const response = await app.request('/v1/resolve', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
v: 1,
|
||||
relayHostId: host,
|
||||
resumeToken: Buffer.alloc(32, 1).toString('base64url')
|
||||
})
|
||||
})
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(response.headers.get('retry-after')).toBe('1')
|
||||
expect(await response.json()).toEqual({ error: 'relay_assignment_row_busy' })
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
function assignmentRequest(relayHostId: string, extra: { reconnect?: boolean } = {}): RequestInit {
|
||||
|
||||
-17
@@ -39,23 +39,6 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('idle cutover durable reconciliation', () => {
|
||||
it('only permits reopening when the exact source still owns the assignment', async () => {
|
||||
const { store } = await setup()
|
||||
expect(await store.reconcileIdleRegionalRehome(request)).toBe('not-committed')
|
||||
await store.activateControl(request, {
|
||||
cellId: request.sourceCellId,
|
||||
assignmentEpoch: request.sourceAssignmentEpoch,
|
||||
generation: request.sourceGeneration + 1,
|
||||
cellIncarnation: request.sourceCellIncarnation
|
||||
})
|
||||
expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale')
|
||||
})
|
||||
|
||||
it('does not reopen an obsolete source after an assignment change', async () => {
|
||||
const { store } = await setup()
|
||||
await store.startEvacuation(request, request.targetCellId)
|
||||
expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale')
|
||||
})
|
||||
|
||||
it('waits for an outstanding assignment transaction before deciding authority', async () => {
|
||||
const { database, store } = await setup()
|
||||
|
||||
@@ -111,15 +111,6 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('constrained idle regional assignment transaction', () => {
|
||||
it('sees durable control changes between candidate polls', async () => {
|
||||
const { store, database, safety } = await setup()
|
||||
await database.query("UPDATE relay_region_rehome_control SET enabled = 0 WHERE control_id = 'global'")
|
||||
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([])
|
||||
await database.query("UPDATE relay_region_rehome_control SET enabled = 1 WHERE control_id = 'global'")
|
||||
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1)
|
||||
await database.query("UPDATE relay_region_rehome_control SET enabled = 0 WHERE control_id = 'global'")
|
||||
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([10, 11])('reserves source activity plus assignment at target capacity %i', async (capacity) => {
|
||||
const { store, database, safety, request } = await setup()
|
||||
@@ -321,7 +312,6 @@ describe('constrained idle regional assignment transaction', () => {
|
||||
})
|
||||
expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
function holdStatement(database: RelayDatabase, fragment: string) {
|
||||
|
||||
@@ -220,23 +220,4 @@ describe('idle regional rehome candidate window', () => {
|
||||
expect(seen.size).toBe(363)
|
||||
expect(wrapped).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes a host inside its rehome cooldown and takes it back after', async () => {
|
||||
const { store, database, safety, now } = await setup()
|
||||
await seedHost(store, identity)
|
||||
await database.query(
|
||||
`INSERT INTO relay_region_rehome_attempts
|
||||
(attempt_id, user_id, relay_host_id, preferred_region, source_cell_id, source_cell_incarnation,
|
||||
target_cell_id, target_cell_incarnation, previous_epoch, assignment_epoch, drain_grace_ms,
|
||||
send_attempts, created_at, updated_at)
|
||||
VALUES ('cooled', ?, ?, 'asia-east2', 'us', ?, 'asia-idle', ?, 0, 9, 0, 0, ?, ?)`,
|
||||
[identity.userId, identity.relayHostId, incarnations[0], incarnations[2], now - 1_000, now]
|
||||
)
|
||||
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([])
|
||||
await database.query('UPDATE relay_region_rehome_attempts SET created_at = ?', [
|
||||
now - 604_800_000 - 1
|
||||
])
|
||||
expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(3)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -85,15 +85,6 @@ describe('persisted region decisions across director restart', () => {
|
||||
.toMatchObject({ reportStatus: 'stale' })
|
||||
})
|
||||
|
||||
it('uses server expiry after a restart regardless of an old client report', async () => {
|
||||
const context = await setup()
|
||||
context.setNow(context.window.expiresAt)
|
||||
const restarted = await context.restart()
|
||||
expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch))
|
||||
.toMatchObject({ reportStatus: 'expired' })
|
||||
expect(await restarted.store.previewRegionCorrection()).toEqual({ expired: 1 })
|
||||
})
|
||||
|
||||
it('does not interpret a persisted future-policy window using the old policy after rollback', async () => {
|
||||
const context = await setup()
|
||||
await context.database.query('UPDATE relay_region_decisions SET policy_version = 2')
|
||||
@@ -105,15 +96,4 @@ describe('persisted region decisions across director restart', () => {
|
||||
expect(row.preferred_region).toBeNull()
|
||||
expect(row.report_json).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps generation ordering when the server clock moves backwards across restart', async () => {
|
||||
const context = await setup()
|
||||
context.setNow(1_000_000_000 - 60_000)
|
||||
const restarted = await context.restart()
|
||||
const successor = (await context.issue(restarted.store)).window!
|
||||
expect(successor.generation).toBe(2)
|
||||
expect(successor.expiresAt).toBe(context.window.expiresAt - 60_000)
|
||||
expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch))
|
||||
.toMatchObject({ reportStatus: 'stale' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -147,53 +147,6 @@ describe('ordered region decisions and migration outcomes', () => {
|
||||
).toMatchObject({ reportStatus: 'stale' })
|
||||
})
|
||||
|
||||
it('compares with assigned region, preserves hints, and never extends a window on report', async () => {
|
||||
const context = await setup()
|
||||
await context.store.assign(identity, 'asia-east2')
|
||||
const issued = await window(context)
|
||||
expect(issued.incumbentRegion).toBe('us-central1')
|
||||
context.advance(50)
|
||||
await context.store.exchangeRegionCorrection(
|
||||
identity,
|
||||
{
|
||||
v: 1,
|
||||
action: 'report',
|
||||
generation: issued.generation,
|
||||
assignmentEpoch: issued.assignmentEpoch,
|
||||
policyVersion: 1,
|
||||
outcome: 'conclusive',
|
||||
measurements: { 'us-central1': 110, 'asia-east2': 90 }
|
||||
},
|
||||
issued.assignmentEpoch
|
||||
)
|
||||
expect(await context.store.previewRegionCorrection()).toEqual({ ineligible: 1 })
|
||||
const row = (await context.database.query(`SELECT * FROM relay_region_decisions`))[0]!
|
||||
expect(Number(row.expires_at)).toBe(issued.expiresAt)
|
||||
const hint = (
|
||||
await context.database.query(
|
||||
`SELECT preferred_region FROM relay_assignment_region_preferences WHERE user_id = ?`,
|
||||
[identity.userId]
|
||||
)
|
||||
)[0]
|
||||
expect(hint?.preferred_region).toBe('asia-east2')
|
||||
context.advance(24 * 60 * 60_000)
|
||||
expect(
|
||||
await context.store.exchangeRegionCorrection(
|
||||
identity,
|
||||
{
|
||||
v: 1,
|
||||
action: 'report',
|
||||
generation: issued.generation,
|
||||
assignmentEpoch: issued.assignmentEpoch,
|
||||
policyVersion: 1,
|
||||
outcome: 'inconclusive',
|
||||
reason: 'late'
|
||||
},
|
||||
issued.assignmentEpoch
|
||||
)
|
||||
).toMatchObject({ reportStatus: 'expired' })
|
||||
})
|
||||
|
||||
it('rejects stale assignment basis and requires both thresholds', async () => {
|
||||
const context = await setup()
|
||||
for (const measurements of [
|
||||
|
||||
@@ -30,68 +30,6 @@ const targetIncarnation = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
describe('regional rehome assignment state', () => {
|
||||
|
||||
it('moves one live preferred host and completes without disabling its source cell', async () => {
|
||||
const context = await setup()
|
||||
const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }
|
||||
const neighbor = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' }
|
||||
const sourceControl = await activatePreferredSource(context, identity)
|
||||
await activateSource(context, neighbor)
|
||||
|
||||
const attempt = await context.store.tryIdleRehome()
|
||||
expect(attempt).toMatchObject({
|
||||
userId: identity.userId,
|
||||
relayHostId: identity.relayHostId,
|
||||
sourceCellId: source.id,
|
||||
sourceCellIncarnation: sourceIncarnation,
|
||||
targetCellId: target.id,
|
||||
targetCellIncarnation: targetIncarnation,
|
||||
previousEpoch: 1,
|
||||
assignmentEpoch: 2,
|
||||
sendAttempts: 1
|
||||
})
|
||||
expect(await context.store.resolve(neighbor)).toMatchObject({ cellId: source.id })
|
||||
expect(await context.store.completeReadyRegionalRehomes()).toBe(0)
|
||||
expect(
|
||||
await context.database.query(
|
||||
'SELECT drain_outcome FROM relay_region_rehome_attempts WHERE attempt_id = ?',
|
||||
[attempt!.attemptId]
|
||||
)
|
||||
).toEqual([{ drain_outcome: 'accepted' }])
|
||||
|
||||
const targetControl = await context.store.activateControl(identity, {
|
||||
cellId: target.id,
|
||||
assignmentEpoch: 2,
|
||||
generation: 1
|
||||
})
|
||||
await context.store.markMigrationTargetRegistered(identity, {
|
||||
cellId: target.id,
|
||||
assignmentEpoch: 2
|
||||
})
|
||||
expect(await context.store.completeReadyRegionalRehomes()).toBe(0)
|
||||
await context.store.releaseActivity(identity, sourceControl)
|
||||
expect(await context.store.completeReadyRegionalRehomes()).toBe(1)
|
||||
|
||||
expect(await context.store.resolve(identity)).toMatchObject({
|
||||
cellId: target.id,
|
||||
assignmentEpoch: 2
|
||||
})
|
||||
expect(await context.store.resolve(neighbor)).toMatchObject({ cellId: source.id })
|
||||
expect(
|
||||
await context.database.query(
|
||||
`SELECT completed_at, aborted_at FROM relay_assignment_migrations
|
||||
WHERE user_id = ? AND relay_host_id = ?`,
|
||||
[identity.userId, identity.relayHostId]
|
||||
)
|
||||
).toEqual([{ completed_at: context.now(), aborted_at: null }])
|
||||
expect(
|
||||
await context.database.query(
|
||||
`SELECT drain_receipt_at, completed_at, aborted_at FROM relay_region_rehome_attempts`
|
||||
)
|
||||
).toEqual([{ drain_receipt_at: context.now(), completed_at: context.now(), aborted_at: null }])
|
||||
expect(targetControl).toMatch(/^control:/)
|
||||
await context.database.close()
|
||||
})
|
||||
|
||||
it('completes healthy candidates past a poisoned attempt and logs it', async () => {
|
||||
const context = await setup()
|
||||
const poisoned = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }
|
||||
@@ -146,8 +84,6 @@ describe('regional rehome assignment state', () => {
|
||||
).toEqual([{ completed_at: context.now() }])
|
||||
await context.database.close()
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
type Context = Awaited<ReturnType<typeof setup>>
|
||||
@@ -251,18 +187,6 @@ async function activatePreferredSource(
|
||||
return control
|
||||
}
|
||||
|
||||
async function activateSource(
|
||||
context: Context,
|
||||
identity: { userId: string; relayHostId: string }
|
||||
): Promise<string> {
|
||||
const assignment = await context.store.assign(identity, undefined, 'us-central1')
|
||||
return await context.store.activateControl(identity, {
|
||||
cellId: source.id,
|
||||
assignmentEpoch: assignment.assignmentEpoch,
|
||||
generation: 1
|
||||
})
|
||||
}
|
||||
|
||||
async function heartbeat(
|
||||
store: IdleRehomeTestStore,
|
||||
cell: typeof source | typeof target,
|
||||
|
||||
@@ -45,16 +45,6 @@ describe('Relay regional placement', () => {
|
||||
|
||||
afterEach(async () => await database.close())
|
||||
|
||||
it('falls back globally only when the target region has no safe general cell', async () => {
|
||||
await store.configureCell(CELLS[1]!, 'migration-only')
|
||||
await store.configureCell(CELLS[2]!, 'migration-only')
|
||||
await store.configureCell(CELLS[3]!, 'migration-only')
|
||||
|
||||
await expect(
|
||||
store.assign({ userId: 'fallback-user', relayHostId: 'fallbackhost0001' }, 'asia-east2')
|
||||
).resolves.toMatchObject({ cellId: 'us-c1', region: 'us-central1' })
|
||||
})
|
||||
|
||||
it('preserves sticky assignments while updating only explicit preferences', async () => {
|
||||
const identity = { userId: 'sticky-user', relayHostId: 'stickyhost000001' }
|
||||
await expect(store.assign(identity)).resolves.toMatchObject({ cellId: 'us-c1' })
|
||||
@@ -219,20 +209,4 @@ describe('Relay regional placement', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('expires identity-linked region preferences after 30 days', async () => {
|
||||
const identity = { userId: 'expiry-user', relayHostId: 'expiryhost000001' }
|
||||
await store.assign(identity, 'asia-east2')
|
||||
now += 30 * 24 * 60 * 60_000
|
||||
await expect(store.releaseExpiredRegionPreferences()).resolves.toBe(0)
|
||||
now += 1
|
||||
|
||||
await expect(store.releaseExpiredRegionPreferences()).resolves.toBe(1)
|
||||
await expect(
|
||||
database.query(
|
||||
`SELECT preferred_region FROM relay_assignment_region_preferences
|
||||
WHERE user_id = ? AND relay_host_id = ?`,
|
||||
[identity.userId, identity.relayHostId]
|
||||
)
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,20 +26,6 @@ describe('reserve ledger writer', () => {
|
||||
)
|
||||
)[0]
|
||||
|
||||
it('writes a booked join, and only a higher epoch replaces it', async () => {
|
||||
const { database: db, writer } = await setup()
|
||||
writer.enqueue({ ...HOST, cellId: 'c1', epoch: 5 })
|
||||
await writer.flush()
|
||||
expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 })
|
||||
writer.enqueue({ ...HOST, cellId: 'c2', epoch: 4 })
|
||||
writer.enqueue({ ...HOST, cellId: 'c3', epoch: 5 })
|
||||
await writer.flush()
|
||||
expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 })
|
||||
writer.enqueue({ ...HOST, cellId: 'c2', epoch: 6 })
|
||||
await writer.flush()
|
||||
expect(await row(db)).toMatchObject({ cell_id: 'c2', assignment_epoch: 6 })
|
||||
})
|
||||
|
||||
it('drops a moved row’s counters but keeps a same-cell row’s', async () => {
|
||||
const { database: db, writer } = await setup()
|
||||
await db.query(
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RelayConfig } from './config.js'
|
||||
|
||||
vi.mock('./authentication/admin-token-verifier.js', () => ({
|
||||
createAdminTokenVerifier: () => async (token: string) => token === 'deploy-token',
|
||||
createReadOnlyAdminTokenVerifier: () => async () => false,
|
||||
createRegionalRehomeControlApplyTokenVerifier: () => async () => false,
|
||||
createRegionalRehomeRuntimeTokenVerifier: () => async () => false,
|
||||
createRegionalRehomeTokenVerifier: () => async () => false,
|
||||
createRuntimeTokenVerifier: () => async () => false
|
||||
}))
|
||||
|
||||
vi.mock('./authentication/relay-token-verifier.js', () => ({
|
||||
createRelayTokenVerifier: () => async () => null,
|
||||
readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null
|
||||
}))
|
||||
|
||||
import { createRelayApp } from './app.js'
|
||||
import { RelayAssignmentStore } from './assignment/assignment-store.js'
|
||||
import type { CellReserveClient } from './cell/reservations/cell-reserve-client.js'
|
||||
import { openInMemoryRelayDatabase, type RelayDatabase } from './database/database.js'
|
||||
import { ReserveAssignment, type ReservePlacementMode } from './assignment/reserve-placement/reserve-assignment.js'
|
||||
import { ReservePlacer } from './assignment/reserve-placement/reserve-placement.js'
|
||||
import {
|
||||
ShadowSeatDirectory,
|
||||
type SeatFeedCell,
|
||||
type SeatFeedResponse
|
||||
} from './cell/seats/shadow-seat-directory.js'
|
||||
|
||||
const INCARNATION = '11111111-1111-4111-8111-111111111111'
|
||||
const CELLS = [
|
||||
{ id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 },
|
||||
{ id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 }
|
||||
]
|
||||
const PER_HOST_FIELDS = [
|
||||
'reservedRequests',
|
||||
'assignments',
|
||||
'activityLeases',
|
||||
'activityRequestUnits',
|
||||
'restartBlockingActivityLeases',
|
||||
'restartBlockingActivityRequestUnits',
|
||||
'restartBlockingReservedRequests',
|
||||
'outgoingMigrations',
|
||||
'incomingMigrations'
|
||||
]
|
||||
|
||||
function feed(cellId: string, overrides: Partial<SeatFeedResponse> = {}): SeatFeedResponse {
|
||||
return {
|
||||
v: 1,
|
||||
cellId,
|
||||
incarnation: INCARNATION,
|
||||
seq: 1,
|
||||
at: 0,
|
||||
draining: false,
|
||||
counts: { seats: 40, controls: 42, bookings: 3, units: 45, ceiling: 500 },
|
||||
intake: { perSec: 5, burst: 20, tokens: 20 },
|
||||
flagsApplied: { generation: 3, flags: { admitMode: 'reserve' } },
|
||||
admitModeEffective: 'reserve',
|
||||
admitModeRaw: 'reserve',
|
||||
full: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('reserve-aware cell status', () => {
|
||||
let database: RelayDatabase | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await database?.close()
|
||||
database = undefined
|
||||
})
|
||||
|
||||
async function setup(
|
||||
options: { mode?: ReservePlacementMode; startedAt?: number; placement?: boolean } = {}
|
||||
) {
|
||||
const now = { value: 100_000 }
|
||||
database = await openInMemoryRelayDatabase()
|
||||
const store = new RelayAssignmentStore(database, () => now.value, { heartbeatTtlMs: 45_000 })
|
||||
await store.reconcileCells(CELLS)
|
||||
for (const cell of CELLS) {
|
||||
await store.recordCellHeartbeat({
|
||||
cellId: cell.id,
|
||||
cellUrl: cell.url,
|
||||
cellIncarnation: INCARNATION,
|
||||
startedAt: 50,
|
||||
ready: true,
|
||||
observedRequests: 0
|
||||
})
|
||||
}
|
||||
await store.assign({ userId: 'user-a', relayHostId: 'host000000000001' })
|
||||
await store.setCellAdmitMode('cell-a', 'reserve')
|
||||
const cells: SeatFeedCell[] = CELLS.map((cell) => ({
|
||||
cellId: cell.id,
|
||||
cellUrl: cell.url,
|
||||
region: 'us-central1',
|
||||
heartbeatExpiresAt: Number.MAX_SAFE_INTEGER,
|
||||
requiredForComplete: true,
|
||||
general: true
|
||||
}))
|
||||
const directory = new ShadowSeatDirectory()
|
||||
directory.setCells(cells.map((cell) => cell.cellId))
|
||||
const reservePlacement = new ReserveAssignment({
|
||||
mode: options.mode ?? 'on',
|
||||
directory,
|
||||
cells: () => cells,
|
||||
startedAt: options.startedAt ?? 0,
|
||||
placer: new ReservePlacer(
|
||||
() => now.value,
|
||||
() => 0.25
|
||||
),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status route never books or demotes.
|
||||
client: {} as CellReserveClient,
|
||||
readRow: async () => null,
|
||||
now: () => now.value
|
||||
})
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: store,
|
||||
...(options.placement === false ? {} : { reservePlacement }),
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
const status = async (body: Record<string, unknown>) =>
|
||||
await app.request('/v1/admin/cell-status', {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer deploy-token', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ v: 1, ...body })
|
||||
})
|
||||
return { store, directory, now, status }
|
||||
}
|
||||
|
||||
it('reads a database cell exactly as today, and leaves a reserve cell without per-host counts', async () => {
|
||||
const { store } = await setup()
|
||||
expect(await store.reserveAwareCellDeploymentStatus('cell-b')).toEqual({
|
||||
admitMode: 'db',
|
||||
status: await store.cellDeploymentStatus('cell-b')
|
||||
})
|
||||
await expect(store.cellDeploymentStatus('cell-a')).rejects.toThrow('cell_in_reserve_mode')
|
||||
const reserve = await store.reserveAwareCellDeploymentStatus('cell-a')
|
||||
expect(reserve).toMatchObject({
|
||||
admitMode: 'reserve',
|
||||
status: {
|
||||
cellId: 'cell-a',
|
||||
runtime: { cellIncarnation: INCARNATION, ready: true, heartbeatFresh: true }
|
||||
}
|
||||
})
|
||||
expect(Object.keys(reserve.status).filter((key) => PER_HOST_FIELDS.includes(key))).toEqual([])
|
||||
await database!.query(
|
||||
'ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone'
|
||||
)
|
||||
const unknown = new RelayAssignmentStore(database!, () => 100_000)
|
||||
await expect(unknown.reserveAwareCellDeploymentStatus('cell-b')).rejects.toThrow(
|
||||
'reserve_mode_unknown'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the 404 for a reserve cell without the opt-in, and 400s an unknown view', async () => {
|
||||
const { status } = await setup()
|
||||
const refused = await status({ cellId: 'cell-a' })
|
||||
expect(refused.status).toBe(404)
|
||||
expect(await refused.json()).toEqual({ error: 'cell_in_reserve_mode' })
|
||||
expect((await status({ cellId: 'cell-a', view: 'all' })).status).toBe(400)
|
||||
})
|
||||
|
||||
it('answers a reserve cell from the map, with perHost null and never zeros', async () => {
|
||||
const { directory, now, status } = await setup()
|
||||
directory.apply('cell-a', feed('cell-a', { draining: true }), now.value, now.value - 1_500)
|
||||
directory.apply(
|
||||
'cell-b',
|
||||
feed('cell-b', { admitModeEffective: 'db', admitModeRaw: 'db' }),
|
||||
now.value,
|
||||
now.value
|
||||
)
|
||||
now.value += 20_000
|
||||
const response = await status({ cellId: 'cell-a', view: 'reserve-aware' })
|
||||
expect(response.status).toBe(200)
|
||||
const body = await statusBody(response)
|
||||
expect(body.status).toMatchObject({
|
||||
cellId: 'cell-a',
|
||||
admissionState: 'general',
|
||||
runtime: { cellIncarnation: INCARNATION },
|
||||
perHost: null,
|
||||
reserve: {
|
||||
admitMode: 'reserve',
|
||||
feedStatus: 'live',
|
||||
// Reported, not enforced: the caller rejects a stale map.
|
||||
polledAgeMs: 21_500,
|
||||
incarnation: INCARNATION,
|
||||
draining: true,
|
||||
controls: 42,
|
||||
seats: 40,
|
||||
bookings: 3,
|
||||
units: 45,
|
||||
ceiling: 500,
|
||||
intakePerSec: 5,
|
||||
free: 500 - (45 - 3) - 3
|
||||
}
|
||||
})
|
||||
expect(Object.keys(body.status).filter((key) => PER_HOST_FIELDS.includes(key))).toEqual([])
|
||||
})
|
||||
|
||||
it('reports nulls, not zeros, for a restarted cell that has not applied its reserve switch', async () => {
|
||||
const { directory, now, status } = await setup()
|
||||
directory.apply(
|
||||
'cell-a',
|
||||
feed('cell-a', {
|
||||
counts: { seats: 0, controls: 0 },
|
||||
intake: undefined,
|
||||
flagsApplied: { generation: 0, flags: {} },
|
||||
admitModeEffective: 'db',
|
||||
admitModeRaw: 'db'
|
||||
}),
|
||||
now.value,
|
||||
now.value
|
||||
)
|
||||
const body = await statusBody(await status({ cellId: 'cell-a', view: 'reserve-aware' }))
|
||||
expect(body.status.reserve).toMatchObject({
|
||||
admitMode: 'db',
|
||||
bookings: null,
|
||||
units: null,
|
||||
ceiling: null,
|
||||
intakePerSec: null,
|
||||
free: null
|
||||
})
|
||||
})
|
||||
|
||||
it('answers a retryable 503 while this director cannot vouch for the cell', async () => {
|
||||
const unavailable = async (response: Response) => ({
|
||||
status: response.status,
|
||||
retryAfter: response.headers.get('Retry-After'),
|
||||
body: await response.json()
|
||||
})
|
||||
const expected = (reason: string) => ({
|
||||
status: 503,
|
||||
retryAfter: '5',
|
||||
body: { error: 'reserve_status_unavailable', reason }
|
||||
})
|
||||
for (const options of [{ mode: 'dry-run' as const }, { placement: false }]) {
|
||||
const { directory, now, status } = await setup(options)
|
||||
directory.apply('cell-a', feed('cell-a'), now.value, now.value)
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(
|
||||
expected('placement-off')
|
||||
)
|
||||
await database?.close()
|
||||
}
|
||||
// A starting director with one cell unheard.
|
||||
const starting = await setup({ startedAt: 99_000 })
|
||||
starting.directory.apply('cell-a', feed('cell-a'), starting.now.value, starting.now.value)
|
||||
expect(
|
||||
await unavailable(await starting.status({ cellId: 'cell-a', view: 'reserve-aware' }))
|
||||
).toEqual(expected('map-incomplete'))
|
||||
await database?.close()
|
||||
const { directory, now, status } = await setup()
|
||||
directory.apply('cell-b', feed('cell-b'), now.value, now.value)
|
||||
directory.markNoFeed('cell-a', now.value)
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(
|
||||
expected('no-feed')
|
||||
)
|
||||
// A feed without the drain flag or counts is an older image: no feed, never an empty cell.
|
||||
directory.apply('cell-a', feed('cell-a', { draining: undefined }), now.value, now.value)
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(
|
||||
expected('no-feed')
|
||||
)
|
||||
directory.apply('cell-a', feed('cell-a', { counts: undefined }), now.value, now.value)
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(
|
||||
expected('no-feed')
|
||||
)
|
||||
// The process that answered the feed is not the one heartbeating the runtime row.
|
||||
directory.apply(
|
||||
'cell-a',
|
||||
feed('cell-a', { incarnation: '22222222-2222-4222-8222-222222222222' }),
|
||||
now.value,
|
||||
now.value
|
||||
)
|
||||
expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual(
|
||||
expected('incarnation-mismatch')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function statusBody(response: Response) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the route answers { v, status } on 200.
|
||||
return (await response.json()) as { v: number; status: Record<string, unknown> }
|
||||
}
|
||||
|
||||
function config(overrides: Partial<RelayConfig> = {}): RelayConfig {
|
||||
return {
|
||||
port: 8080,
|
||||
publicUrl: 'https://relay.example.test',
|
||||
cellUrl: 'https://relay.example.test',
|
||||
region: 'us-central1',
|
||||
authIssuer: 'https://auth.example.test',
|
||||
authAudience: 'orca-relay',
|
||||
jwksUrl: 'https://auth.example.test/jwks',
|
||||
assignmentSigningKey: new Uint8Array(32),
|
||||
role: 'director',
|
||||
cellId: 'director',
|
||||
cells: [],
|
||||
adminAudience: 'https://relay.example.test/v1/admin/drain',
|
||||
deployServiceAccount: 'deploy@example.test',
|
||||
runtimeServiceAccount: 'relay-cell@example.test',
|
||||
adminJwksUrl: 'https://auth.example.test/jwks',
|
||||
databasePoolMax: 10,
|
||||
publicAssignmentsEnabled: true,
|
||||
publicAssignmentConcurrency: 2,
|
||||
publicAssignmentQueueMax: 128,
|
||||
publicAssignmentWaitMs: 4_000,
|
||||
publicResolveConcurrency: 1,
|
||||
publicResolveWaitMs: 5_000,
|
||||
publicAssignmentRetryAfterSeconds: 5,
|
||||
dataDir: './data',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract/assignment-invariants'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { RelayAssignmentStore } from '../../assignment/assignment-store.js'
|
||||
@@ -27,41 +26,6 @@ const BASE_MEMBERSHIP = {
|
||||
general: ['c1', 'c2', 'c3']
|
||||
}
|
||||
|
||||
class FailAfterIntentDatabase implements RelayDatabase {
|
||||
private transactionsUntilFailure = Number.POSITIVE_INFINITY
|
||||
|
||||
constructor(private readonly delegate: RelayDatabase) {}
|
||||
|
||||
failSecondTransaction(): void {
|
||||
this.transactionsUntilFailure = 2
|
||||
}
|
||||
|
||||
async query(sql: string, params?: unknown[]): Promise<SqlRow[]> {
|
||||
return await this.delegate.query(sql, params)
|
||||
}
|
||||
|
||||
async queryLocked(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
options?: RelayLockOptions
|
||||
): Promise<SqlRow[]> {
|
||||
return await this.delegate.queryLocked(sql, params, options)
|
||||
}
|
||||
|
||||
async transaction<T>(operation: (transaction: RelayDatabase) => Promise<T>): Promise<T> {
|
||||
this.transactionsUntilFailure--
|
||||
if (this.transactionsUntilFailure === 0) {
|
||||
this.transactionsUntilFailure = Number.POSITIVE_INFINITY
|
||||
throw new Error('injected_commit_ambiguity')
|
||||
}
|
||||
return await this.delegate.transaction(operation)
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.delegate.close()
|
||||
}
|
||||
}
|
||||
|
||||
class AfterTransactionDatabase implements RelayDatabase {
|
||||
private afterTransaction: (() => Promise<void>) | undefined
|
||||
|
||||
@@ -154,34 +118,6 @@ describe('relay cell admission selector', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes existing-only and migration-only cells from dormant placement', async () => {
|
||||
let now = 100
|
||||
const store = await setup(() => now)
|
||||
const identity = { userId: 'dormant', relayHostId: 'host000000000001' }
|
||||
const assignment = await store.assign(identity)
|
||||
const control = await store.activateControl(identity, {
|
||||
cellId: assignment.cellId,
|
||||
assignmentEpoch: assignment.assignmentEpoch,
|
||||
generation: 1
|
||||
})
|
||||
await store.releaseActivity(identity, control)
|
||||
now += ASSIGNMENT_LIMITS.dormantTtlMs + 1
|
||||
|
||||
await store.applyCellAdmissionSelector({
|
||||
attemptId: 'cutover_002',
|
||||
expectedGeneration: 0,
|
||||
expectedMembershipSha256: BASE_MEMBERSHIP_SHA256,
|
||||
membership: INITIAL_MEMBERSHIP
|
||||
})
|
||||
|
||||
await expect(store.rebalanceDormant(identity, 'c2')).rejects.toThrow(
|
||||
'target_cell_unavailable'
|
||||
)
|
||||
await expect(store.rebalanceDormant(identity, 'c3')).resolves.toMatchObject({
|
||||
cellId: 'c3'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not recover an unfenced dead existing-only assignment', async () => {
|
||||
let now = 100
|
||||
const store = await setup(() => now, true)
|
||||
@@ -283,94 +219,6 @@ describe('relay cell admission selector', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('persists failed CAS intent without mutating current membership', async () => {
|
||||
const store = await setup(() => 100)
|
||||
await store.applyCellAdmissionSelector({
|
||||
attemptId: 'cutover_005',
|
||||
expectedGeneration: 0,
|
||||
expectedMembershipSha256: BASE_MEMBERSHIP_SHA256,
|
||||
membership: INITIAL_MEMBERSHIP
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.applyCellAdmissionSelector({
|
||||
attemptId: 'stale_attempt',
|
||||
expectedGeneration: 5,
|
||||
membership: {
|
||||
existingOnly: ['c1'],
|
||||
migrationOnly: [],
|
||||
general: ['c2', 'c3']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('admission_selector_generation_mismatch')
|
||||
await expect(store.inspectCellAdmissionSelector('stale_attempt')).resolves.toMatchObject({
|
||||
selector: { generation: 1, membership: INITIAL_MEMBERSHIP },
|
||||
intent: { state: 'diverged' }
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate or incomplete selector membership before mutation', async () => {
|
||||
const store = await setup(() => 100)
|
||||
|
||||
await expect(
|
||||
store.applyCellAdmissionSelector({
|
||||
attemptId: 'duplicate_001',
|
||||
expectedGeneration: 0,
|
||||
expectedMembershipSha256: BASE_MEMBERSHIP_SHA256,
|
||||
membership: {
|
||||
existingOnly: ['c1', 'c1'],
|
||||
migrationOnly: ['c2'],
|
||||
general: ['c3']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('admission_selector_duplicate_cell')
|
||||
await expect(
|
||||
store.applyCellAdmissionSelector({
|
||||
attemptId: 'incomplete_001',
|
||||
expectedGeneration: 0,
|
||||
expectedMembershipSha256: BASE_MEMBERSHIP_SHA256,
|
||||
membership: {
|
||||
existingOnly: ['c1'],
|
||||
migrationOnly: ['c2'],
|
||||
general: []
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('admission_selector_incomplete_membership')
|
||||
await expect(store.inspectCellAdmissionSelector()).resolves.toMatchObject({
|
||||
selector: {
|
||||
generation: 0,
|
||||
membership: { existingOnly: [], migrationOnly: [], general: ['c1', 'c2', 'c3'] }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('inspects an unchanged durable intent after an ambiguous apply failure', async () => {
|
||||
const underlying = await openInMemoryRelayDatabase()
|
||||
const failingDatabase = new FailAfterIntentDatabase(underlying)
|
||||
database = failingDatabase
|
||||
const store = new RelayAssignmentStore(failingDatabase, () => 100)
|
||||
await store.reconcileCells(CELLS)
|
||||
failingDatabase.failSecondTransaction()
|
||||
|
||||
const input = {
|
||||
attemptId: 'ambiguous_001',
|
||||
expectedGeneration: 0,
|
||||
expectedMembershipSha256: BASE_MEMBERSHIP_SHA256,
|
||||
membership: INITIAL_MEMBERSHIP
|
||||
}
|
||||
await expect(store.applyCellAdmissionSelector(input)).rejects.toThrow(
|
||||
'injected_commit_ambiguity'
|
||||
)
|
||||
await expect(store.inspectCellAdmissionSelector(input.attemptId)).resolves.toMatchObject({
|
||||
selector: { generation: 0 },
|
||||
intent: { state: 'unchanged' }
|
||||
})
|
||||
await expect(store.applyCellAdmissionSelector(input)).resolves.toMatchObject({
|
||||
changed: true,
|
||||
selector: { generation: 1, membership: INITIAL_MEMBERSHIP }
|
||||
})
|
||||
})
|
||||
|
||||
it('allows only one concurrent CAS and never re-enables legacy cells', async () => {
|
||||
const store = await setup(() => 100)
|
||||
await store.applyCellAdmissionSelector({
|
||||
@@ -416,24 +264,6 @@ describe('relay cell admission selector', () => {
|
||||
).rejects.toThrow('admission_selector_legacy_reenable')
|
||||
})
|
||||
|
||||
it('preserves the selector boundary across compatible reconciliation', async () => {
|
||||
const store = await setup(() => 100)
|
||||
await store.applyCellAdmissionSelector({
|
||||
attemptId: 'cutover_007',
|
||||
expectedGeneration: 0,
|
||||
expectedMembershipSha256: BASE_MEMBERSHIP_SHA256,
|
||||
membership: INITIAL_MEMBERSHIP
|
||||
})
|
||||
|
||||
await expect(store.reconcileCells(CELLS, false)).resolves.toBeUndefined()
|
||||
await expect(store.reconcileCells([CELLS[0]!, CELLS[2]!])).rejects.toThrow(
|
||||
'admission_selector_boundary_active'
|
||||
)
|
||||
await expect(store.inspectCellAdmissionSelector()).resolves.toMatchObject({
|
||||
selector: { generation: 1, membership: INITIAL_MEMBERSHIP }
|
||||
})
|
||||
})
|
||||
|
||||
it('atomically adds exact migration-only cells after the selector boundary', async () => {
|
||||
const store = await setup(() => 100)
|
||||
await store.applyCellAdmissionSelector({
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RelayAssignmentStore } from '../../assignment/assignment-store.js'
|
||||
import { encodeMembership, type CellAdmissionMembership } from './cell-admission-selector.js'
|
||||
import type { RelayCellConfig } from '../../config.js'
|
||||
import { openInMemoryRelayDatabase, type RelayDatabase } from '../../database/database.js'
|
||||
|
||||
const HEARTBEAT_TTL_MS = 45_000
|
||||
const BOUND = 60
|
||||
const OLD_INCARNATION = '11111111-1111-4111-8111-111111111111'
|
||||
const NEW_INCARNATION = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function cell(index: number, connectionHardCap: 1_000 | 3_000): RelayCellConfig {
|
||||
const id = `us-c${String(index).padStart(2, '0')}`
|
||||
@@ -40,7 +37,7 @@ type Report = {
|
||||
async function setup(cells: RelayCellConfig[]) {
|
||||
const database = await openInMemoryRelayDatabase()
|
||||
databases.push(database)
|
||||
let now = 100
|
||||
const now = 100
|
||||
const store = new RelayAssignmentStore(database, () => now, {
|
||||
requireLiveCells: true,
|
||||
heartbeatTtlMs: HEARTBEAT_TTL_MS
|
||||
@@ -81,32 +78,13 @@ async function setup(cells: RelayCellConfig[]) {
|
||||
}
|
||||
return rows
|
||||
}
|
||||
const isolateForRoll = async (cellId: string): Promise<void> => {
|
||||
const current = await store.inspectCellAdmissionSelector()
|
||||
const membership: CellAdmissionMembership = {
|
||||
existingOnly: [],
|
||||
migrationOnly: [cellId],
|
||||
general: cells.map(({ id }) => id).filter((id) => id !== cellId)
|
||||
}
|
||||
await store.applyCellAdmissionSelector({
|
||||
attemptId: `isolate-${cellId}`,
|
||||
expectedGeneration: current.selector.generation,
|
||||
expectedMembershipSha256: createHash('sha256')
|
||||
.update(encodeMembership(current.selector.membership))
|
||||
.digest('hex'),
|
||||
membership,
|
||||
rollIsolatedCells: [cellId]
|
||||
})
|
||||
}
|
||||
return {
|
||||
database,
|
||||
store,
|
||||
heartbeat,
|
||||
capacity,
|
||||
reportedHardCap,
|
||||
telemetryRows,
|
||||
isolateForRoll,
|
||||
setNow: (value: number) => (now = value)
|
||||
telemetryRows
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,63 +193,4 @@ describe('effective connection cap = min(configured, reported)', () => {
|
||||
harness.store.assign({ userId: 'user-a', relayHostId: 'host000000000001' })
|
||||
).rejects.toThrow('relay_capacity_exhausted')
|
||||
})
|
||||
|
||||
it('places a mixed fleet against each cell’s own reported cap', async () => {
|
||||
const small = cell(1, 3_000)
|
||||
const large = cell(2, 3_000)
|
||||
const harness = await setup([small, large])
|
||||
await harness.heartbeat(small, { connectionHardCap: 1_000, units: 840 })
|
||||
await harness.heartbeat(large, { connectionHardCap: 3_000, units: 840 })
|
||||
|
||||
expect(await harness.capacity(small.id)).toMatchObject({ normalAdmissionPause: 840 })
|
||||
expect(await harness.capacity(large.id)).toMatchObject({ normalAdmissionPause: 2_840 })
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await expect(
|
||||
harness.store.assign({ userId: `user-${index}`, relayHostId: `host00000000000${index}` })
|
||||
).resolves.toMatchObject({ cellId: large.id })
|
||||
}
|
||||
})
|
||||
|
||||
it('has no unavailable window while a cell rolls 1,000 -> 3,000', async () => {
|
||||
const cells = [cell(1, 1_000), cell(2, 1_000)]
|
||||
const [rolled, neighbour] = cells
|
||||
const harness = await setup(cells)
|
||||
const identity = { userId: 'user-a', relayHostId: 'host000000000001' }
|
||||
await harness.heartbeat(rolled!, { connectionHardCap: 1_000 })
|
||||
await harness.heartbeat(neighbour!, { connectionHardCap: 1_000, units: 500 })
|
||||
const home = await harness.store.assign(identity)
|
||||
expect(home.cellId).toBe(rolled!.id)
|
||||
await harness.store.reconcileCells(
|
||||
cells.map((_, index) => cell(index + 1, 3_000)),
|
||||
true
|
||||
)
|
||||
|
||||
// The old process keeps heartbeating its 1,000 well past the TTL; the host stays sticky.
|
||||
for (const at of [30_000, 60_000, 90_000]) {
|
||||
harness.setNow(at)
|
||||
await harness.heartbeat(rolled!, { connectionHardCap: 1_000 })
|
||||
await harness.heartbeat(neighbour!, { connectionHardCap: 1_000, units: 500 })
|
||||
await expect(harness.store.assign(identity)).resolves.toMatchObject({
|
||||
cellId: rolled!.id,
|
||||
assignmentEpoch: home.assignmentEpoch
|
||||
})
|
||||
}
|
||||
// The roll isolates the live old process: the isolated-incumbent lane re-places the host.
|
||||
await harness.isolateForRoll(rolled!.id)
|
||||
const moved = await harness.store.assign(identity)
|
||||
expect(moved).toMatchObject({ cellId: neighbour!.id, placed: true })
|
||||
|
||||
// The replacement boots and reports the new cap.
|
||||
harness.setNow(120_000)
|
||||
await harness.heartbeat(rolled!, {
|
||||
connectionHardCap: 3_000,
|
||||
incarnation: NEW_INCARNATION,
|
||||
startedAt: 110_000
|
||||
})
|
||||
expect(await harness.capacity(rolled!.id)).toMatchObject({
|
||||
effectiveHardCap: 3_000,
|
||||
heartbeatFresh: true
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -77,25 +77,6 @@ describe('control flag channel', () => {
|
||||
.filter((line) => line.includes(`"event":"${CELL_FLAGS_APPLIED_EVENT}"`))
|
||||
.map((line): unknown => JSON.parse(line))
|
||||
|
||||
it('flips a flag on and back off, logging each applied generation once', async () => {
|
||||
const google = fakeGoogle(cellObject(5, { readinessLocal: true, ticketCheck: 'shadow' }))
|
||||
const channel = cellChannel(google.fetchImpl)
|
||||
await channel.poll()
|
||||
await channel.poll()
|
||||
expect(google.state.storageRequests[1]?.searchParams.get('ifGenerationNotMatch')).toBe('5')
|
||||
google.state.object = cellObject(7, { readinessLocal: false, ticketCheck: 'off' })
|
||||
await channel.poll()
|
||||
expect(channel.applied()).toEqual({ generation: 7, flags: CELL_FLAG_DEFAULTS })
|
||||
expect(appliedLines()).toEqual([
|
||||
{
|
||||
event: CELL_FLAGS_APPLIED_EVENT,
|
||||
generation: 5,
|
||||
flags: { ...CELL_FLAG_DEFAULTS, readinessLocal: true, ticketCheck: 'shadow' }
|
||||
},
|
||||
{ event: CELL_FLAGS_APPLIED_EVENT, generation: 7, flags: CELL_FLAG_DEFAULTS }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the applied values through every kind of lost contact, logging once per cause', async () => {
|
||||
const google = fakeGoogle(cellObject(5, { readinessLocal: true }))
|
||||
const channel = cellChannel(google.fetchImpl)
|
||||
|
||||
@@ -128,13 +128,4 @@ describe('dead-man trip slots', () => {
|
||||
expect(seated.socket.readyState).toBe(WebSocket.OPEN)
|
||||
}, 30_000)
|
||||
|
||||
it('without the flag, trips with every slot taken, as before', async () => {
|
||||
let now = Date.now()
|
||||
const { database, reserve } = await cell(() => now)
|
||||
await trippedPeer(database, 'production-gce-c90', now)
|
||||
await reserve.bookedHost(3)
|
||||
expect(reserve.relay.sessions.inReserveMode()).toBe(true)
|
||||
now += reserveDeadManWindowMs(reserve.config.cellId) + 1_000
|
||||
expect(reserve.relay.sessions.inReserveMode()).toBe(false)
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -55,15 +55,6 @@ describe('ReserveDeadMan', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the plain window for a later reserve write once a director has been in touch', () => {
|
||||
const { clock, deadMan } = setup(3 * WINDOW_MS)
|
||||
deadMan.mode(reserve(1))
|
||||
deadMan.contact()
|
||||
expect(deadMan.mode(reserve(2))).toBe('reserve')
|
||||
clock.value += WINDOW_MS + 1
|
||||
expect(deadMan.mode(reserve(2))).toBe('db')
|
||||
})
|
||||
|
||||
// A claim answered after the director came back must not trip the cell later without a slot.
|
||||
it('drops a permit for a silence that contact has ended', () => {
|
||||
const { clock, deadMan, events } = setup()
|
||||
|
||||
@@ -50,7 +50,7 @@ const answer = (status: number) => new Response('{}', { status })
|
||||
// Every cell serves a feed page on the first round; `respond` answers every round after it.
|
||||
function poller(
|
||||
respond: (cellId: string) => Response | Promise<Response>,
|
||||
options: { identityFails?: boolean; cells?: SeatFeedCell[]; neverServed?: ReadonlySet<string> } = {}
|
||||
options: { cells?: SeatFeedCell[]; neverServed?: ReadonlySet<string> } = {}
|
||||
) {
|
||||
let now = 1_000
|
||||
let round = 0
|
||||
@@ -66,7 +66,6 @@ function poller(
|
||||
return round === 0 ? page(cellId) : await respond(cellId)
|
||||
}),
|
||||
identityToken: vi.fn(async () => {
|
||||
if (options.identityFails && round > 0) throw new Error('metadata_unavailable')
|
||||
return 'token-1'
|
||||
}),
|
||||
now: () => now,
|
||||
@@ -107,13 +106,6 @@ describe('seat-feed poller reachability', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not blame a cell for a token this director cannot mint', async () => {
|
||||
const cells = poller((cellId) => page(cellId), { identityFails: true })
|
||||
const at = await cells.run(CELL_POLL_DEAD_STREAK_MS)
|
||||
cells.stop()
|
||||
expect(cells.deadCellVerdict('c2', at)).toBeNull()
|
||||
})
|
||||
|
||||
it('counts only cells that served a feed: legacy 404 rows neither fill the sample nor dilute it', async () => {
|
||||
// Staging's shape: a few real cells and 16 legacy rows. Every real cell fails.
|
||||
const cells = poller(() => answer(502), { cells: [...CELLS.slice(0, 3), ...LEGACY] })
|
||||
|
||||
@@ -351,17 +351,6 @@ describe('startShadowSeatPoller', () => {
|
||||
expect(directory.cellState('cell-b')?.flagsApplied).toBeUndefined()
|
||||
})
|
||||
|
||||
it('completes without a cell that is dead, empty or isolated', async () => {
|
||||
const cell = poller({ 'cell-a': () => json(feed({ seq: 1, full: [] })) }, 'all', async () => [
|
||||
CELLS[0]!,
|
||||
{ ...CELLS[1]!, requiredForComplete: false }
|
||||
])
|
||||
await cell.tick()
|
||||
cell.stop()
|
||||
expect(cell.directory.cellState('cell-b')).toMatchObject({ status: 'pending' })
|
||||
expect(cell.directory.isComplete()).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a malformed or mislabelled body as unverifiable', async () => {
|
||||
const cell = poller({
|
||||
'cell-a': () => json({ v: 2 }),
|
||||
|
||||
@@ -2,11 +2,9 @@ import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reserveDeadManWindowMs } from '../cell/reservations/cell-reserve-dead-man.js'
|
||||
import { openRelayDatabase } from '../database/database.js'
|
||||
import {
|
||||
nextFrame,
|
||||
RESERVE_MODE_CELL_ID,
|
||||
startReserveModeCell
|
||||
} from '../test-fixtures/reserve-mode-cell.js'
|
||||
import { StatementRecordingDatabase } from '../test-fixtures/statement-recording-database.js'
|
||||
@@ -22,14 +20,6 @@ const OTHER_CELL = {
|
||||
capacityRequests: 900
|
||||
}
|
||||
|
||||
async function until(check: () => Promise<boolean>, timeoutMs = 10_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!(await check())) {
|
||||
if (Date.now() > deadline) throw new Error('condition not reached')
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
}
|
||||
|
||||
describe('the memory phone path', () => {
|
||||
const cleanup: Array<() => Promise<void> | void> = []
|
||||
|
||||
@@ -67,14 +57,12 @@ describe('the memory phone path', () => {
|
||||
// A phone resumed and spliced to its desktop, waiting to confirm.
|
||||
async function splicedPhone(
|
||||
phonePath: 'db' | 'memory',
|
||||
device: { expiresAt?: number } = {},
|
||||
clock: { now?: () => number; beforeDial?: () => void } = {}
|
||||
device: { expiresAt?: number } = {}
|
||||
) {
|
||||
const reserve = await cell(clock.now)
|
||||
const reserve = await cell()
|
||||
reserve.setAdmitMode('reserve', { phonePath })
|
||||
const seated = await reserve.bookedHost(3)
|
||||
await reserve.installDevice(seated.identity.hostId, TOKEN, device)
|
||||
clock.beforeDial?.()
|
||||
const connOpen = nextFrame(seated.socket, 'conn-open')
|
||||
const phone = await reserve.dialPhone(seated.identity.hostId, TOKEN)
|
||||
const basisConnId = String((await connOpen).connId)
|
||||
@@ -97,44 +85,6 @@ describe('the memory phone path', () => {
|
||||
const deviceRow = async (reserve: Awaited<ReturnType<typeof cell>>) =>
|
||||
(await reserve.raw.query(`SELECT current_expires_at FROM relay_devices`))[0]
|
||||
|
||||
it('admits a seated phone resume without an activity lease', async () => {
|
||||
const { reserve, seated } = await seatedPhoneHost('memory')
|
||||
const connOpen = nextFrame(seated.socket, 'conn-open')
|
||||
const phone = await reserve.dialPhone(seated.identity.hostId, TOKEN)
|
||||
expect(await connOpen).toMatchObject({ kind: 'resume', relayDeviceId: 'device-1' })
|
||||
await reserve.attachData(await connOpen, seated.ack.generation)
|
||||
expect(await phone.hello).toMatchObject({
|
||||
type: 'relay-hello',
|
||||
ok: true,
|
||||
credentialKind: 'resume'
|
||||
})
|
||||
expect(await reserve.activityLeases()).toEqual([])
|
||||
})
|
||||
|
||||
it('persists the connection basis and activity leases while phonePath is db', async () => {
|
||||
const { reserve, seated } = await seatedPhoneHost('db')
|
||||
const connOpen = nextFrame(seated.socket, 'conn-open')
|
||||
const phone = await reserve.dialPhone(seated.identity.hostId, TOKEN)
|
||||
await reserve.attachData(await connOpen, seated.ack.generation)
|
||||
expect(await phone.hello).toMatchObject({ type: 'relay-hello', ok: true })
|
||||
expect((await reserve.activityLeases()).map((lease) => lease.activity_kind).sort()).toEqual([
|
||||
'confirmation',
|
||||
'splice'
|
||||
])
|
||||
expect(
|
||||
await reserve.raw.query(
|
||||
`SELECT basis_conn_id, relay_device_id, credential_kind, active FROM relay_connection_bases`
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
basis_conn_id: String((await connOpen).connId),
|
||||
relay_device_id: 'device-1',
|
||||
credential_kind: 'resume',
|
||||
active: 1
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('closes a revoked or expired device 4401', async () => {
|
||||
const reserve = await cell()
|
||||
reserve.setAdmitMode('reserve', { phonePath: 'memory' })
|
||||
@@ -275,24 +225,6 @@ describe('the memory phone path', () => {
|
||||
expect(await confirm('confirm-2')).toMatchObject({ code: 'confirmation_not_active' })
|
||||
})
|
||||
|
||||
it('answers a grace-version confirm renewed:false with its expiry unchanged, as today', async () => {
|
||||
const { reserve, confirm } = await splicedPhone('memory')
|
||||
const stored = Number((await deviceRow(reserve))?.current_expires_at)
|
||||
// An install rotated the credential after the accept read, so the accepted version is grace.
|
||||
await reserve.raw.query(
|
||||
`UPDATE relay_devices SET grace_hash = current_hash, grace_version = 1, grace_expires_at = ?,
|
||||
current_hash = 'rotated', current_version = 2`,
|
||||
[Date.now() + 60_000]
|
||||
)
|
||||
expect(await confirm('confirm-1')).toMatchObject({
|
||||
type: 'device-resume-confirmed',
|
||||
currentVersion: 2,
|
||||
acceptedAs: 'current',
|
||||
renewed: false,
|
||||
resumeExpiresAt: stored
|
||||
})
|
||||
})
|
||||
|
||||
it('durably confirms and renews a database-path resume, releasing only its confirmation lease', async () => {
|
||||
const { reserve, confirm, basisConnId } = await splicedPhone('db')
|
||||
const before = Number((await deviceRow(reserve))?.current_expires_at)
|
||||
@@ -314,70 +246,6 @@ describe('the memory phone path', () => {
|
||||
expect(await confirm('confirm-1')).toEqual(answer)
|
||||
})
|
||||
|
||||
describe('rolling a memory cell back', () => {
|
||||
// Flip back by the switch, or by the dead-man; either way the confirm finishes from memory.
|
||||
type Cell = Awaited<ReturnType<typeof cell>>
|
||||
type Advance = (ms: number) => void
|
||||
type Rollback = {
|
||||
beforeDial?: (advance: Advance) => void
|
||||
roll: (reserve: Cell, advance: Advance) => void
|
||||
}
|
||||
// The dead-man trips just after the phone's accept: its window ran from the host's booking.
|
||||
const window = reserveDeadManWindowMs(RESERVE_MODE_CELL_ID)
|
||||
const rollbacks: Record<string, Rollback> = {
|
||||
'admitMode=db': { roll: (reserve) => reserve.setAdmitMode('db', { phonePath: 'memory' }) },
|
||||
'phonePath=db': { roll: (reserve) => reserve.setAdmitMode('reserve', { phonePath: 'db' }) },
|
||||
'a dead-man trip': {
|
||||
beforeDial: (advance) => advance(window - 5_000),
|
||||
roll: (_reserve, advance) => advance(6_000)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, rollback] of Object.entries(rollbacks)) {
|
||||
it(`finishes an in-flight confirm on its memory basis after ${name}, writing and leaking no lease`, async () => {
|
||||
let now = Date.now()
|
||||
const advance: Advance = (ms) => {
|
||||
now += ms
|
||||
}
|
||||
const spliced = await splicedPhone(
|
||||
'memory',
|
||||
{},
|
||||
{
|
||||
now: () => now,
|
||||
beforeDial: () => rollback.beforeDial?.(advance)
|
||||
}
|
||||
)
|
||||
const { reserve, seated } = spliced
|
||||
// The flag tool records reserve in Postgres before the cell admits from memory.
|
||||
await reserve.relay.assignments.reconcileCells([...reserve.config.cells, OTHER_CELL])
|
||||
await reserve.relay.assignments.setCellAdmitMode(reserve.config.cellId, 'reserve')
|
||||
rollback.roll(reserve, advance)
|
||||
expect(reserve.relay.sessions.phonePathEffective()).toBe('db')
|
||||
expect(await spliced.confirm('confirm-1')).toMatchObject({
|
||||
type: 'device-resume-confirmed',
|
||||
renewed: true
|
||||
})
|
||||
// Only the control lease a flip back re-registers; nothing for the phone.
|
||||
const leases = async () =>
|
||||
(await reserve.activityLeases()).map((lease) => lease.activity_kind)
|
||||
expect((await leases()).filter((kind) => kind !== 'control')).toEqual([])
|
||||
// Postgres still records reserve, so evacuation leaves the cell alone.
|
||||
await expect(
|
||||
reserve.relay.assignments.startEvacuation(
|
||||
{ userId: 'user-1', relayHostId: seated.identity.hostId },
|
||||
OTHER_CELL.id
|
||||
)
|
||||
).rejects.toThrow('cell_in_reserve_mode')
|
||||
// The splice still runs, and closing it releases nothing it never took.
|
||||
expect(spliced.phone.socket.readyState).toBe(spliced.phone.socket.OPEN)
|
||||
await until(async () => reserve.relay.sessions.reregistrationPending() === 0)
|
||||
spliced.phone.socket.close()
|
||||
await spliced.phone.closed
|
||||
expect((await leases()).filter((kind) => kind !== 'control')).toEqual([])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// E-dep-6 in miniature: the staging drill blocks one memory cell's database for 10 minutes.
|
||||
it('rides out a database outage: splices keep forwarding, new work fails cleanly and recovers', async () => {
|
||||
let now = Date.now()
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { CONTROL_CONTINUITY_LIMITS } from '@orca-cloud/relay-contract/control-continuity'
|
||||
import { RELAY_CLOSE_CODE } from '@orca-cloud/relay-contract/close-codes'
|
||||
import { RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME } from '@orca-cloud/relay-contract/control-messages'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type WebSocket from 'ws'
|
||||
import type { RelayAssignmentStore } from '../assignment/assignment-store.js'
|
||||
import type { RelayConfig } from '../config.js'
|
||||
import type { RelayCredentialStore } from '../credentials/credential-store.js'
|
||||
import { CONTROL_RENEWAL_BATCH_INTERVAL_MS } from '../control-leases/control-renewal-batch.js'
|
||||
import type {
|
||||
ControlRenewalOutcome
|
||||
} from '../control-leases/control-renewal-statement.js'
|
||||
import {
|
||||
HostSessionRegistry,
|
||||
IDLE_REHOME_MIN_CONTROL_AGE_MS,
|
||||
type HostSession
|
||||
} from './host-session-registry.js'
|
||||
import type { RelayRuntimeObserver } from '../observability/relay-observability.js'
|
||||
import type { RelayTokenClaims } from '../authentication/relay-token-verifier.js'
|
||||
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
|
||||
|
||||
// A due renewal leaves the heartbeat as a batch enqueue, so the store only sees
|
||||
// the tick once the batch window closes.
|
||||
async function closeRenewalWindow(): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
readonly OPEN = 1
|
||||
readonly CLOSING = 2
|
||||
readonly CLOSED = 3
|
||||
readyState = this.OPEN
|
||||
readonly send = vi.fn()
|
||||
readonly close = vi.fn((code?: number, reason?: string) => {
|
||||
this.readyState = this.CLOSED
|
||||
this.emit('close', code, Buffer.from(reason ?? ''))
|
||||
})
|
||||
readonly terminate = vi.fn(() => {
|
||||
this.readyState = this.CLOSED
|
||||
this.emit('close')
|
||||
})
|
||||
}
|
||||
|
||||
type ActivateSession = (
|
||||
socket: WebSocket,
|
||||
identity: RelayTokenClaims,
|
||||
existing: HostSession | null,
|
||||
generation: number,
|
||||
rebind: boolean,
|
||||
assignmentEpoch: number,
|
||||
appVersion?: string
|
||||
) => Promise<void>
|
||||
|
||||
const config = {
|
||||
port: 8080,
|
||||
publicUrl: 'https://relay-c3.example.com',
|
||||
cellUrl: 'https://relay-c3.example.com',
|
||||
authIssuer: 'https://auth.example.com',
|
||||
authAudience: 'orca-relay',
|
||||
jwksUrl: 'https://auth.example.com/jwks',
|
||||
assignmentSigningKey: new Uint8Array(32),
|
||||
role: 'cell',
|
||||
cellId: 'production-gce-c3',
|
||||
cells: [
|
||||
{
|
||||
id: 'production-gce-c3',
|
||||
url: 'https://relay-c3.example.com',
|
||||
capacityRequests: 4_000
|
||||
}
|
||||
],
|
||||
adminAudience: 'https://relay-c3.example.com/v1/admin/drain',
|
||||
deployServiceAccount: 'deploy@example.com',
|
||||
runtimeServiceAccount: 'runtime@example.com',
|
||||
adminJwksUrl: 'https://auth.example.com/admin-jwks',
|
||||
databasePoolMax: 10,
|
||||
publicAssignmentsEnabled: true,
|
||||
publicAssignmentConcurrency: 2,
|
||||
publicAssignmentQueueMax: 128,
|
||||
publicAssignmentWaitMs: 4_000,
|
||||
publicResolveConcurrency: 1,
|
||||
publicResolveWaitMs: 5_000,
|
||||
publicAssignmentRetryAfterSeconds: 5,
|
||||
dataDir: './test-data'
|
||||
} satisfies RelayConfig
|
||||
|
||||
const identity = {
|
||||
sub: 'user-1',
|
||||
prof: 'profile-1',
|
||||
relayHostId: 'abcdefghijklmnop',
|
||||
purpose: 'host-control',
|
||||
exp: 4_102_444_800
|
||||
} satisfies RelayTokenClaims
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve(value: T): void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((complete) => {
|
||||
resolve = complete
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function createRegistry(
|
||||
activateControl: RelayAssignmentStore['activateControl'],
|
||||
store: Partial<RelayCredentialStore> = {},
|
||||
verifyRelayToken: (token: string) => Promise<RelayTokenClaims | null> = vi.fn()
|
||||
): {
|
||||
registry: HostSessionRegistry
|
||||
activate: ActivateSession
|
||||
acquireActivity: ReturnType<typeof vi.fn>
|
||||
renewControlActivities: ReturnType<typeof vi.fn<RelayAssignmentStore['renewControlActivities']>>
|
||||
releaseActivity: ReturnType<typeof vi.fn>
|
||||
observer: {
|
||||
recordAuth: ReturnType<typeof vi.fn>
|
||||
recordControlClose: ReturnType<typeof vi.fn>
|
||||
recordPostProofClose: ReturnType<typeof vi.fn>
|
||||
recordSpliceClose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
} {
|
||||
const acquireActivity = vi.fn().mockResolvedValue(undefined)
|
||||
const releaseActivity = vi.fn().mockResolvedValue(true)
|
||||
const renewControlActivities = vi
|
||||
.fn<RelayAssignmentStore['renewControlActivities']>()
|
||||
.mockImplementation(async (rows) => rows.map(() => 'renewed'))
|
||||
const assignments = {
|
||||
activateControl,
|
||||
markMigrationTargetRegistered: vi.fn().mockResolvedValue(undefined),
|
||||
resolve: vi.fn().mockResolvedValue({ cellId: config.cellId }),
|
||||
acquireActivity,
|
||||
renewControlActivities,
|
||||
releaseActivity
|
||||
} as unknown as RelayAssignmentStore
|
||||
const observer = {
|
||||
recordAuth: vi.fn(),
|
||||
recordForwardedBytes: vi.fn(),
|
||||
recordHttp: vi.fn(),
|
||||
recordReconnect: vi.fn(),
|
||||
recordSql: vi.fn(),
|
||||
recordControlClose: vi.fn(),
|
||||
recordPostProofClose: vi.fn(),
|
||||
recordSpliceClose: vi.fn()
|
||||
} satisfies RelayRuntimeObserver
|
||||
const registry = new HostSessionRegistry(
|
||||
config,
|
||||
verifyRelayToken,
|
||||
store as RelayCredentialStore,
|
||||
assignments,
|
||||
new ProcessQueuedByteBudget(),
|
||||
observer,
|
||||
Date.now,
|
||||
Math.random,
|
||||
'incarnation-1'
|
||||
)
|
||||
// Mirrors the production signature exactly so a future positional shift fails to compile.
|
||||
const bound = (
|
||||
registry as unknown as {
|
||||
activate: (
|
||||
socket: WebSocket,
|
||||
identity: RelayTokenClaims,
|
||||
existing: HostSession | null,
|
||||
generation: number,
|
||||
rebind: boolean,
|
||||
assignmentEpoch: number,
|
||||
appVersion: string,
|
||||
connectionInclusionWatermark?: number
|
||||
) => Promise<void>
|
||||
}
|
||||
).activate.bind(registry)
|
||||
const activate: ActivateSession = (
|
||||
socket,
|
||||
identity,
|
||||
existing,
|
||||
generation,
|
||||
rebind,
|
||||
assignmentEpoch,
|
||||
appVersion = '1.4.173'
|
||||
) => bound(socket, identity, existing, generation, rebind, assignmentEpoch, appVersion)
|
||||
return {
|
||||
registry,
|
||||
activate,
|
||||
acquireActivity,
|
||||
renewControlActivities,
|
||||
releaseActivity,
|
||||
observer
|
||||
}
|
||||
}
|
||||
|
||||
describe('host session cleanup races', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// c34 ghost seat came from activation outliving orphan grace and reviving a removed session outside the map.
|
||||
it('refuses a rebind whose session left the map while its activation was in flight', async () => {
|
||||
const blocked = deferred<string>()
|
||||
const activateControl = vi
|
||||
.fn<RelayAssignmentStore['activateControl']>()
|
||||
.mockResolvedValueOnce('control:production-gce-c3:1')
|
||||
.mockReturnValueOnce(blocked.promise)
|
||||
const { registry, activate, releaseActivity } = createRegistry(activateControl)
|
||||
const key = { userId: identity.sub, relayHostId: identity.relayHostId }
|
||||
const originalSocket = new FakeSocket()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface.
|
||||
await activate(originalSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
||||
const original = registry.get(key)
|
||||
originalSocket.close(1006, 'network reset')
|
||||
expect(original?.state).toBe('orphaned')
|
||||
|
||||
const rebindSocket = new FakeSocket()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface.
|
||||
const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// The grace runs out while the database answers slowly.
|
||||
await vi.advanceTimersByTimeAsync(CONTROL_CONTINUITY_LIMITS.orphanGraceMs)
|
||||
expect(registry.get(key)).toBeNull()
|
||||
blocked.resolve('control:production-gce-c3:1')
|
||||
await rebinding
|
||||
|
||||
expect(rebindSocket.close).toHaveBeenCalledWith(
|
||||
RELAY_CLOSE_CODE.PEER_DROPPED,
|
||||
'control activation superseded'
|
||||
)
|
||||
expect(registry.get(key)).toBeNull()
|
||||
expect(original?.state).toBe('closed')
|
||||
const page = registry.seatFeed(null)
|
||||
expect('full' in page && page.full).toEqual([])
|
||||
expect(releaseActivity).toHaveBeenCalledWith(key, 'control:production-gce-c3:1')
|
||||
})
|
||||
|
||||
it('ignores a denial belonging to the socket before a same-generation rebind', async () => {
|
||||
const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1'))
|
||||
const oldSocket = new FakeSocket()
|
||||
await h.activate(oldSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
||||
const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
|
||||
let finish!: (outcomes: ControlRenewalOutcome[]) => void
|
||||
h.renewControlActivities.mockReturnValueOnce(
|
||||
new Promise<ControlRenewalOutcome[]>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
await closeRenewalWindow()
|
||||
const replacement = new FakeSocket()
|
||||
await h.activate(replacement as unknown as WebSocket, identity, session, 1, true, 1)
|
||||
finish(['activity_cell_not_authoritative'])
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(replacement.close).not.toHaveBeenCalled()
|
||||
expect(session.socket).toBe(replacement)
|
||||
expect(session.generation).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('source-owned idle cutover', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
const request = {
|
||||
attemptId: 'idle-1',
|
||||
userId: identity.sub,
|
||||
relayHostId: identity.relayHostId,
|
||||
sourceAssignmentEpoch: 1,
|
||||
sourceGeneration: 1,
|
||||
sourceCellIncarnation: 'incarnation-1',
|
||||
targetCellId: 'target'
|
||||
}
|
||||
async function source() {
|
||||
const h = createRegistry(vi.fn().mockResolvedValue('control:1'))
|
||||
const socket = new FakeSocket()
|
||||
h.registry.acceptControl(
|
||||
socket as unknown as WebSocket,
|
||||
identity,
|
||||
undefined,
|
||||
new Set([RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME])
|
||||
)
|
||||
socket.removeAllListeners('message')
|
||||
await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
||||
// Moves the clock without firing the heartbeat timers that would close the session.
|
||||
vi.setSystemTime(Date.now() + IDLE_REHOME_MIN_CONTROL_AGE_MS)
|
||||
return { ...h, socket, session: h.registry.get(request)! }
|
||||
}
|
||||
|
||||
it('rejects new accepts and replacements synchronously while a commit awaits', async () => {
|
||||
const h = await source()
|
||||
const result = deferred<{ outcome: 'deferred' }>()
|
||||
const commit = vi.fn().mockReturnValue(result.promise)
|
||||
const moving = h.registry.idleRehome(
|
||||
request,
|
||||
commit,
|
||||
vi.fn().mockResolvedValue('not-committed')
|
||||
)
|
||||
const duplicate = h.registry.idleRehome(
|
||||
request,
|
||||
commit,
|
||||
vi.fn().mockResolvedValue('not-committed')
|
||||
)
|
||||
const client = new FakeSocket()
|
||||
const release = vi.fn()
|
||||
await h.registry.acceptClient(
|
||||
client as unknown as WebSocket,
|
||||
identity.relayHostId,
|
||||
'credential',
|
||||
{ release } as never
|
||||
)
|
||||
expect(client.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String))
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
const replacement = new FakeSocket()
|
||||
await h.activate(replacement as unknown as WebSocket, identity, h.session, 2, false, 1)
|
||||
expect(replacement.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String))
|
||||
result.resolve({ outcome: 'deferred' })
|
||||
await moving
|
||||
await duplicate
|
||||
expect(commit).toHaveBeenCalledOnce()
|
||||
expect(h.socket.close).not.toHaveBeenCalled()
|
||||
expect(
|
||||
await h.registry.idleRehome(
|
||||
{ ...request, attemptId: 'next' },
|
||||
vi.fn().mockResolvedValue({ outcome: 'committed' }),
|
||||
vi.fn().mockResolvedValue('not-committed')
|
||||
)
|
||||
).toEqual({ outcome: 'committed' })
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,7 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { RelayAssignmentStore } from '../assignment/assignment-store.js'
|
||||
import type { RelayCellConfig } from '../config.js'
|
||||
import {
|
||||
openInMemoryRelayDatabase,
|
||||
openRelayDatabase,
|
||||
type RelayDatabase
|
||||
} from '../database/database.js'
|
||||
import { openInMemoryRelayDatabase, type RelayDatabase } from '../database/database.js'
|
||||
|
||||
const KEY_PREFIX = 'reconnect-claim'
|
||||
const USER_ID = `${KEY_PREFIX}-user-1`
|
||||
@@ -21,54 +17,19 @@ const CELL_INCARNATION = '11111111-1111-4111-8111-111111111111'
|
||||
const IDENTITY = { userId: USER_ID, relayHostId: RELAY_HOST_ID }
|
||||
const CONTROL_ACTIVITY_ID = `control:${CELL.id}:1`
|
||||
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const backends: { name: string; open: () => Promise<RelayDatabase> }[] = [
|
||||
{ name: 'sqlite', open: openInMemoryRelayDatabase },
|
||||
...(databaseUrl
|
||||
? [
|
||||
{
|
||||
name: 'postgres',
|
||||
open: () => openRelayDatabase({ databaseUrl, dataDir: '' })
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
const databases: RelayDatabase[] = []
|
||||
|
||||
async function removeScopedRows(database: RelayDatabase): Promise<void> {
|
||||
await database.query(
|
||||
`DELETE FROM relay_control_connection_reservations WHERE user_id LIKE '${KEY_PREFIX}-%'`
|
||||
)
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases WHERE user_id LIKE '${KEY_PREFIX}-%'`
|
||||
)
|
||||
await database.query(`DELETE FROM relay_assignments WHERE user_id LIKE '${KEY_PREFIX}-%'`)
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const database of databases.splice(0)) {
|
||||
await removeScopedRows(database)
|
||||
await database.close()
|
||||
}
|
||||
})
|
||||
|
||||
async function setup(
|
||||
open: () => Promise<RelayDatabase>,
|
||||
now: () => number
|
||||
): Promise<{ database: RelayDatabase; store: RelayAssignmentStore }> {
|
||||
const database = await open()
|
||||
const database = await openInMemoryRelayDatabase()
|
||||
databases.push(database)
|
||||
await removeScopedRows(database)
|
||||
const store = new RelayAssignmentStore(database, now, {
|
||||
requireLiveCells: true,
|
||||
heartbeatTtlMs: 45_000
|
||||
@@ -113,10 +74,10 @@ async function reservations(
|
||||
}))
|
||||
}
|
||||
|
||||
describe.each(backends)('control reservation claim on reconnect ($name)', ({ open }) => {
|
||||
describe('control reservation claim on reconnect', () => {
|
||||
it('claims the fresh reservation when the same generation reconnects', async () => {
|
||||
let now = 100_000_000
|
||||
const { database, store } = await setup(open, () => now)
|
||||
const { database, store } = await setup(() => now)
|
||||
|
||||
const assignment = await store.assign(IDENTITY)
|
||||
await store.activateControl(IDENTITY, {
|
||||
@@ -158,7 +119,7 @@ describe.each(backends)('control reservation claim on reconnect ($name)', ({ ope
|
||||
|
||||
it('still refuses to claim a second reservation while the first is outstanding', async () => {
|
||||
let now = 100_000_000
|
||||
const { database, store } = await setup(open, () => now)
|
||||
const { database, store } = await setup(() => now)
|
||||
|
||||
const assignment = await store.assign(IDENTITY)
|
||||
await store.activateControl(IDENTITY, {
|
||||
|
||||
@@ -63,12 +63,7 @@ async function remainingIds(database: RelayDatabase): Promise<string[]> {
|
||||
return rows.map((row) => String(row.reservation_id))
|
||||
}
|
||||
|
||||
const dialects: [string, () => Promise<RelayDatabase>][] = [
|
||||
['sqlite', openInMemoryRelayDatabase],
|
||||
...(databaseUrl ? [['postgres', openPostgres] as [string, () => Promise<RelayDatabase>]] : [])
|
||||
]
|
||||
|
||||
describe.each(dialects)('released reservation prune (%s)', (_dialect, open) => {
|
||||
describe('released reservation prune', () => {
|
||||
let database: RelayDatabase | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -76,12 +71,8 @@ describe.each(dialects)('released reservation prune (%s)', (_dialect, open) => {
|
||||
database = undefined
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
})
|
||||
|
||||
it('deletes only released rows older than the retention window', async () => {
|
||||
database = await open()
|
||||
database = await openInMemoryRelayDatabase()
|
||||
await insertReservation(database, 'a-old-released', 'released', STALE - 1)
|
||||
await insertReservation(database, 'b-edge-released', 'released', STALE)
|
||||
await insertReservation(database, 'c-fresh-released', 'released', STALE + 1)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AUDIT_EVENT_RETENTION_MS,
|
||||
hashCredential,
|
||||
CONFIRM_RESULT_RETENTION_MS,
|
||||
RelayCredentialStore,
|
||||
type RelayIdentity
|
||||
@@ -292,54 +291,4 @@ describe('credential cleanup invite reaper', () => {
|
||||
expect(await remainingIds(database, 'relay_audit_events', 'id')).toEqual(['audit-recent'])
|
||||
await database.close()
|
||||
})
|
||||
|
||||
it('still replays a confirm result inside retention', async () => {
|
||||
const database = await openInMemoryRelayDatabase()
|
||||
let now = NOW - DAY_MS
|
||||
const store = new RelayCredentialStore(database, () => now)
|
||||
try {
|
||||
const invite = await store.createInvite(identity, 'device-1')
|
||||
const reservation = await store.reserveCredential(identity.relayHostId, invite.inviteToken)
|
||||
if (!reservation) {
|
||||
throw new Error('invite did not reserve')
|
||||
}
|
||||
await store.recordConnectionBasis({
|
||||
...reservation,
|
||||
basisConnId: 'invite-basis',
|
||||
owningControlGeneration: 1,
|
||||
deadline: reservation.leaseExpiresAt
|
||||
})
|
||||
await store.installCredential({
|
||||
...identity,
|
||||
relayDeviceId: 'device-1',
|
||||
reqId: 'install-1',
|
||||
newResumeTokenHash: hashCredential('resume-token'),
|
||||
owningControlGeneration: 1,
|
||||
authorization: { mode: 'relay-basis', basisConnId: 'invite-basis' }
|
||||
})
|
||||
const resume = await store.reserveCredential(identity.relayHostId, 'resume-token')
|
||||
if (!resume) {
|
||||
throw new Error('resume did not reserve')
|
||||
}
|
||||
await store.recordConnectionBasis({
|
||||
...resume,
|
||||
basisConnId: 'resume-basis',
|
||||
owningControlGeneration: 1,
|
||||
deadline: resume.leaseExpiresAt
|
||||
})
|
||||
const request = {
|
||||
...identity,
|
||||
reqId: 'confirm-1',
|
||||
basisConnId: 'resume-basis',
|
||||
owningControlGeneration: 1
|
||||
}
|
||||
const confirmed = await store.confirmResume(request)
|
||||
expect(confirmed).toMatchObject({ renewed: true, acceptedAs: 'current' })
|
||||
now = NOW
|
||||
await store.cleanup()
|
||||
expect(await store.confirmResume(request)).toEqual(confirmed)
|
||||
} finally {
|
||||
await database.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { openInMemoryRelayDatabase, type RelayDatabase, type SqlRow } from '../database/database.js'
|
||||
import { ResumeConfirmedAuditBuffer } from './resume-confirmed-audit-buffer.js'
|
||||
|
||||
const event = (reqId: string) => ({
|
||||
userId: 'user-1',
|
||||
relayHostId: 'host000000000001',
|
||||
relayDeviceId: 'device-1',
|
||||
detail: { reqId, basisConnId: `basis-${reqId}`, renewed: true }
|
||||
})
|
||||
|
||||
describe('the batched resume-confirmed audit', () => {
|
||||
let inner: RelayDatabase | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await inner?.close()
|
||||
inner = undefined
|
||||
})
|
||||
|
||||
async function setup() {
|
||||
inner = await openInMemoryRelayDatabase()
|
||||
const database = inner
|
||||
const buffer = new ResumeConfirmedAuditBuffer(database, Date.now, () => undefined)
|
||||
const rows = async (): Promise<SqlRow[]> =>
|
||||
await inner!.query(
|
||||
`SELECT type, relay_device_id, detail_json FROM relay_audit_events ORDER BY at, id`
|
||||
)
|
||||
return { database, buffer, rows }
|
||||
}
|
||||
|
||||
it('retries a flush whose commit outcome was lost without writing any row twice', async () => {
|
||||
const { database, buffer, rows } = await setup()
|
||||
buffer.add(event('a'))
|
||||
buffer.add(event('b'))
|
||||
// The insert commits, then the reply is lost.
|
||||
const query = database.query.bind(database)
|
||||
let lost = true
|
||||
database.query = async (sql, params) => {
|
||||
const result = await query(sql, params)
|
||||
if (lost) {
|
||||
lost = false
|
||||
throw new Error('Connection terminated unexpectedly')
|
||||
}
|
||||
return result
|
||||
}
|
||||
await buffer.flush()
|
||||
await buffer.flush()
|
||||
expect(await rows()).toHaveLength(2)
|
||||
buffer.stop()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { RelayAssignmentStore } from '../assignment/assignment-store.js'
|
||||
import { openRelayDatabase, type RelayDatabase } from './database.js'
|
||||
import { readPostgresLockWaitSample } from './postgres-lock-wait-sample.js'
|
||||
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const describePostgres = databaseUrl ? describe : describe.skip
|
||||
|
||||
const cell = {
|
||||
id: 'lock-wait-sample-cell',
|
||||
url: 'https://lock-wait-sample.example.com',
|
||||
capacityRequests: 10
|
||||
}
|
||||
|
||||
// The split rests on application_name surviving into pg_stat_activity for both
|
||||
// the waiter and its blocker, which only a real server shows.
|
||||
describePostgres('PostgreSQL lock-wait sample', () => {
|
||||
let cellDatabase: RelayDatabase
|
||||
let directorDatabase: RelayDatabase
|
||||
|
||||
beforeAll(async () => {
|
||||
cellDatabase = await openRelayDatabase({
|
||||
databaseUrl,
|
||||
dataDir: '',
|
||||
applicationName: `orca-relay/cell/${cell.id}`
|
||||
})
|
||||
directorDatabase = await openRelayDatabase({
|
||||
databaseUrl,
|
||||
dataDir: '',
|
||||
applicationName: 'orca-relay/director/director'
|
||||
})
|
||||
await new RelayAssignmentStore(directorDatabase).reconcileCells([cell])
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const table of ['relay_cell_regions', 'relay_cell_admission']) {
|
||||
await directorDatabase?.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id])
|
||||
}
|
||||
await directorDatabase?.query(`DELETE FROM relay_cells WHERE cell_id = ?`, [cell.id])
|
||||
await cellDatabase?.close()
|
||||
await directorDatabase?.close()
|
||||
})
|
||||
|
||||
// Every waiter after the first is blocked by the first waiter's tuple lock, so
|
||||
// only the root of the chain names the cell transaction that holds the row.
|
||||
it('attributes a convoy of directors to the cell holding the relay_cells row', async () => {
|
||||
expect(await readPostgresLockWaitSample(directorDatabase)).toEqual([])
|
||||
|
||||
let release!: () => void
|
||||
const released = new Promise<void>((resolve) => (release = resolve))
|
||||
let held!: () => void
|
||||
const holding = new Promise<void>((resolve) => (held = resolve))
|
||||
const holder = cellDatabase.transaction(async (transaction) => {
|
||||
await transaction.query(
|
||||
`UPDATE relay_cells SET reserved_requests = reserved_requests WHERE cell_id = ?`,
|
||||
[cell.id]
|
||||
)
|
||||
held()
|
||||
await released
|
||||
})
|
||||
await holding
|
||||
const waiters = Array.from({ length: 3 }, () =>
|
||||
directorDatabase.transaction(async (transaction) => {
|
||||
await transaction.queryLocked(`SELECT * FROM relay_cells WHERE cell_id = ?`, [cell.id])
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(async () => await readPostgresLockWaitSample(directorDatabase), { timeout: 900 })
|
||||
.toEqual([
|
||||
{ waiterRole: 'director', table: 'relay_cells', holderRole: 'cell', waiters: 3 }
|
||||
])
|
||||
} finally {
|
||||
release()
|
||||
await Promise.all([holder, ...waiters])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import pg from 'pg'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { openRelayDatabase } from './database.js'
|
||||
import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js'
|
||||
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
@@ -52,28 +51,6 @@ describePostgres('optional PostgreSQL statement statistics', () => {
|
||||
return result.rows[0]!.present
|
||||
}
|
||||
|
||||
it('exposes an existing collector idempotently, and skips servers without one', async () => {
|
||||
const url = await freshDatabase()
|
||||
const database = await openRelayDatabase({ databaseUrl: url, dataDir: '' })
|
||||
await database.close()
|
||||
const client = await connect(url)
|
||||
try {
|
||||
expect(await installed(client)).toBe(preloaded)
|
||||
if (preloaded) {
|
||||
const before = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info')
|
||||
await client.query(POSTGRES_STATEMENT_STATS_MIGRATION)
|
||||
const after = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info')
|
||||
expect(after.rows).toEqual(before.rows)
|
||||
await client.query('SELECT calls, wal_bytes, shared_blks_dirtied FROM public.pg_stat_statements LIMIT 1')
|
||||
} else {
|
||||
await client.query(POSTGRES_STATEMENT_STATS_MIGRATION)
|
||||
expect(await installed(client)).toBe(false)
|
||||
}
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
})
|
||||
|
||||
it('tolerates missing extension privileges', async () => {
|
||||
const client = await connect(await freshDatabase())
|
||||
const role = `relay_stats_role_${randomUUID().replaceAll('-', '')}`
|
||||
|
||||
@@ -100,43 +100,16 @@ describePostgres('PostgreSQL transaction recovery', () => {
|
||||
'relay_assignment_migration_incarnations',
|
||||
'relay_control_connection_reservations'
|
||||
]) {
|
||||
await database.query(`DELETE FROM ${table} WHERE user_id IN (?, ?)`, [
|
||||
'completion-race-user',
|
||||
'completion-contention-user'
|
||||
await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [
|
||||
'completion-race-user'
|
||||
])
|
||||
}
|
||||
for (const table of ['relay_cell_admission', 'relay_cell_regions']) {
|
||||
await database.query(`DELETE FROM ${table} WHERE cell_id IN (?, ?, ?, ?)`, [
|
||||
await database.query(`DELETE FROM ${table} WHERE cell_id IN (?, ?)`, [
|
||||
'completion-race-source',
|
||||
'completion-race-target',
|
||||
'completion-contention-source',
|
||||
'completion-contention-target'
|
||||
'completion-race-target'
|
||||
])
|
||||
}
|
||||
for (const identity of [
|
||||
{ userId: 'released-order-user', relayHostId: 'releasedorder001' },
|
||||
{ userId: 'normalized-order-user', relayHostId: 'normalizedorder1' },
|
||||
{ userId: 'atomic-final-user-a', relayHostId: 'atomicfinalhosta' },
|
||||
{ userId: 'atomic-final-user-b', relayHostId: 'atomicfinalhostb' },
|
||||
{ userId: 'lease-assignment-contention-user', relayHostId: 'leaseassignment1' }
|
||||
]) {
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ?`,
|
||||
[identity.userId, identity.relayHostId]
|
||||
)
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`,
|
||||
[identity.userId, identity.relayHostId]
|
||||
)
|
||||
}
|
||||
await database.query(`DELETE FROM relay_cells WHERE cell_id IN (?, ?)`, [
|
||||
'released-order-cell',
|
||||
'normalized-order-cell'
|
||||
])
|
||||
await database.query(`DELETE FROM relay_cells WHERE cell_id = ?`, ['atomic-final-cell'])
|
||||
await database.query(`DELETE FROM relay_cells WHERE cell_id = ?`, [
|
||||
'lease-assignment-contention-cell'
|
||||
])
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases WHERE user_id LIKE 'reconcile-user-%'`
|
||||
)
|
||||
@@ -159,110 +132,6 @@ describePostgres('PostgreSQL transaction recovery', () => {
|
||||
'completion-race-source',
|
||||
'completion-race-target'
|
||||
])
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`,
|
||||
['completion-contention-user']
|
||||
)
|
||||
await database.query(`DELETE FROM relay_assignment_migrations WHERE user_id = ?`, [
|
||||
'completion-contention-user'
|
||||
])
|
||||
await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [
|
||||
'completion-contention-user'
|
||||
])
|
||||
await database.query(`DELETE FROM relay_cell_runtime WHERE cell_id IN (?, ?)`, [
|
||||
'completion-contention-source',
|
||||
'completion-contention-target'
|
||||
])
|
||||
await database.query(`DELETE FROM relay_cells WHERE cell_id IN (?, ?)`, [
|
||||
'completion-contention-source',
|
||||
'completion-contention-target'
|
||||
])
|
||||
await database.query(`DELETE FROM relay_cell_fences WHERE cell_id = ?`, [
|
||||
'dead-source-contention-source'
|
||||
])
|
||||
await database.query(
|
||||
`DELETE FROM relay_control_connection_reservations WHERE user_id = ?`,
|
||||
['dead-source-contention-user']
|
||||
)
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`,
|
||||
['dead-source-contention-user']
|
||||
)
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_migration_incarnations WHERE user_id = ?`,
|
||||
['dead-source-contention-user']
|
||||
)
|
||||
await database.query(`DELETE FROM relay_assignment_migrations WHERE user_id = ?`, [
|
||||
'dead-source-contention-user'
|
||||
])
|
||||
await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [
|
||||
'dead-source-contention-user'
|
||||
])
|
||||
await database.query(`DELETE FROM relay_cell_runtime WHERE cell_id IN (?, ?)`, [
|
||||
'dead-source-contention-source',
|
||||
'dead-source-contention-target'
|
||||
])
|
||||
await database.query(`DELETE FROM relay_cell_admission WHERE cell_id IN (?, ?)`, [
|
||||
'dead-source-contention-source',
|
||||
'dead-source-contention-target'
|
||||
])
|
||||
await database.query(`DELETE FROM relay_cells WHERE cell_id IN (?, ?)`, [
|
||||
'dead-source-contention-source',
|
||||
'dead-source-contention-target'
|
||||
])
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases
|
||||
WHERE user_id IN (?, ?)`,
|
||||
['lease-contention-user', 'aggregate-contention-user']
|
||||
)
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignments
|
||||
WHERE user_id IN (?, ?)`,
|
||||
['lease-contention-user', 'aggregate-contention-user']
|
||||
)
|
||||
await database.query(`DELETE FROM relay_cells WHERE cell_id IN (?, ?)`, [
|
||||
'lease-contention-cell',
|
||||
'aggregate-contention-cell'
|
||||
])
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`,
|
||||
['sustained-lock-user']
|
||||
)
|
||||
await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [
|
||||
'sustained-lock-user'
|
||||
])
|
||||
await database.query(`DELETE FROM relay_cells WHERE cell_id = ?`, [
|
||||
'sustained-lock-cell'
|
||||
])
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`,
|
||||
['sticky-cell-contention-user']
|
||||
)
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignments WHERE user_id = ?`,
|
||||
['sticky-cell-contention-user']
|
||||
)
|
||||
await database.query(`DELETE FROM relay_cells WHERE cell_id = ?`, [
|
||||
'sticky-cell-contention-cell'
|
||||
])
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases WHERE user_id LIKE 'postgres-user-%'`
|
||||
)
|
||||
await database.query(`DELETE FROM relay_assignments WHERE user_id LIKE 'postgres-user-%'`)
|
||||
await database.query(`DELETE FROM relay_cells WHERE cell_id IN (?, ?, ?)`, [
|
||||
'cell-a',
|
||||
'cell-b',
|
||||
'cell-c'
|
||||
])
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases WHERE user_id LIKE 'sticky-fast-user-%'`
|
||||
)
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignments WHERE user_id LIKE 'sticky-fast-user-%'`
|
||||
)
|
||||
await database.query(`DELETE FROM relay_cells WHERE cell_id = ?`, [
|
||||
'sticky-fast-cell'
|
||||
])
|
||||
})
|
||||
|
||||
it('retries an entire deadlock victim transaction on a fresh attempt', async () => {
|
||||
|
||||
@@ -129,15 +129,4 @@ describe('edge move', () => {
|
||||
expect(moved.assignmentEpoch).toBe(placed.assignmentEpoch + 4)
|
||||
})
|
||||
|
||||
it('rolls back: with the cohort off, the host is re-placed on GCE above the edge epoch', async () => {
|
||||
const { database, store, edge } = await setup({ requireLiveCells: true })
|
||||
await edge.ensureEdgeCell()
|
||||
await store.assign(IDENTITY)
|
||||
await settle(database)
|
||||
const moved = await edge.moveToEdge(IDENTITY, 0)
|
||||
const back = await store.assign(IDENTITY)
|
||||
expect(back.cellId).toBe(GCE_CELL.id)
|
||||
expect(back.assignmentEpoch).toBeGreaterThan(moved.assignmentEpoch)
|
||||
expect(await edge.edgeAssignment(IDENTITY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type * as AdminTokenVerifier from './authentication/admin-token-verifier.js'
|
||||
import type { HostWhereaboutsRow } from './assignment/assignment-store.js'
|
||||
import { RelayAssignmentStore } from './assignment/assignment-store.js'
|
||||
import type { RelayConfig } from './config.js'
|
||||
import { openInMemoryRelayDatabase } from './database/database.js'
|
||||
import { ShadowSeatDirectory } from './cell/seats/shadow-seat-directory.js'
|
||||
|
||||
vi.mock('./authentication/admin-token-verifier.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AdminTokenVerifier>()
|
||||
const identities = new Map<string, Parameters<typeof actual.relayAdminIdentityMayAccess>[0]>([
|
||||
['deploy-token', 'deploy'],
|
||||
['capacity-token', 'capacity']
|
||||
])
|
||||
return {
|
||||
...actual,
|
||||
// The real route allow-list behind a token-to-identity stub.
|
||||
createAdminTokenVerifier: () => async (token: string, route?: string) => {
|
||||
const identity = identities.get(token)
|
||||
if (identity === undefined) return false
|
||||
return !route || actual.relayAdminIdentityMayAccess(identity, route)
|
||||
},
|
||||
createReadOnlyAdminTokenVerifier: () => async () => false,
|
||||
createRegionalRehomeControlApplyTokenVerifier: () => async () => false,
|
||||
createRegionalRehomeRuntimeTokenVerifier: () => async () => false,
|
||||
createRegionalRehomeTokenVerifier: () => async () => false,
|
||||
createRuntimeTokenVerifier: () => async () => false
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./authentication/relay-token-verifier.js', () => ({
|
||||
createRelayTokenVerifier: () => async () => null,
|
||||
readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null
|
||||
}))
|
||||
|
||||
import { createRelayApp } from './app.js'
|
||||
|
||||
const HOST = 'hhhhhhhhhhhhhhhh'
|
||||
const ROW: HostWhereaboutsRow = {
|
||||
cellId: 'cell-a',
|
||||
assignmentEpoch: 3,
|
||||
leaseExpiresAt: 5_000,
|
||||
openMigration: null
|
||||
}
|
||||
|
||||
function request(body: unknown, token = 'deploy-token'): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
function directory(): ShadowSeatDirectory {
|
||||
const shadow = new ShadowSeatDirectory()
|
||||
shadow.setCells(['cell-a', 'cell-b'])
|
||||
shadow.apply(
|
||||
'cell-a',
|
||||
{
|
||||
v: 1,
|
||||
cellId: 'cell-a',
|
||||
incarnation: 'inc-a',
|
||||
seq: 1,
|
||||
at: 1,
|
||||
flagsApplied: { generation: '7', flags: { readinessLocal: false } },
|
||||
full: [
|
||||
{
|
||||
userId: 'user-a',
|
||||
relayHostId: HOST,
|
||||
epoch: 3,
|
||||
generation: 1,
|
||||
state: 'active',
|
||||
joinedAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
Date.now()
|
||||
)
|
||||
shadow.markNoFeed('cell-b', Date.now())
|
||||
return shadow
|
||||
}
|
||||
|
||||
function appWith(
|
||||
hostWhereabouts: (identity: { userId: string; relayHostId: string }) => Promise<unknown>,
|
||||
options: { shadowSeats?: ShadowSeatDirectory; role?: RelayConfig['role'] } = {}
|
||||
) {
|
||||
return createRelayApp(config(options.role), {
|
||||
store: {} as never,
|
||||
// SAFETY: the whereabouts route reads only hostWhereabouts.
|
||||
assignments: { hostWhereabouts } as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true),
|
||||
cellIncarnation: 'director-incarnation',
|
||||
shadowSeats: options.shadowSeats
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /v1/admin/host-whereabouts', () => {
|
||||
it('requires admin auth and a director', async () => {
|
||||
const lookup = vi.fn(async () => ROW)
|
||||
const app = appWith(lookup)
|
||||
const body = { v: 1, relayHostId: HOST, userId: 'user-a' }
|
||||
|
||||
expect((await app.request('/v1/admin/host-whereabouts', request(body, 'other'))).status).toBe(
|
||||
401
|
||||
)
|
||||
const cell = appWith(lookup, { role: 'cell' })
|
||||
expect((await cell.request('/v1/admin/host-whereabouts', request(body))).status).toBe(404)
|
||||
expect(
|
||||
(await app.request('/v1/admin/host-whereabouts', request({ ...body, extra: 1 }))).status
|
||||
).toBe(400)
|
||||
})
|
||||
|
||||
|
||||
it('names the user from the map when the request has only a host id', async () => {
|
||||
const lookup = vi.fn(async () => ROW)
|
||||
const app = appWith(lookup, { shadowSeats: directory() })
|
||||
|
||||
const response = await app.request(
|
||||
'/v1/admin/host-whereabouts',
|
||||
request({ v: 1, relayHostId: HOST })
|
||||
)
|
||||
|
||||
expect(await response.json()).toMatchObject({ users: [{ userId: 'user-a', database: ROW }] })
|
||||
})
|
||||
|
||||
it('judges the verdict as /v1/resolve would when the row names a dead cell', async () => {
|
||||
const shadow = directory()
|
||||
// cell-a's heartbeat had already run out when the cell list was read.
|
||||
shadow.setCells(['cell-a', 'cell-b'], ['cell-a', 'cell-b'], {
|
||||
readAt: Date.now(),
|
||||
expiresAt: new Map([
|
||||
['cell-a', Date.now() - 1],
|
||||
['cell-b', Date.now() + 60_000]
|
||||
])
|
||||
})
|
||||
const app = appWith(async () => ROW, { shadowSeats: shadow })
|
||||
|
||||
const response = await app.request(
|
||||
'/v1/admin/host-whereabouts',
|
||||
request({ v: 1, relayHostId: HOST, userId: 'user-a' })
|
||||
)
|
||||
|
||||
expect(await response.json()).toMatchObject({
|
||||
users: [{ database: ROW, verdict: { class: 'map-only-cell-unlive', explained: true } }]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RelayAssignmentStore.hostWhereabouts', () => {
|
||||
it('reads the assignment row and its open migration by primary key', async () => {
|
||||
const database = await openInMemoryRelayDatabase()
|
||||
try {
|
||||
const store = new RelayAssignmentStore(database, () => 100)
|
||||
await store.reconcileCells([
|
||||
{ id: 'cell-a', url: 'https://cell-a.example.test', capacityRequests: 10 },
|
||||
{ id: 'cell-b', url: 'https://cell-b.example.test', capacityRequests: 10 }
|
||||
])
|
||||
const identity = { userId: 'user-a', relayHostId: HOST }
|
||||
expect(await store.hostWhereabouts(identity)).toBeNull()
|
||||
|
||||
await store.assign(identity)
|
||||
|
||||
await database.query(
|
||||
`INSERT INTO relay_assignment_migrations
|
||||
(user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch,
|
||||
source_request_units, target_reserved_units, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
['user-a', HOST, 'cell-a', 'cell-b', 1, 2, 1, 1, 900, 100, 100]
|
||||
)
|
||||
expect((await store.hostWhereabouts(identity))?.openMigration).toEqual({
|
||||
sourceCellId: 'cell-a',
|
||||
targetCellId: 'cell-b',
|
||||
previousEpoch: 1,
|
||||
assignmentEpoch: 2,
|
||||
expiresAt: 900,
|
||||
targetRegisteredAt: null
|
||||
})
|
||||
} finally {
|
||||
await database.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function config(role: RelayConfig['role'] = 'director'): RelayConfig {
|
||||
return {
|
||||
port: 8080,
|
||||
publicUrl: 'https://relay.example.test',
|
||||
cellUrl: 'https://relay.example.test',
|
||||
authIssuer: 'https://auth.example.test',
|
||||
authAudience: 'orca-relay',
|
||||
jwksUrl: 'https://auth.example.test/jwks',
|
||||
assignmentSigningKey: new TextEncoder().encode('assignment-key-with-at-least-32-bytes'),
|
||||
role,
|
||||
cellId: role === 'cell' ? 'cell-a' : 'director',
|
||||
cells: [],
|
||||
adminAudience: 'https://relay.example.test/v1/admin/drain',
|
||||
deployServiceAccount: 'deploy@example.test',
|
||||
runtimeServiceAccount: 'runtime@example.test',
|
||||
adminJwksUrl: 'https://auth.example.test/jwks',
|
||||
databasePoolMax: 3,
|
||||
publicAssignmentsEnabled: true,
|
||||
publicAssignmentConcurrency: 2,
|
||||
publicAssignmentQueueMax: 128,
|
||||
publicAssignmentWaitMs: 4_000,
|
||||
publicResolveConcurrency: 1,
|
||||
publicResolveWaitMs: 5_000,
|
||||
publicAssignmentRetryAfterSeconds: 5,
|
||||
dataDir: './data'
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,6 @@ describe('idle regional cutover endpoint', () => {
|
||||
})
|
||||
|
||||
describe('regional host drain endpoint', () => {
|
||||
|
||||
it('accepts only the dedicated identity and exact cell generation', async () => {
|
||||
const drainHost = vi.fn(() => 'accepted' as const)
|
||||
const app = createRelayApp(config(), {
|
||||
@@ -116,22 +115,6 @@ describe('regional host drain endpoint', () => {
|
||||
expect(drainHost).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a failed asynchronous drain instead of acknowledging it', async () => {
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: {} as never,
|
||||
drain: vi.fn(),
|
||||
drainHost: async () => {
|
||||
throw new Error('activity_cell_not_authoritative')
|
||||
},
|
||||
cellIncarnation,
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
const response = await post(app, 'rehome-token', request)
|
||||
expect(response.status).toBe(409)
|
||||
expect(await response.json()).toEqual({ error: 'activity_cell_not_authoritative' })
|
||||
})
|
||||
|
||||
it('rechecks synthetic host absence after asynchronous identity proof', async () => {
|
||||
let hostExists = false
|
||||
const drainHost = vi.fn(() => 'host-not-connected' as const)
|
||||
@@ -160,81 +143,6 @@ describe('regional host drain endpoint', () => {
|
||||
expect(response.status).toBe(409)
|
||||
expect(drainHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('regional rehome director controls', () => {
|
||||
|
||||
it('applies the durable switch only with matching confirmation', async () => {
|
||||
const inspectRegionalRehomeControl = vi.fn().mockResolvedValue({
|
||||
generation: 0,
|
||||
enabled: false
|
||||
})
|
||||
const applyRegionalRehomeControl = vi.fn(async (input) => ({
|
||||
...input,
|
||||
generation: input.expectedGeneration + 1
|
||||
}))
|
||||
const app = createRelayApp(config({ role: 'director', cellId: 'director' }), {
|
||||
store: {} as never,
|
||||
assignments: {
|
||||
inspectRegionalRehomeControl,
|
||||
applyRegionalRehomeControl
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
expect(
|
||||
(
|
||||
await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', {
|
||||
v: 1,
|
||||
action: 'inspect'
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
const apply = {
|
||||
v: 1,
|
||||
action: 'apply',
|
||||
expectedGeneration: 0,
|
||||
enabled: true,
|
||||
notBefore: 100,
|
||||
ratePerMinute: 10,
|
||||
preferenceMaxAgeMs: 24 * 60 * 60_000,
|
||||
hostCooldownMs: 7 * 24 * 60 * 60_000,
|
||||
drainGraceMs: 60_000,
|
||||
confirmation: 'ENABLE_REGIONAL_REHOMING'
|
||||
}
|
||||
expect(
|
||||
(await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', apply)).status
|
||||
).toBe(200)
|
||||
expect(applyRegionalRehomeControl).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
(
|
||||
await postPath(app, '/v1/admin/regional-rehome-control', 'monitor-token', {
|
||||
v: 1,
|
||||
action: 'inspect'
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
expect(
|
||||
(await postPath(app, '/v1/admin/regional-rehome-control', 'monitor-token', apply)).status
|
||||
).toBe(403)
|
||||
expect(
|
||||
(
|
||||
await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', {
|
||||
...apply,
|
||||
confirmation: 'DISABLE_REGIONAL_REHOMING'
|
||||
})
|
||||
).status
|
||||
).toBe(400)
|
||||
// The per-host cooldown is part of the durable shape an operator must state.
|
||||
const { hostCooldownMs: _omitted, ...withoutCooldown } = apply
|
||||
expect(
|
||||
(await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', withoutCooldown))
|
||||
.status
|
||||
).toBe(400)
|
||||
expect(applyRegionalRehomeControl).toHaveBeenCalledExactlyOnceWith(apply)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
async function post(
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RelayConfig } from './config.js'
|
||||
|
||||
const fakes = vi.hoisted(() => ({
|
||||
verifyRelayToken: vi.fn(async (token: string) => ({ sub: 'user-1', relayHostId: token }))
|
||||
}))
|
||||
|
||||
vi.mock('./authentication/relay-token-verifier.js', () => ({
|
||||
createRelayTokenVerifier: () => fakes.verifyRelayToken,
|
||||
readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null
|
||||
}))
|
||||
|
||||
import { createRelayApp } from './app.js'
|
||||
|
||||
describe('Relay region API', () => {
|
||||
|
||||
it('returns successful placement when optional window storage is unavailable', async () => {
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: {
|
||||
assign: async () => ({
|
||||
cellId: 'asia-c1',
|
||||
region: 'asia-east2',
|
||||
cellUrl: 'https://asia-c1.relay.example.test',
|
||||
assignmentEpoch: 7
|
||||
}),
|
||||
exchangeRegionCorrection: async () => {
|
||||
throw new Error('database unavailable')
|
||||
}
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: async () => true
|
||||
})
|
||||
const response = await app.request(
|
||||
'/v1/assign',
|
||||
assignmentRequest('abcdefghijklmnop', {
|
||||
preferredRegion: 'asia-east2',
|
||||
regionCorrection: { v: 1, action: 'issue-window' }
|
||||
})
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
cellUrl: 'https://asia-c1.relay.example.test',
|
||||
assignmentEpoch: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('does not place or write a legacy hint when reporting migration evidence', async () => {
|
||||
const current = {
|
||||
userId: 'user-1',
|
||||
relayHostId: 'abcdefghijklmnop',
|
||||
cellId: 'asia-c1',
|
||||
cellUrl: 'https://asia-c1.relay.example.test',
|
||||
region: 'asia-east2',
|
||||
assignmentEpoch: 7,
|
||||
leaseExpiresAt: Date.now() + 300_000
|
||||
}
|
||||
const assign = vi.fn()
|
||||
const resolve = vi.fn(async () => current)
|
||||
const exchangeRegionCorrection = vi.fn(async () => ({ v: 1, reportStatus: 'accepted' }))
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: { assign, resolve, exchangeRegionCorrection } as never,
|
||||
drain: vi.fn(),
|
||||
ready: async () => true
|
||||
})
|
||||
const regionCorrection = {
|
||||
v: 1,
|
||||
action: 'report',
|
||||
generation: 2,
|
||||
assignmentEpoch: 7,
|
||||
policyVersion: 1,
|
||||
outcome: 'conclusive',
|
||||
measurements: { 'us-central1': 40, 'asia-east2': 180 }
|
||||
}
|
||||
const response = await app.request(
|
||||
'/v1/assign',
|
||||
assignmentRequest('abcdefghijklmnop', {
|
||||
preferredRegion: 'us-central1',
|
||||
regionCorrection
|
||||
})
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
expect(exchangeRegionCorrection).toHaveBeenCalledWith(
|
||||
{ userId: 'user-1', relayHostId: 'abcdefghijklmnop' },
|
||||
regionCorrection,
|
||||
7
|
||||
)
|
||||
expect(((await response.json()) as { assignmentEpoch: number }).assignmentEpoch).toBe(7)
|
||||
})
|
||||
|
||||
it('does not manufacture an assignment for a report whose assignment disappeared', async () => {
|
||||
const assign = vi.fn()
|
||||
const exchangeRegionCorrection = vi.fn()
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: { assign, resolve: async () => null, exchangeRegionCorrection } as never,
|
||||
drain: vi.fn(),
|
||||
ready: async () => true
|
||||
})
|
||||
const response = await app.request(
|
||||
'/v1/assign',
|
||||
assignmentRequest('abcdefghijklmnop', {
|
||||
regionCorrection: {
|
||||
v: 1,
|
||||
action: 'report',
|
||||
generation: 2,
|
||||
assignmentEpoch: 7,
|
||||
policyVersion: 1,
|
||||
outcome: 'inconclusive',
|
||||
reason: 'timeout'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(response.status).toBe(409)
|
||||
expect(assign).not.toHaveBeenCalled()
|
||||
expect(exchangeRegionCorrection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers a pool that cannot hand out a client with a retryable 503', async () => {
|
||||
const regionCatalog = vi.fn(async () => {
|
||||
throw new Error('Connection terminated due to connection timeout')
|
||||
})
|
||||
const app = createRelayApp(config({ publicAssignmentRetryAfterSeconds: 7 }), {
|
||||
store: {} as never,
|
||||
assignments: { regionCatalog } as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
const response = await app.request('/v1/regions')
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(response.headers.get('Retry-After')).toBe('7')
|
||||
expect(await response.json()).toEqual({ error: 'region_catalog_temporarily_unavailable' })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
function assignmentRequest(relayHostId: string, extra: Record<string, unknown>): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${relayHostId}`,
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ v: 1, relayHostId, ...extra })
|
||||
}
|
||||
}
|
||||
|
||||
function config(overrides: Partial<RelayConfig> = {}): RelayConfig {
|
||||
return {
|
||||
port: 8080,
|
||||
publicUrl: 'https://relay.example.test',
|
||||
cellUrl: 'https://relay.example.test',
|
||||
region: 'us-central1',
|
||||
authIssuer: 'https://auth.example.test',
|
||||
authAudience: 'orca-relay',
|
||||
jwksUrl: 'https://auth.example.test/jwks',
|
||||
assignmentSigningKey: new TextEncoder().encode('assignment-key-with-at-least-32-bytes'),
|
||||
role: 'director',
|
||||
cellId: 'director',
|
||||
cells: [],
|
||||
adminAudience: 'https://relay.example.test/v1/admin/drain',
|
||||
deployServiceAccount: 'deploy@example.test',
|
||||
runtimeServiceAccount: 'runtime@example.test',
|
||||
adminJwksUrl: 'https://auth.example.test/jwks',
|
||||
databasePoolMax: 3,
|
||||
publicAssignmentsEnabled: true,
|
||||
regionalPlacementEnabled: true,
|
||||
publicAssignmentConcurrency: 2,
|
||||
publicAssignmentQueueMax: 128,
|
||||
publicAssignmentWaitMs: 4_000,
|
||||
publicResolveConcurrency: 1,
|
||||
publicResolveWaitMs: 5_000,
|
||||
publicAssignmentRetryAfterSeconds: 5,
|
||||
dataDir: './data',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { connect, createServer, type Socket } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const appDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const cleanups: (() => void)[] = []
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
async function unusedPort(): Promise<number> {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolveListen) => server.listen(0, '127.0.0.1', resolveListen))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('missing test port')
|
||||
}
|
||||
await new Promise<void>((resolveClose) => server.close(() => resolveClose()))
|
||||
return address.port
|
||||
}
|
||||
|
||||
async function startRelay(port: number): Promise<ChildProcess> {
|
||||
const dataDirectory = mkdtempSync(resolve(tmpdir(), 'orca-relay-sigterm-exit-'))
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
const child = spawnProcess({
|
||||
program: process.execPath,
|
||||
args: ['--import', 'tsx', 'src/index.ts'],
|
||||
cwd: appDirectory,
|
||||
// Explicit so an inherited DATABASE_URL cannot point the relay at a shared database.
|
||||
env: {
|
||||
PATH: process.env.PATH ?? '',
|
||||
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||
PORT: String(port),
|
||||
ORCA_RELAY_PUBLIC_URL: url,
|
||||
ORCA_RELAY_CELL_URL: url,
|
||||
ORCA_RELAY_AUTH_ISSUER: 'http://127.0.0.1:9',
|
||||
ORCA_RELAY_JWKS_URL: 'http://127.0.0.1:9/jwks',
|
||||
ORCA_RELAY_ASSIGNMENT_SIGNING_KEY: 'test-assignment-key-with-at-least-32-bytes',
|
||||
ORCA_RELAY_DATA_DIR: dataDirectory,
|
||||
ORCA_RELAY_ADMIN_AUDIENCE: `${url}/v1/admin/drain`,
|
||||
ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'deploy@example.com',
|
||||
ORCA_RELAY_ADMIN_JWKS_URL: 'http://127.0.0.1:9/jwks'
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
cleanups.push(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
rmSync(dataDirectory, { recursive: true, force: true })
|
||||
})
|
||||
await new Promise<void>((resolveReady, reject) => {
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString()))
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
if (chunk.toString().includes('[orca-relay] listening')) {
|
||||
resolveReady()
|
||||
}
|
||||
})
|
||||
child.once('exit', (code) => reject(new Error(`relay exited before ready: ${code}\n${stderr}`)))
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
// Pauses reading so the peer never answers the relay's close frame.
|
||||
async function unresponsiveWebSocketPeer(port: number): Promise<Socket> {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}/v1/connect/abcdefghijklmnop`)
|
||||
socket.on('error', () => undefined)
|
||||
await new Promise<void>((resolveOpen) => socket.once('open', () => resolveOpen()))
|
||||
const raw = (socket as unknown as { _socket: Socket })._socket
|
||||
raw.pause()
|
||||
return raw
|
||||
}
|
||||
|
||||
// Resolves once the listener refuses new connections, i.e. the relay has begun shutting down.
|
||||
async function waitUntilListenerClosed(port: number): Promise<void> {
|
||||
const deadline = Date.now() + 4_000
|
||||
while (Date.now() < deadline) {
|
||||
const accepted = await new Promise<boolean>((resolveProbe) => {
|
||||
const probe = connect(port, '127.0.0.1')
|
||||
probe.once('connect', () => {
|
||||
probe.destroy()
|
||||
resolveProbe(true)
|
||||
})
|
||||
probe.once('error', () => resolveProbe(false))
|
||||
})
|
||||
if (!accepted) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolveDelay) => setTimeout(resolveDelay, 50))
|
||||
}
|
||||
throw new Error('relay kept accepting connections after SIGTERM')
|
||||
}
|
||||
|
||||
// Peers that are not idle to server.close(); unbounded, a silent one held exit until SIGKILL.
|
||||
it('exits within the shutdown bound on SIGTERM with an unresponsive upgraded WebSocket peer', async () => {
|
||||
const port = await unusedPort()
|
||||
const relay = await startRelay(port)
|
||||
const peer = await unresponsiveWebSocketPeer(port)
|
||||
cleanups.push(() => peer.destroy())
|
||||
const peerClosed = new Promise<void>((resolveClose) => peer.once('close', () => resolveClose()))
|
||||
|
||||
const startedAt = Date.now()
|
||||
const exited = new Promise<number | null>((resolveExit) =>
|
||||
relay.once('exit', (code) => resolveExit(code))
|
||||
)
|
||||
relay.kill('SIGTERM')
|
||||
// A second signal must not restart or extend shutdown. Send it only once the first has been
|
||||
// handled, so the two cannot coalesce into one delivery.
|
||||
await waitUntilListenerClosed(port)
|
||||
relay.kill('SIGTERM')
|
||||
|
||||
expect(await exited).toBe(0)
|
||||
// Under the 10 s backstop: the socket grace, not the backstop, released it.
|
||||
expect(Date.now() - startedAt).toBeLessThan(9_000)
|
||||
// A paused peer only observes the relay's FIN once it reads again.
|
||||
peer.resume()
|
||||
await peerClosed
|
||||
}, 15_000)
|
||||
@@ -130,7 +130,6 @@
|
||||
1 test-no-real-time-wait:src/main/browser/cdp/input/guest-focused-frame-route.test.ts
|
||||
3 test-no-real-time-wait:src/main/browser/cookies/browser-cookie-import-concurrency.test.ts
|
||||
1 test-no-real-time-wait:src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-native-compatibility.electron.test.ts
|
||||
1 test-no-real-time-wait:src/main/browser/identity/wire-probes/browser-session-ua-wire-identity-cross-context.electron.test.ts
|
||||
1 test-no-real-time-wait:src/main/browser/identity/wire-probes/browser-session-ua-wire-identity.electron.test.ts
|
||||
1 test-no-real-time-wait:src/main/browser/network/tunnels/execution-route-socket-duplex.test.ts
|
||||
1 test-no-real-time-wait:src/main/browser/network/tunnels/remote-browser-socks-server.test.ts
|
||||
@@ -555,7 +554,6 @@
|
||||
1 test-timeout-needs-reason:src/main/browser/cookies/browser-cookie-import-partition.electron.test.ts
|
||||
1 test-timeout-needs-reason:src/main/browser/cookies/browser-cookie-import-validated-partition.electron.test.ts
|
||||
1 test-timeout-needs-reason:src/main/browser/guest-lifecycle/find/browser-find-frame-traversal.electron.test.ts
|
||||
1 test-timeout-needs-reason:src/main/browser/identity/wire-probes/browser-session-ua-wire-identity-cross-context.electron.test.ts
|
||||
1 test-timeout-needs-reason:src/main/browser/identity/wire-probes/browser-session-ua-wire-identity.electron.test.ts
|
||||
2 test-timeout-needs-reason:src/main/codex/app-server/codex-app-server-teardown.integration.test.ts
|
||||
1 test-timeout-needs-reason:src/main/codex/hooks/installation/hook-service-legacy-cleanup.test.ts
|
||||
|
||||
@@ -317,10 +317,10 @@
|
||||
1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts
|
||||
3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts
|
||||
3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts
|
||||
1 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts
|
||||
1 src/renderer/src/store/terminals/terminal-tab-appearance.ts
|
||||
1 src/renderer/src/store/terminals/terminal-tab-close-providers.ts
|
||||
1 src/renderer/src/store/terminals/terminal-tab-retirement.ts
|
||||
1 src/renderer/src/store/terminals/tabs/creation/terminal-active-workspace-creation.ts
|
||||
1 src/renderer/src/store/terminals/tabs/presentation/terminal-tab-appearance.ts
|
||||
1 src/renderer/src/store/terminals/retirement/terminal-tab-close-providers.ts
|
||||
1 src/renderer/src/store/terminals/retirement/terminal-tab-retirement.ts
|
||||
1 src/renderer/src/web/preload-api/web-app-api.ts
|
||||
3 src/renderer/src/web/preload-api/web-preference-normalization.ts
|
||||
20 src/renderer/src/web/preload-api/web-preferences-store.ts
|
||||
|
||||
@@ -23,7 +23,7 @@ src/renderer/src/components/terminal-pane/pty-transport/legacy-relay/legacy-rela
|
||||
src/renderer/src/components/terminal-pane/pty-connection/output/foreground-output-refresh.ts
|
||||
src/renderer/src/components/terminal-pane/pty-connection/host-ownership/unserved-ssh-pane-connect.ts
|
||||
src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts
|
||||
src/renderer/src/store/terminals/legacy-relay-bridged-tab-close.ts
|
||||
src/renderer/src/store/terminals/retirement/legacy-relay-bridged-tab-close.ts
|
||||
src/shared/legacy-relay-bridge-contract.ts
|
||||
src/shared/ssh-types.ts
|
||||
src/shared/telemetry-ssh-host-server-event-schemas.ts
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
5 src/main/daemon/admission/host-terminal-ledger.ts
|
||||
1 src/main/daemon/admission/legacy-restore-records.ts
|
||||
1 src/main/daemon/daemon-entry.ts
|
||||
1 src/main/daemon/daemon-process-name.ts
|
||||
1 src/main/daemon/endpoint/identity/daemon-endpoint-incarnation.ts
|
||||
3 src/main/daemon/endpoint/identity/daemon-pid-identity.ts
|
||||
2 src/main/daemon/endpoint/identity/daemon-pid-record-quarantine.ts
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -101,23 +101,23 @@
|
||||
5 src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts
|
||||
2 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts
|
||||
6 src/renderer/src/store/terminals/create-terminal-slice.ts
|
||||
5 src/renderer/src/store/terminals/terminal-layout-state.ts
|
||||
6 src/renderer/src/store/terminals/terminal-orphan-cleanup.ts
|
||||
2 src/renderer/src/store/terminals/terminal-pane-hibernation.ts
|
||||
3 src/renderer/src/store/terminals/terminal-pty-bindings.ts
|
||||
2 src/renderer/src/store/terminals/terminal-pty-release.ts
|
||||
1 src/renderer/src/store/terminals/terminal-shutdown-guards.ts
|
||||
3 src/renderer/src/store/terminals/terminal-shutdown-state.ts
|
||||
1 src/renderer/src/store/terminals/terminal-tab-agent-launch-pane.ts
|
||||
2 src/renderer/src/store/terminals/terminal-tab-appearance.ts
|
||||
6 src/renderer/src/store/terminals/terminal-tab-close.ts
|
||||
10 src/renderer/src/store/terminals/terminal-tab-creation.ts
|
||||
7 src/renderer/src/store/terminals/terminal-tab-navigation.ts
|
||||
3 src/renderer/src/store/terminals/terminal-tab-presentation.ts
|
||||
2 src/renderer/src/store/terminals/terminal-tab-title-batch.ts
|
||||
5 src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts
|
||||
2 src/renderer/src/store/terminals/workspace-terminal-hydration.ts
|
||||
2 src/renderer/src/store/terminals/workspace-terminal-reconnect.ts
|
||||
5 src/renderer/src/store/terminals/layout/terminal-layout-state.ts
|
||||
6 src/renderer/src/store/terminals/retirement/terminal-orphan-cleanup.ts
|
||||
2 src/renderer/src/store/terminals/sleep/terminal-pane-hibernation.ts
|
||||
3 src/renderer/src/store/terminals/pty/terminal-pty-bindings.ts
|
||||
2 src/renderer/src/store/terminals/pty/terminal-pty-release.ts
|
||||
1 src/renderer/src/store/terminals/sleep/terminal-shutdown-guards.ts
|
||||
3 src/renderer/src/store/terminals/sleep/terminal-shutdown-state.ts
|
||||
1 src/renderer/src/store/terminals/tabs/creation/terminal-tab-agent-launch-pane.ts
|
||||
2 src/renderer/src/store/terminals/tabs/presentation/terminal-tab-appearance.ts
|
||||
6 src/renderer/src/store/terminals/retirement/terminal-tab-close.ts
|
||||
10 src/renderer/src/store/terminals/tabs/creation/terminal-tab-creation.ts
|
||||
7 src/renderer/src/store/terminals/tabs/terminal-tab-navigation.ts
|
||||
3 src/renderer/src/store/terminals/tabs/presentation/terminal-tab-presentation.ts
|
||||
2 src/renderer/src/store/terminals/tabs/presentation/terminal-tab-title-batch.ts
|
||||
5 src/renderer/src/store/terminals/hydration/workspace-terminal-hydration-patch.ts
|
||||
2 src/renderer/src/store/terminals/hydration/workspace-terminal-hydration.ts
|
||||
2 src/renderer/src/store/terminals/hydration/workspace-terminal-reconnect.ts
|
||||
7 src/renderer/src/store/workspace-layout/frames/workspace-layout-frame-held-tabs.ts
|
||||
4 src/renderer/src/store/workspace-layout/frames/workspace-layout-frame-slices.ts
|
||||
12 src/renderer/src/store/workspace-layout/frames/workspace-layout-frame-store-patch.ts
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ const { spawn, spawnSync } = require('node:child_process')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { connect } = require('node:net')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, relative } = require('node:path')
|
||||
const { basename, join, relative } = require('node:path')
|
||||
const { setTimeout: delay } = require('node:timers/promises')
|
||||
const {
|
||||
macTerminalHostPaths
|
||||
@@ -267,7 +267,7 @@ async function verifyPackagedMacTerminalHostBoots(appPath, { arch, required }) {
|
||||
const stderr = []
|
||||
// Boot clone outside build tree so ancestor node_modules cannot satisfy missing packaged dependency.
|
||||
const isolated = mkdtempSync(join(tmpdir(), 'orca-terminal-host-boot-'))
|
||||
const clone = join(isolated, 'Orca Terminal Host.app')
|
||||
const clone = join(isolated, basename(paths.bundle))
|
||||
try {
|
||||
const cloned = spawnSync('/bin/cp', ['-cR', paths.bundle, clone], { encoding: 'utf8' })
|
||||
if (cloned.status !== 0) {
|
||||
|
||||
@@ -15,8 +15,8 @@ const { builtinModules } = require('node:module')
|
||||
const { basename, dirname, join, relative, resolve, sep } = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const MAC_TERMINAL_HOST_NAME = 'Orca Terminal Host'
|
||||
const MAC_TERMINAL_HOST_EXECUTABLE = 'orca-terminal-host'
|
||||
const MAC_TERMINAL_HOST_NAME = 'Orca Agents'
|
||||
const MAC_TERMINAL_HOST_EXECUTABLE = 'Orca Agents'
|
||||
const macTerminalHostSignIgnore = [`/Contents/Helpers/${MAC_TERMINAL_HOST_NAME}\\.app(/|$)`]
|
||||
const NODE_PTY_FILES = [
|
||||
'package.json',
|
||||
@@ -194,8 +194,8 @@ function buildMacTerminalHostInfo({ appInfo, usageDescriptions, minimumOs }) {
|
||||
return {
|
||||
...usageDescriptions,
|
||||
CFBundleIdentifier: appInfo.CFBundleIdentifier,
|
||||
CFBundleName: 'Orca',
|
||||
CFBundleDisplayName: 'Orca',
|
||||
CFBundleName: MAC_TERMINAL_HOST_NAME,
|
||||
CFBundleDisplayName: MAC_TERMINAL_HOST_NAME,
|
||||
CFBundleExecutable: MAC_TERMINAL_HOST_EXECUTABLE,
|
||||
CFBundlePackageType: 'APPL',
|
||||
CFBundleInfoDictionaryVersion: '6.0',
|
||||
@@ -210,6 +210,15 @@ function buildMacTerminalHostInfo({ appInfo, usageDescriptions, minimumOs }) {
|
||||
|
||||
function findMacTerminalHostInfoProblems(info, { appId, usageDescriptions, minimumOs }) {
|
||||
const problems = []
|
||||
for (const [key, expected] of Object.entries({
|
||||
CFBundleName: MAC_TERMINAL_HOST_NAME,
|
||||
CFBundleDisplayName: MAC_TERMINAL_HOST_NAME,
|
||||
CFBundleExecutable: MAC_TERMINAL_HOST_EXECUTABLE
|
||||
})) {
|
||||
if (info[key] !== expected) {
|
||||
problems.push(`${key} is ${info[key]}, expected ${expected}`)
|
||||
}
|
||||
}
|
||||
if (info.CFBundleIdentifier !== appId) {
|
||||
problems.push(`CFBundleIdentifier is ${info.CFBundleIdentifier}, expected ${appId}`)
|
||||
}
|
||||
|
||||
+7
-7
@@ -96,13 +96,13 @@ export const WRITERS = [
|
||||
'src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts',
|
||||
'src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts',
|
||||
'src/renderer/src/store/slices/worktrees/teardown/record-key-omission.ts',
|
||||
'src/renderer/src/store/terminals/terminal-tab-close.ts',
|
||||
'src/renderer/src/store/terminals/terminal-tab-creation.ts',
|
||||
'src/renderer/src/store/terminals/terminal-tab-navigation.ts',
|
||||
'src/renderer/src/store/terminals/terminal-tab-title-batch.ts',
|
||||
'src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts',
|
||||
'src/renderer/src/store/terminals/workspace-terminal-hydration.ts',
|
||||
'src/renderer/src/store/terminals/workspace-terminal-reconnect.ts',
|
||||
'src/renderer/src/store/terminals/retirement/terminal-tab-close.ts',
|
||||
'src/renderer/src/store/terminals/tabs/creation/terminal-tab-creation.ts',
|
||||
'src/renderer/src/store/terminals/tabs/terminal-tab-navigation.ts',
|
||||
'src/renderer/src/store/terminals/tabs/presentation/terminal-tab-title-batch.ts',
|
||||
'src/renderer/src/store/terminals/hydration/workspace-terminal-hydration-patch.ts',
|
||||
'src/renderer/src/store/terminals/hydration/workspace-terminal-hydration.ts',
|
||||
'src/renderer/src/store/terminals/hydration/workspace-terminal-reconnect.ts',
|
||||
'src/renderer/src/store/workspace-layout/frames/workspace-layout-frame-store-patch.ts'
|
||||
]
|
||||
export const SEAM_FIELDS = new Set([
|
||||
|
||||
+24
-20
@@ -41,28 +41,32 @@ const HOT_MAPS = new Set([
|
||||
])
|
||||
const OWNER_ROOTS = [
|
||||
{
|
||||
rel: 'store/terminals/terminal-parking-input-owner.ts',
|
||||
rel: 'store/terminals/parking/terminal-parking-input-owner.ts',
|
||||
name: 'acceptTerminalParkingInputs',
|
||||
inputs: []
|
||||
},
|
||||
{
|
||||
rel: 'store/terminals/terminal-parking-input-owner.ts',
|
||||
rel: 'store/terminals/parking/terminal-parking-input-owner.ts',
|
||||
name: 'publishAcceptedTerminalParkingInputs',
|
||||
inputs: []
|
||||
},
|
||||
{
|
||||
rel: 'store/terminals/terminal-parking-input-owner.ts',
|
||||
rel: 'store/terminals/parking/terminal-parking-input-owner.ts',
|
||||
name: 'invalidateTerminalParkingPtyInputs',
|
||||
inputs: []
|
||||
},
|
||||
{
|
||||
rel: 'store/terminals/terminal-tab-owner-index.ts',
|
||||
rel: 'store/terminals/ownership/terminal-tab-owner-index.ts',
|
||||
name: 'update',
|
||||
inputs: ['tabsByWorktree']
|
||||
},
|
||||
{ rel: 'store/terminals/sleeping-agent-record-index.ts', name: 'update', inputs: ['records'] },
|
||||
{
|
||||
rel: 'store/terminals/sleeping-agent-record-index.ts',
|
||||
rel: 'store/terminals/sleep/sleeping-agent-record-index.ts',
|
||||
name: 'update',
|
||||
inputs: ['records']
|
||||
},
|
||||
{
|
||||
rel: 'store/terminals/sleep/sleeping-agent-record-index.ts',
|
||||
name: 'acceptTransition',
|
||||
inputs: ['previous', 'next']
|
||||
},
|
||||
@@ -73,14 +77,14 @@ const OWNER_ROOTS = [
|
||||
}
|
||||
]
|
||||
const KEY_DIFFS = new Set([
|
||||
`${RENDERER}store/terminals/terminal-parking-input-owner.ts#diffUnknownParkingRecordKeys`,
|
||||
`${RENDERER}store/terminals/terminal-tab-owner-index.ts#diffUnknownTabOwnerKeys`,
|
||||
`${RENDERER}store/terminals/sleeping-agent-record-index.ts#diffUnknownSleepingRecordKeys`,
|
||||
`${RENDERER}store/terminals/parking/terminal-parking-input-owner.ts#diffUnknownParkingRecordKeys`,
|
||||
`${RENDERER}store/terminals/ownership/terminal-tab-owner-index.ts#diffUnknownTabOwnerKeys`,
|
||||
`${RENDERER}store/terminals/sleep/sleeping-agent-record-index.ts#diffUnknownSleepingRecordKeys`,
|
||||
`${RENDERER}lib/workspace/tabs/worktree-tab-bucket-projection.ts#diffUnknownProjectionKeys`
|
||||
])
|
||||
const COLD_RESYNCS = new Set([
|
||||
`${RENDERER}store/terminals/terminal-parking-input-owner.ts#resyncTerminalParkingWorkspaceIds`,
|
||||
`${RENDERER}store/terminals/sleeping-agent-record-index.ts#resyncSleepingAgentRecordKeys`
|
||||
`${RENDERER}store/terminals/parking/terminal-parking-input-owner.ts#resyncTerminalParkingWorkspaceIds`,
|
||||
`${RENDERER}store/terminals/sleep/sleeping-agent-record-index.ts#resyncSleepingAgentRecordKeys`
|
||||
])
|
||||
|
||||
function isColdResyncCall(node, source) {
|
||||
@@ -139,15 +143,15 @@ const ADDRESSED_FIELDS = new Set([
|
||||
'sleepingAgentSessionsByPaneKey'
|
||||
])
|
||||
const ORDINARY_WRITERS = [
|
||||
'store/terminals/terminal-layout-state.ts',
|
||||
'store/terminals/terminal-pane-hibernation.ts',
|
||||
'store/terminals/terminal-pty-bindings.ts',
|
||||
'store/terminals/terminal-pty-release.ts',
|
||||
'store/terminals/terminal-orphan-cleanup.ts',
|
||||
'store/terminals/terminal-startup-queues.ts',
|
||||
'store/terminals/terminal-tab-presentation.ts',
|
||||
'store/terminals/terminal-shutdown-state.ts',
|
||||
'store/terminals/window-tab-start-in-main.ts',
|
||||
'store/terminals/layout/terminal-layout-state.ts',
|
||||
'store/terminals/sleep/terminal-pane-hibernation.ts',
|
||||
'store/terminals/pty/terminal-pty-bindings.ts',
|
||||
'store/terminals/pty/terminal-pty-release.ts',
|
||||
'store/terminals/retirement/terminal-orphan-cleanup.ts',
|
||||
'store/terminals/tabs/creation/terminal-startup-queues.ts',
|
||||
'store/terminals/tabs/presentation/terminal-tab-presentation.ts',
|
||||
'store/terminals/sleep/terminal-shutdown-state.ts',
|
||||
'store/terminals/tabs/creation/window-tab-start-in-main.ts',
|
||||
'store/slices/agent-status/agent-status-live-reducer.ts',
|
||||
'store/slices/agent-status/agent-status-launch-actions.ts',
|
||||
'store/slices/agent-status/agent-status-provider-session-actions.ts',
|
||||
|
||||
@@ -447,7 +447,7 @@ export const PR_E2E_SOURCE_ROUTES = [
|
||||
],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:layout\/close\/retire-unbound-(?:ipc|runtime)-terminal-pane|terminal-pane-(?:close-admission|close-identity|lifecycle-close|pane-closed|retirement-ownership)|use-terminal-pane-close-actions)|src\/renderer\/src\/store\/(?:terminals\/terminal-tab-close(?:-providers)?|slices\/(?:terminal-tab-retirement|terminal-retirement-teardown-reservation|retired-terminal-tab-state-sweep)))\.ts$/.test(
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:layout\/close\/retire-unbound-(?:ipc|runtime)-terminal-pane|terminal-pane-(?:close-admission|close-identity|lifecycle-close|pane-closed|retirement-ownership)|use-terminal-pane-close-actions)|src\/renderer\/src\/store\/(?:terminals\/retirement\/(?:terminal-tab-close(?:-providers)?|terminal-tab-retirement|terminal-retirement-teardown-reservation|retired-terminal-tab-state-sweep)))\.ts$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ const LAYOUT_ORACLE_PREFIXES = [
|
||||
// Window-side layout writers the layout-holder stack replaces; widened while it is open.
|
||||
'src/renderer/src/store/slices/tabs',
|
||||
'src/renderer/src/store/terminals/',
|
||||
'src/renderer/src/components/sidebar/workspace-card/activity/worktree-terminal-record-selector.ts',
|
||||
'src/renderer/src/store/workspace-layout/workspace-layout-cache',
|
||||
'src/renderer/src/components/terminal-pane/',
|
||||
'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationMenuItem.tsx',
|
||||
|
||||
@@ -70,4 +70,4 @@
|
||||
6 src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-row-removal.ts # store writer
|
||||
8 src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts # store writer
|
||||
1 src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts # store writer
|
||||
8 src/renderer/src/store/terminals/workspace-terminal-hydration.ts # store writer
|
||||
8 src/renderer/src/store/terminals/hydration/workspace-terminal-hydration.ts # store writer
|
||||
|
||||
@@ -14,13 +14,14 @@ protected files keeps working after its old executable is deleted.
|
||||
|
||||
So a packaged macOS GUI app starts its daemon from a private copy that the updater never touches:
|
||||
|
||||
- Each packaged macOS app ships a signed helper, `Contents/Helpers/Orca Terminal Host.app` (about
|
||||
- Each packaged macOS app ships a signed helper, `Contents/Helpers/Orca Agents.app` (about
|
||||
124 MiB): the pinned Node from `src/shared/node-runtime-pin.ts` as its main executable, the
|
||||
daemon's `out/main` JS and that slice's node-pty. `config/scripts/macos-terminal-host-bundle.cjs`
|
||||
daemon's `out/main` JS and that slice's node-pty.
|
||||
`config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs`
|
||||
builds and signs it in `afterPack`, boots it through one PTY round trip, and fails the build on any
|
||||
layout, Info.plist, OS floor, architecture or signature drift.
|
||||
- `macos-daemon-bundle.ts` keeps one copy per helper build, named by the helper's CDHash, at
|
||||
`userData/daemon-host/macos/terminal-host-<cdhash>/app.noindex/Orca Terminal Host.app`. The helper
|
||||
`userData/daemon-host/macos/terminal-host-<cdhash>/app.noindex/Orca Agents.app`. The helper
|
||||
is read from the bundle the running main process executes from, never the whole app. A present
|
||||
copy is reused as is. Otherwise the helper is cloned (APFS clone, regular copy otherwise) into a
|
||||
`.staging-<pid>-*` sibling, verified once with
|
||||
@@ -55,6 +56,12 @@ Signing the helper with the app's own identifier lets it inherit every existing
|
||||
Disk Access, which a user can only add by hand. A distinct identifier would be a new client: new
|
||||
folder prompts naming it, and a new Full Disk Access row for every user.
|
||||
|
||||
The helper's bundle, executable and display name are `Orca Agents`; its identifier, signing team
|
||||
and designated requirement still match Orca.app. This names the existing terminal-agent process
|
||||
without changing its privacy client. Native chat providers still run under the GUI process. An
|
||||
adopted older daemon keeps its original name until it exits; the fork fallback and Windows names
|
||||
are unchanged.
|
||||
|
||||
## macOS floor
|
||||
|
||||
The helper's Node declares `LC_BUILD_VERSION minos 13.5`, which is also the helper's
|
||||
@@ -108,6 +115,8 @@ The helper declares no links, documents or types, has a lower `CFBundleVersion`
|
||||
`.noindex` folder, which Spotlight skips, and Orca never registers them. Starting the daemon through
|
||||
launchd leaves no record, but another process running the copy's executable directly can make
|
||||
macOS register it, so retirement runs `lsregister -u` on the bundle before deleting it.
|
||||
Retirement recognizes both `Orca Agents.app` and the legacy `Orca Terminal Host.app` inside unused
|
||||
copies, so changing the current bundle name does not leave old LaunchServices registrations behind.
|
||||
|
||||
The helper's main-executable UUID is Node's, shared by every official Node 24.21.0 binary of that
|
||||
architecture, so it is not unique to Orca. Local Network access still passed in the signed spike:
|
||||
|
||||
@@ -66,54 +66,10 @@ function fakeHost(
|
||||
},
|
||||
admit: vi.fn()
|
||||
}
|
||||
const emit = (event: BrowserPageEvent): void => pageEvents.forEach((listener) => listener(event))
|
||||
return { host, tabs, emit }
|
||||
return { host }
|
||||
}
|
||||
|
||||
describe('runWithActionEffects', () => {
|
||||
it('waits for a navigation the action started and reports where it landed', async () => {
|
||||
const guest = fakeGuest()
|
||||
const appTree = '- heading "Welcome ada" [level=1, ref=e9]\n- button "Submit ticket" [ref=e10]'
|
||||
const { host } = fakeHost(guest, [UNCHANGED, { snapshot: { kind: 'full', tree: appTree } }])
|
||||
|
||||
const result = await runWithActionEffects(host, async () => {
|
||||
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
// A redirecting submit replaces its first navigation, which then fails as ERR_ABORTED.
|
||||
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
guest.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'http://127.0.0.1/submit', true)
|
||||
setTimeout(() => {
|
||||
guest.url = 'http://127.0.0.1/app.html'
|
||||
guest.title = 'App'
|
||||
guest.emit('dom-ready')
|
||||
}, 120)
|
||||
return { clicked: '@e6' }
|
||||
})
|
||||
|
||||
expect(readBrowserActionEffects(result)).toEqual({
|
||||
settled: true,
|
||||
url: 'http://127.0.0.1/app.html',
|
||||
title: 'App',
|
||||
page: { lines: appTree.split('\n'), removedLines: 0, full: true, truncated: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a new tab once it opens and a child window separately', async () => {
|
||||
const guest = fakeGuest()
|
||||
const { host, tabs, emit } = fakeHost(guest, [UNCHANGED, UNCHANGED])
|
||||
|
||||
const result = await runWithActionEffects(host, async () => {
|
||||
emit({ kind: 'popup', action: 'window', origin: 'https://accounts.example' })
|
||||
emit({ kind: 'tab', url: 'http://127.0.0.1/basic2.html' })
|
||||
setTimeout(() => tabs.set('page-2', 'http://127.0.0.1/basic2.html'), 100)
|
||||
return { clicked: '@e18' }
|
||||
})
|
||||
|
||||
expect(readBrowserActionEffects(result)).toMatchObject({
|
||||
newTabs: [{ browserPageId: 'page-2', url: 'http://127.0.0.1/basic2.html' }],
|
||||
popups: [{ action: 'window', origin: 'https://accounts.example' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not read a replacement guest the caller was never admitted to', async () => {
|
||||
const guest = fakeGuest()
|
||||
const replacement = Object.assign(fakeGuest('http://127.0.0.1/app.html', 'App'), { id: 8 })
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
import { guestNavigationMethods } from '../../guest-lifecycle/manager/browser-manager-test-harness'
|
||||
import type * as WorktreeScanCacheModule from '../../../git/worktrees/catalog/worktree-scan-cache'
|
||||
import type * as WorktreeListingModule from '../../../git/worktrees/catalog/worktree-listing'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { RuntimeBrowserCommands } from '../../../runtime/browser/commands/browser-commands'
|
||||
import { setRuntimeBrowserCommandsFactory } from '../../../runtime/browser/installation/runtime-browser-commands-factory'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createConnection } from 'node:net'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BrowserManager } from '../../guest-lifecycle/manager/browser-manager'
|
||||
import { CdpBridge } from './cdp-bridge'
|
||||
import { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import { OrcaRuntimeRpcServer } from '../../../runtime/rpc/server/runtime-rpc'
|
||||
import { readRuntimeMetadata } from '../../../runtime/rpc/server/runtime-metadata'
|
||||
|
||||
const { webContentsFromIdMock } = vi.hoisted(() => ({
|
||||
webContentsFromIdMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../git/worktrees/catalog/worktree-scan-cache', async (importOriginal) => {
|
||||
const facade = getWorktreeListingMocks()
|
||||
return {
|
||||
...(await importOriginal<typeof WorktreeScanCacheModule>()),
|
||||
listWorktrees: facade.listWorktrees
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../git/worktrees/catalog/worktree-listing', async (importOriginal) => {
|
||||
const facade = getWorktreeListingMocks()
|
||||
return {
|
||||
...(await importOriginal<typeof WorktreeListingModule>()),
|
||||
listWorktreesStrict: facade.listWorktreesStrict
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
webContents: { fromId: webContentsFromIdMock },
|
||||
shell: { openExternal: vi.fn() },
|
||||
ipcMain: { handle: vi.fn(), removeHandler: vi.fn(), on: vi.fn() },
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
|
||||
}))
|
||||
|
||||
const { getWorktreeListingMocks } = vi.hoisted(() => {
|
||||
const createWorktreeListingMocks = () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([]),
|
||||
listWorktreesStrict: vi.fn().mockResolvedValue([])
|
||||
})
|
||||
let listings: ReturnType<typeof createWorktreeListingMocks> | undefined
|
||||
return { getWorktreeListingMocks: () => (listings ??= createWorktreeListingMocks()) }
|
||||
})
|
||||
|
||||
type AXNode = {
|
||||
nodeId: string
|
||||
backendDOMNodeId?: number
|
||||
role?: { type: string; value: string }
|
||||
name?: { type: string; value: string }
|
||||
properties?: { name: string; value: { type: string; value: unknown } }[]
|
||||
childIds?: string[]
|
||||
ignored?: boolean
|
||||
}
|
||||
|
||||
function axNode(
|
||||
id: string,
|
||||
role: string,
|
||||
name: string,
|
||||
opts?: { childIds?: string[]; backendDOMNodeId?: number }
|
||||
): AXNode {
|
||||
return {
|
||||
nodeId: id,
|
||||
backendDOMNodeId: opts?.backendDOMNodeId ?? Number.parseInt(id, 10) * 100,
|
||||
role: { type: 'role', value: role },
|
||||
name: { type: 'computedString', value: name },
|
||||
childIds: opts?.childIds
|
||||
}
|
||||
}
|
||||
|
||||
const EXAMPLE_COM_TREE: AXNode[] = [
|
||||
axNode('1', 'WebArea', 'Example Domain', { childIds: ['2', '3', '4'] }),
|
||||
axNode('2', 'heading', 'Example Domain'),
|
||||
axNode('3', 'staticText', 'This domain is for use in illustrative examples.'),
|
||||
axNode('4', 'link', 'More information...', { backendDOMNodeId: 400 })
|
||||
]
|
||||
|
||||
const SEARCH_PAGE_TREE: AXNode[] = [
|
||||
axNode('1', 'WebArea', 'Search', { childIds: ['2', '3', '4', '5'] }),
|
||||
axNode('2', 'navigation', 'Main Nav', { childIds: ['3'] }),
|
||||
axNode('3', 'link', 'Home', { backendDOMNodeId: 300 }),
|
||||
axNode('4', 'textbox', 'Search query', { backendDOMNodeId: 400 }),
|
||||
axNode('5', 'button', 'Search', { backendDOMNodeId: 500 })
|
||||
]
|
||||
|
||||
function createMockGuest(
|
||||
id: number,
|
||||
url: string,
|
||||
title: string,
|
||||
options?: { readyState?: string | (() => string) }
|
||||
) {
|
||||
let currentUrl = url
|
||||
let currentTitle = title
|
||||
let currentTree = EXAMPLE_COM_TREE
|
||||
let navHistoryId = 1
|
||||
let debuggerAttached = false
|
||||
|
||||
const sendCommandMock = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
switch (method) {
|
||||
case 'Page.enable':
|
||||
case 'DOM.enable':
|
||||
case 'Accessibility.enable':
|
||||
return {}
|
||||
case 'Accessibility.getFullAXTree':
|
||||
return { nodes: currentTree }
|
||||
case 'Page.getNavigationHistory':
|
||||
return {
|
||||
entries: [{ id: navHistoryId, url: currentUrl }],
|
||||
currentIndex: 0
|
||||
}
|
||||
case 'Page.navigate': {
|
||||
const targetUrl = (params as { url: string }).url
|
||||
if (targetUrl.includes('nonexistent.invalid')) {
|
||||
return { errorText: 'net::ERR_NAME_NOT_RESOLVED' }
|
||||
}
|
||||
navHistoryId++
|
||||
currentUrl = targetUrl
|
||||
if (targetUrl.includes('search.example.com')) {
|
||||
currentTitle = 'Search'
|
||||
currentTree = SEARCH_PAGE_TREE
|
||||
} else {
|
||||
currentTitle = 'Example Domain'
|
||||
currentTree = EXAMPLE_COM_TREE
|
||||
}
|
||||
return {}
|
||||
}
|
||||
case 'Runtime.evaluate': {
|
||||
const expr = (params as { expression: string }).expression
|
||||
if (expr === 'document.readyState') {
|
||||
return {
|
||||
result: {
|
||||
value:
|
||||
typeof options?.readyState === 'function'
|
||||
? options.readyState()
|
||||
: (options?.readyState ?? 'complete')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expr === 'location.origin') {
|
||||
return { result: { value: new URL(currentUrl).origin } }
|
||||
}
|
||||
if (expr.includes('innerWidth')) {
|
||||
return { result: { value: JSON.stringify({ w: 1280, h: 720 }) } }
|
||||
}
|
||||
if (expr.includes('scrollBy')) {
|
||||
return { result: { value: undefined } }
|
||||
}
|
||||
if (expr.includes('dispatchEvent')) {
|
||||
return { result: { value: undefined } }
|
||||
}
|
||||
// eslint-disable-next-line no-eval
|
||||
return { result: { value: String(eval(expr)), type: 'string' } }
|
||||
}
|
||||
case 'DOM.scrollIntoViewIfNeeded':
|
||||
return {}
|
||||
case 'DOM.getBoxModel':
|
||||
return { model: { content: [100, 200, 300, 200, 300, 250, 100, 250] } }
|
||||
case 'Input.dispatchMouseEvent':
|
||||
return {}
|
||||
case 'Input.insertText':
|
||||
return {}
|
||||
case 'Input.dispatchKeyEvent':
|
||||
return {}
|
||||
case 'DOM.focus':
|
||||
return {}
|
||||
case 'DOM.describeNode':
|
||||
return { node: { nodeId: 1 } }
|
||||
case 'DOM.requestNode':
|
||||
return { nodeId: 1 }
|
||||
case 'DOM.resolveNode':
|
||||
return { object: { objectId: 'obj-1' } }
|
||||
case 'Runtime.callFunctionOn':
|
||||
return { result: { value: undefined } }
|
||||
case 'DOM.setFileInputFiles':
|
||||
return {}
|
||||
case 'Page.captureScreenshot':
|
||||
return {
|
||||
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
}
|
||||
case 'Page.reload':
|
||||
return {}
|
||||
case 'Network.enable':
|
||||
return {}
|
||||
case 'Target.setAutoAttach':
|
||||
return {}
|
||||
case 'Page.addScriptToEvaluateOnNewDocument':
|
||||
return { identifier: 'mock-script-id' }
|
||||
case 'Runtime.enable':
|
||||
return {}
|
||||
default:
|
||||
throw new Error(`Unexpected CDP method: ${method}`)
|
||||
}
|
||||
})
|
||||
|
||||
const debuggerListeners = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
|
||||
const guest = {
|
||||
...guestNavigationMethods(),
|
||||
id,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isCurrentlyAudible: vi.fn(() => false),
|
||||
setIgnoreMenuShortcuts: vi.fn(),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
getURL: vi.fn(() => currentUrl),
|
||||
getTitle: vi.fn(() => currentTitle),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
debugger: {
|
||||
isAttached: vi.fn(() => debuggerAttached),
|
||||
attach: vi.fn(() => {
|
||||
if (debuggerAttached) {
|
||||
throw new Error('Another debugger is already attached')
|
||||
}
|
||||
debuggerAttached = true
|
||||
}),
|
||||
detach: vi.fn(),
|
||||
sendCommand: sendCommandMock,
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = debuggerListeners.get(event) ?? []
|
||||
handlers.push(handler)
|
||||
debuggerListeners.set(event, handlers)
|
||||
}),
|
||||
removeListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = debuggerListeners.get(event) ?? []
|
||||
const idx = handlers.indexOf(handler)
|
||||
if (idx !== -1) {
|
||||
handlers.splice(idx, 1)
|
||||
}
|
||||
}),
|
||||
removeAllListeners: vi.fn((event: string) => {
|
||||
debuggerListeners.set(event, [])
|
||||
}),
|
||||
off: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
guest,
|
||||
sendCommandMock,
|
||||
setTree(tree: AXNode[]) {
|
||||
currentTree = tree
|
||||
},
|
||||
emitDebugger(event: string, ...args: unknown[]) {
|
||||
for (const handler of debuggerListeners.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
},
|
||||
emitDebuggerMessage(method: string, params?: Record<string, unknown>) {
|
||||
for (const handler of debuggerListeners.get('message') ?? []) {
|
||||
handler({}, method, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequest(
|
||||
endpoint: string,
|
||||
request: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = createConnection(endpoint)
|
||||
let buffer = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', reject)
|
||||
socket.on('data', (chunk: string) => {
|
||||
buffer += chunk
|
||||
const newlineIndex = buffer.indexOf('\n')
|
||||
if (newlineIndex === -1) {
|
||||
return
|
||||
}
|
||||
const message = buffer.slice(0, newlineIndex)
|
||||
socket.end()
|
||||
resolve(JSON.parse(message) as Record<string, unknown>)
|
||||
})
|
||||
socket.on('connect', () => {
|
||||
socket.write(`${JSON.stringify(request)}\n`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('Browser automation pipeline (integration)', () => {
|
||||
let server: OrcaRuntimeRpcServer
|
||||
let endpoint: string
|
||||
let authToken: string
|
||||
let activeGuestHarness: ReturnType<typeof createMockGuest>
|
||||
let cdpBridge: CdpBridge
|
||||
|
||||
const GUEST_WC_ID = 5001
|
||||
const RENDERER_WC_ID = 1
|
||||
|
||||
beforeEach(async () => {
|
||||
// The Electron entry installs this factory; Node hosts omit browser commands.
|
||||
setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host))
|
||||
activeGuestHarness = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain')
|
||||
const { guest } = activeGuestHarness
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === GUEST_WC_ID) {
|
||||
return guest
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const browserManager = new BrowserManager()
|
||||
// Production applies these policies in will-attach-webview.
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-1',
|
||||
webContentsId: GUEST_WC_ID,
|
||||
rendererWebContentsId: RENDERER_WC_ID
|
||||
})
|
||||
|
||||
cdpBridge = new CdpBridge(browserManager)
|
||||
cdpBridge.setActiveTab(GUEST_WC_ID)
|
||||
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'browser-e2e-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
runtime.setAgentBrowserBridge(cdpBridge as any)
|
||||
|
||||
server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)!
|
||||
endpoint = metadata.transports[0]!.endpoint
|
||||
authToken = metadata.authToken!
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
async function rpc(method: string, params?: Record<string, unknown>) {
|
||||
const response = await sendRequest(endpoint, {
|
||||
id: `req_${method}`,
|
||||
authToken,
|
||||
method,
|
||||
...(params ? { params } : {})
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
it('navigates to a URL and invalidates refs', async () => {
|
||||
await rpc('browser.snapshot')
|
||||
|
||||
const gotoRes = await rpc('browser.goto', { url: 'https://search.example.com' })
|
||||
expect(gotoRes.ok).toBe(true)
|
||||
const gotoResult = gotoRes.result as { url: string; title: string }
|
||||
expect(gotoResult.url).toBe('https://search.example.com')
|
||||
expect(gotoResult.title).toBe('Search')
|
||||
|
||||
const clickRes = await rpc('browser.click', { element: '@e1' })
|
||||
expect(clickRes.ok).toBe(false)
|
||||
expect((clickRes.error as { code: string }).code).toBe('browser_stale_ref')
|
||||
|
||||
const snapRes = await rpc('browser.snapshot')
|
||||
expect(snapRes.ok).toBe(true)
|
||||
const snapResult = snapRes.result as { snapshot: string; refs: { name: string }[] }
|
||||
expect(snapResult.snapshot).toContain('Search')
|
||||
expect(snapResult.refs.map((r) => r.name)).toContain('Search')
|
||||
expect(snapResult.refs.map((r) => r.name)).toContain('Home')
|
||||
})
|
||||
})
|
||||
@@ -7,17 +7,6 @@ describe('CdpPdfStreamStore', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('claims only its own handles', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('pdf'))
|
||||
|
||||
expect(store.ownsHandle({ handle })).toBe(true)
|
||||
expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/)
|
||||
expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false)
|
||||
expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false)
|
||||
expect(store.ownsHandle({})).toBe(false)
|
||||
})
|
||||
|
||||
it('reads sequential chunks and reports eof', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abcdef'))
|
||||
@@ -32,23 +21,6 @@ describe('CdpPdfStreamStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('honors an explicit read offset', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abcdef'))
|
||||
|
||||
expect(store.read({ handle, offset: 4 })).toEqual({
|
||||
data: Buffer.from('ef').toString('base64'),
|
||||
eof: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty eof chunk when reading past the end', () => {
|
||||
const store = new CdpPdfStreamStore()
|
||||
const handle = store.create(Buffer.from('abc'))
|
||||
|
||||
expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true })
|
||||
})
|
||||
|
||||
it('evicts an abandoned stream after the TTL', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = new CdpPdfStreamStore()
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { splitBrowserTextInsertionChunks } from '../../../../shared/browser-text-insertion-chunks'
|
||||
|
||||
describe('browser text insertion chunking', () => {
|
||||
it('keeps a single multibyte character intact when the byte cap is smaller', () => {
|
||||
const chunks = splitBrowserTextInsertionChunks('😀a', 1)
|
||||
|
||||
expect(chunks).toEqual(['😀', 'a'])
|
||||
expect(chunks.join('')).toBe('😀a')
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startBrowserScreencast } from './browser-screencast-stream'
|
||||
import type { BrowserScreencastOptions } from './browser-screencast-stream-types'
|
||||
import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double'
|
||||
|
||||
function start(
|
||||
webContents: ReturnType<typeof createMockScreencastWebContents>,
|
||||
options: BrowserScreencastOptions
|
||||
) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream touches only the debugger, isDestroyed and capturePage the doubles provide.
|
||||
return startBrowserScreencast(webContents as never, options)
|
||||
}
|
||||
|
||||
function startOptions(viewport: boolean) {
|
||||
return {
|
||||
format: 'jpeg' as const,
|
||||
quality: 70,
|
||||
maxWidth: 1440,
|
||||
maxHeight: 1200,
|
||||
everyNthFrame: 2,
|
||||
minFrameIntervalMs: 0,
|
||||
holdPaint: () => () => {},
|
||||
...(viewport ? { viewportWidth: 390, viewportHeight: 844 } : {}),
|
||||
onFrame: vi.fn(() => true),
|
||||
onError: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser screencast first-frame deadline', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('drops a capture that settles after the limit', async () => {
|
||||
let settleCapture!: (image: unknown) => void
|
||||
const webContents = Object.assign(createMockScreencastWebContents(), {
|
||||
capturePage: vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settleCapture = resolve
|
||||
})
|
||||
)
|
||||
})
|
||||
const options = startOptions(true)
|
||||
const session = await start(webContents, options)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
settleCapture({
|
||||
getSize: () => ({ width: 390, height: 844 }),
|
||||
toJPEG: () => Buffer.from('late'),
|
||||
toPNG: () => Buffer.from('late')
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(options.onFrame).not.toHaveBeenCalled()
|
||||
session.stop()
|
||||
await session.done
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { decodeBrowserScreencastFrame } from '../../../../shared/browser/screencast/browser-screencast-protocol'
|
||||
|
||||
import { startBrowserScreencast } from './browser-screencast-stream'
|
||||
import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double'
|
||||
|
||||
@@ -76,55 +74,6 @@ async function startOversizedStream(guest: ReturnType<typeof createGuest>, onFra
|
||||
}
|
||||
|
||||
describe('screencast of a guest smaller than the requested viewport (#30341)', () => {
|
||||
it('re-emulates at the size the guest can paint and labels frames with it', async () => {
|
||||
const guest = createGuest('webview')
|
||||
const onFrame = vi.fn()
|
||||
const session = await startOversizedStream(guest, onFrame)
|
||||
await vi.waitFor(() =>
|
||||
expect(guest.debugger.sendCommand).toHaveBeenCalledWith(
|
||||
'Page.captureScreenshot',
|
||||
expect.anything()
|
||||
)
|
||||
)
|
||||
expect(emulatedSizes(guest)).toEqual(['1833x1179'])
|
||||
|
||||
emitGuestFrame(guest, 1, { width: 1463, height: 849 })
|
||||
await vi.waitFor(() => expect(emulatedSizes(guest)).toEqual(['1833x1179', '1463x849']))
|
||||
expect(guest.debugger.sendCommand).toHaveBeenCalledWith('Page.screencastFrameAck', {
|
||||
sessionId: 1
|
||||
})
|
||||
expect(onFrame).not.toHaveBeenCalled()
|
||||
|
||||
emitGuestFrame(guest, 2, { width: 1463, height: 849 })
|
||||
await vi.waitFor(() => expect(onFrame).toHaveBeenCalledTimes(1))
|
||||
expect(decodeBrowserScreencastFrame(onFrame.mock.calls[0][0])?.metadata).toMatchObject({
|
||||
deviceWidth: 1463,
|
||||
deviceHeight: 849
|
||||
})
|
||||
|
||||
session.stop()
|
||||
await session.done
|
||||
})
|
||||
|
||||
it('gives the requested size back once the guest grows, and follows a new request', async () => {
|
||||
const guest = createGuest('webview')
|
||||
const session = await startOversizedStream(guest)
|
||||
emitGuestFrame(guest, 1, { width: 1463, height: 849 })
|
||||
await vi.waitFor(() => expect(emulatedSizes(guest)).toHaveLength(2))
|
||||
|
||||
emitGuestFrame(guest, 2, { width: 2000, height: 1300 })
|
||||
await vi.waitFor(() => expect(emulatedSizes(guest).at(-1)).toBe('1833x1179'))
|
||||
|
||||
await session.updateViewport({ viewportWidth: 1028, viewportHeight: 1179 })
|
||||
expect(emulatedSizes(guest).at(-1)).toBe('1028x1179')
|
||||
|
||||
emitGuestFrame(guest, 3, { width: 1463, height: 849 })
|
||||
await vi.waitFor(() => expect(emulatedSizes(guest).at(-1)).toBe('1028x849'))
|
||||
|
||||
session.stop()
|
||||
await session.done
|
||||
})
|
||||
|
||||
it('keeps dropping guest frames until the fitted size is actually emulated', async () => {
|
||||
const guest = createGuest('webview')
|
||||
let releaseSnapshot!: () => void
|
||||
@@ -183,20 +132,4 @@ describe('screencast of a guest smaller than the requested viewport (#30341)', (
|
||||
|
||||
session.stop()
|
||||
})
|
||||
|
||||
it('keeps the full request for a window guest, which can grow to it', async () => {
|
||||
const guest = createGuest('window')
|
||||
const session = await startOversizedStream(guest)
|
||||
|
||||
emitGuestFrame(guest, 1, { width: 1463, height: 849 })
|
||||
await vi.waitFor(() =>
|
||||
expect(guest.debugger.sendCommand).toHaveBeenCalledWith('Page.screencastFrameAck', {
|
||||
sessionId: 1
|
||||
})
|
||||
)
|
||||
expect(emulatedSizes(guest)).toEqual(['1833x1179'])
|
||||
|
||||
session.stop()
|
||||
await session.done
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,15 +142,6 @@ describe('BrowserCertificateTrustController', () => {
|
||||
controller.installSessionRequestGuard(browserSession)
|
||||
})
|
||||
|
||||
it('keeps challenge ownership and expiry when reading a specific guest', () => {
|
||||
certificateEvent({ controller, guest })
|
||||
expect(controller.hasFailureForGuest(guest.id)).toBe(true)
|
||||
expect(controller.hasFailureForGuest(otherGuest.id)).toBe(false)
|
||||
now += 300_000
|
||||
expect(controller.hasFailureForGuest(guest.id)).toBe(false)
|
||||
expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: false, reason: 'missing' })
|
||||
})
|
||||
|
||||
it('blocks a session-cached certificate for sibling requests until that guest approves it', () => {
|
||||
certificateEvent({ controller, guest })
|
||||
expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true })
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createPageInitiatedTabBudget,
|
||||
MAX_PAGE_INITIATED_TABS_PER_WINDOW,
|
||||
PAGE_INITIATED_TAB_WINDOW_MS
|
||||
} from './browser-page-initiated-tab-budget'
|
||||
import { createPageInitiatedTabBudget } from './browser-page-initiated-tab-budget'
|
||||
|
||||
describe('page-initiated tab budget', () => {
|
||||
it('refills once the rolling window has passed, so real browsing is unaffected', () => {
|
||||
const budget = createPageInitiatedTabBudget()
|
||||
for (let i = 0; i < MAX_PAGE_INITIATED_TABS_PER_WINDOW; i++) {
|
||||
expect(budget.tryConsume(1_000)).toBe(true)
|
||||
}
|
||||
expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS - 1)).toBe(false)
|
||||
expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS)).toBe(true)
|
||||
})
|
||||
|
||||
it('slides rather than resetting, so a paced flood cannot outrun the cap', () => {
|
||||
const budget = createPageInitiatedTabBudget(2, 1_000)
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolvePagePromptTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../manager/browser-manager', () => ({
|
||||
browserManager: {
|
||||
resolvePagePromptTarget: mocks.resolvePagePromptTarget
|
||||
}
|
||||
}))
|
||||
|
||||
import type { BrowserPagePromptRequest } from '../../../../shared/browser/prompts/browser-page-prompt'
|
||||
import { handleBrowserGuestLogin } from './browser-http-auth-prompt'
|
||||
import { BROWSER_PAGE_PROMPT_TIMEOUT_MS, respondToBrowserPagePrompt } from './browser-page-prompts'
|
||||
|
||||
type MockGuest = Electron.WebContents & EventEmitter
|
||||
|
||||
const guests: MockGuest[] = []
|
||||
|
||||
function mockGuest(pageUrl = 'https://staging.acme.com/'): MockGuest {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prompt only reads id, isDestroyed, getURL and the input, navigation and destroyed events.
|
||||
const guest = new EventEmitter() as MockGuest
|
||||
Object.assign(guest, {
|
||||
id: 100 + guests.length,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => pageUrl
|
||||
})
|
||||
guests.push(guest)
|
||||
return guest
|
||||
}
|
||||
|
||||
function mockRenderer(id: number): Electron.WebContents {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: prompts only read id, isDestroyed, send and subscribe to the window's lifecycle events.
|
||||
return {
|
||||
id,
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
} as unknown as Electron.WebContents
|
||||
}
|
||||
|
||||
function showTabIn(renderer: Electron.WebContents, browserPageId = 'page-1'): void {
|
||||
mocks.resolvePagePromptTarget.mockReturnValue({ browserPageId, renderer })
|
||||
}
|
||||
|
||||
function challenge(
|
||||
guest: MockGuest,
|
||||
{
|
||||
url = 'https://staging.acme.com/',
|
||||
isRequestForNavigation = true,
|
||||
firstAuthAttempt = true,
|
||||
isProxy = false,
|
||||
realm = 'Staging'
|
||||
} = {}
|
||||
) {
|
||||
const event = { preventDefault: vi.fn() }
|
||||
const callback = vi.fn()
|
||||
handleBrowserGuestLogin(
|
||||
event,
|
||||
guest,
|
||||
{ url, pid: 1, isRequestForNavigation, firstAuthAttempt },
|
||||
{ isProxy, scheme: 'basic', host: new URL(url).hostname, port: 443, realm },
|
||||
callback
|
||||
)
|
||||
return { asked: event.preventDefault.mock.calls.length > 0, callback }
|
||||
}
|
||||
|
||||
function sentRequests(renderer: Electron.WebContents): BrowserPagePromptRequest<'http-auth'>[] {
|
||||
return vi
|
||||
.mocked(renderer.send)
|
||||
.mock.calls.filter(([channel]) => channel === 'browser:page-prompt-requested')
|
||||
.map(([, request]) => request)
|
||||
}
|
||||
|
||||
const answer = (renderer: Electron.WebContents, requestId: string, value: unknown) =>
|
||||
respondToBrowserPagePrompt(renderer, { requestId, answer: value })
|
||||
|
||||
const CREDENTIALS = { username: 'qa', password: 'hunter2' }
|
||||
|
||||
describe('browser HTTP auth prompt', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.resolvePagePromptTarget.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.advanceTimersByTime(BROWSER_PAGE_PROMPT_TIMEOUT_MS)
|
||||
for (const guest of guests.splice(0)) {
|
||||
guest.emit('destroyed')
|
||||
}
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a proxy challenge', { isProxy: true }],
|
||||
[
|
||||
'an image from another site',
|
||||
{ url: 'https://cdn.other.com/a.png', isRequestForNavigation: false }
|
||||
]
|
||||
])('leaves %s to Electron, which cancels it', (_name, options) => {
|
||||
const renderer = mockRenderer(10)
|
||||
showTabIn(renderer)
|
||||
|
||||
const { asked } = challenge(mockGuest(), options)
|
||||
|
||||
expect(asked).toBe(false)
|
||||
expect(sentRequests(renderer)).toEqual([])
|
||||
})
|
||||
|
||||
it('shares one prompt per realm and cancels a different challenge meanwhile', async () => {
|
||||
const renderer = mockRenderer(10)
|
||||
showTabIn(renderer)
|
||||
const guest = mockGuest()
|
||||
|
||||
const first = challenge(guest)
|
||||
const sameRealm = challenge(guest, {
|
||||
url: 'https://staging.acme.com/logo.png',
|
||||
isRequestForNavigation: false
|
||||
})
|
||||
const otherRealm = challenge(guest, { realm: 'Admin' })
|
||||
|
||||
expect([first.asked, sameRealm.asked, otherRealm.asked]).toEqual([true, true, false])
|
||||
const [request] = sentRequests(renderer)
|
||||
expect(sentRequests(renderer)).toHaveLength(1)
|
||||
await answer(renderer, request.requestId, CREDENTIALS)
|
||||
expect(first.callback).toHaveBeenCalledWith('qa', 'hunter2')
|
||||
expect(sameRealm.callback).toHaveBeenCalledWith('qa', 'hunter2')
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getBrowserJavaScriptDialog,
|
||||
installBrowserJavaScriptDialogs
|
||||
} from './browser-javascript-dialogs'
|
||||
import { respondToBrowserPagePrompt } from './browser-page-prompts'
|
||||
|
||||
type MockContents = Electron.WebContents & EventEmitter & { debugger: EventEmitter }
|
||||
|
||||
@@ -58,39 +57,6 @@ function setup() {
|
||||
}
|
||||
|
||||
describe('browser JavaScript dialogs', () => {
|
||||
it('asks in the page prompt instead of Electron’s window-wide sheet', () => {
|
||||
const { guest, electronSheet, openDialog, shownPrompts } = setup()
|
||||
|
||||
const callback = openDialog('confirm', 'Delete everything?')
|
||||
|
||||
expect(electronSheet).not.toHaveBeenCalled()
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
expect(getBrowserJavaScriptDialog(guest.id)).toEqual({
|
||||
type: 'confirm',
|
||||
message: 'Delete everything?',
|
||||
defaultPromptText: ''
|
||||
})
|
||||
expect(shownPrompts()).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'javascript-dialog',
|
||||
details: {
|
||||
type: 'confirm',
|
||||
message: 'Delete everything?',
|
||||
defaultPromptText: '',
|
||||
origin: 'https://shop.example'
|
||||
}
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('names an embedded page that asks, not the site around it', () => {
|
||||
const { openDialog, shownPrompts } = setup()
|
||||
|
||||
openDialog('prompt', 'Re-enter your password', '', { origin: 'https://ads.example' })
|
||||
|
||||
expect(shownPrompts()[0]?.details).toMatchObject({ origin: 'https://ads.example' })
|
||||
})
|
||||
|
||||
it('takes an agent’s prompt answer and closes the page prompt', () => {
|
||||
const { guest, openDialog, closedPrompts } = setup()
|
||||
const callback = openDialog('prompt', 'Your name?', 'def')
|
||||
@@ -103,19 +69,6 @@ describe('browser JavaScript dialogs', () => {
|
||||
expect(answerBrowserJavaScriptDialog(guest.id, { accept: true, promptText: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('answers the page with what the user chose in the page prompt', async () => {
|
||||
const { renderer, guest, openDialog, shownPrompts } = setup()
|
||||
const callback = openDialog('confirm', 'Leave?')
|
||||
|
||||
await respondToBrowserPagePrompt(renderer, {
|
||||
requestId: shownPrompts()[0].requestId,
|
||||
answer: { accept: false, promptText: '' }
|
||||
})
|
||||
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(false, '')
|
||||
expect(getBrowserJavaScriptDialog(guest.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('turns away a second frame’s dialog and keeps the first one answerable', () => {
|
||||
const { guest, openDialog } = setup()
|
||||
const first = openDialog('confirm', 'Delete everything?')
|
||||
|
||||
@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserPagePromptRequest } from '../../../../shared/browser/prompts/browser-page-prompt'
|
||||
import {
|
||||
BROWSER_PAGE_PROMPT_TIMEOUT_MS,
|
||||
cancelBrowserPagePrompts,
|
||||
openBrowserPagePrompt,
|
||||
respondToBrowserPagePrompt,
|
||||
showBrowserPagePromptsAwaitingRegistration,
|
||||
@@ -57,13 +56,6 @@ function sentRequests(renderer: Electron.WebContents): BrowserPagePromptRequest[
|
||||
.map(([, request]) => request)
|
||||
}
|
||||
|
||||
function closedRequestIds(renderer: Electron.WebContents): string[] {
|
||||
return vi
|
||||
.mocked(renderer.send)
|
||||
.mock.calls.filter(([channel]) => channel === 'browser:page-prompt-closed')
|
||||
.map(([, event]) => event.requestId)
|
||||
}
|
||||
|
||||
const latestId = (renderer: Electron.WebContents): string =>
|
||||
sentRequests(renderer).at(-1)!.requestId
|
||||
|
||||
@@ -172,25 +164,4 @@ describe('browser page prompts', () => {
|
||||
await respondToBrowserPagePrompt(renderer, { requestId: latestId(renderer), answer: true })
|
||||
expect(ask(guest).opening).toBe('prompting')
|
||||
})
|
||||
|
||||
it('closes the prompt with a null answer when it expires or its page goes away', async () => {
|
||||
const renderer = mockRenderer(10)
|
||||
const expiring = ask(mockGuest(renderer, 'page-1'))
|
||||
const expiringId = latestId(renderer)
|
||||
const closingGuest = mockGuest(renderer, 'page-2')
|
||||
const closing = ask(closingGuest)
|
||||
const closingId = latestId(renderer)
|
||||
|
||||
cancelBrowserPagePrompts('page-2')
|
||||
vi.advanceTimersByTime(BROWSER_PAGE_PROMPT_TIMEOUT_MS)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(closedRequestIds(renderer)).toEqual([closingId, expiringId])
|
||||
expect(expiring.settle).toHaveBeenCalledWith(null)
|
||||
expect(closing.settle).toHaveBeenCalledWith(null)
|
||||
await expect(
|
||||
respondToBrowserPagePrompt(renderer, { requestId: closingId, answer: true })
|
||||
).resolves.toBe('rejected')
|
||||
expect(ask(closingGuest).opening).toBe('prompting')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getOrcaProfileBrowserSessionPartition } from '../../../../shared/orca-profiles'
|
||||
import { inspectRetiredBrowserSessionProfileUserAgentModes } from './browser-retired-profile-identity-inspection'
|
||||
|
||||
const ORCA_PROFILE_ID = 'local-default'
|
||||
const PROFILE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function profileWithMode(mode: unknown): Record<string, unknown> {
|
||||
return {
|
||||
id: PROFILE_ID,
|
||||
scope: 'isolated',
|
||||
partition: getOrcaProfileBrowserSessionPartition(ORCA_PROFILE_ID, PROFILE_ID),
|
||||
label: 'Existing',
|
||||
source: null,
|
||||
userAgentMode: mode
|
||||
}
|
||||
}
|
||||
|
||||
function unhydratableProfile(extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 'not-a-uuid',
|
||||
scope: 'isolated',
|
||||
partition: 'persist:orca-browser-session-not-a-uuid',
|
||||
label: 'Unhydratable',
|
||||
source: null,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
describe('retired browser profile identity inspection', () => {
|
||||
it('detects an inspectable old choice without removing its bytes', () => {
|
||||
const profile = profileWithMode('native')
|
||||
|
||||
expect(inspectRetiredBrowserSessionProfileUserAgentModes([profile], ORCA_PROFILE_ID)).toEqual({
|
||||
noticePending: true,
|
||||
degraded: false
|
||||
})
|
||||
expect(profile.userAgentMode).toBe('native')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ scenario: 'an unreadable mode', entry: profileWithMode('unexpected') },
|
||||
{
|
||||
scenario: 'a mode on an entry that cannot be hydrated',
|
||||
entry: unhydratableProfile({ userAgentMode: 'native' })
|
||||
}
|
||||
])('turns $scenario into a degraded notice without throwing', ({ entry }) => {
|
||||
expect(() =>
|
||||
inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID)
|
||||
).not.toThrow()
|
||||
expect(inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID)).toEqual({
|
||||
noticePending: true,
|
||||
degraded: true
|
||||
})
|
||||
})
|
||||
})
|
||||
-416
@@ -1,416 +0,0 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
import {
|
||||
BrowserSessionUaCdpCollector,
|
||||
type BrowserSessionUaCdpRequest,
|
||||
waitForBrowserCdpEndpoint
|
||||
} from './browser-session-ua-cdp-collector'
|
||||
import {
|
||||
startBrowserSessionUaWireProbeServer,
|
||||
type WireProbeJavaScriptIdentity,
|
||||
type WireProbeReceipt
|
||||
} from './browser-session-ua-wire-probe-server'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
let processIdentityModulePath: string
|
||||
let exceptionModulePath: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-wire-identity-modules-'))
|
||||
fixtureRoots.push(root)
|
||||
processIdentityModulePath = join(root, 'browser-process-user-agent.cjs')
|
||||
exceptionModulePath = join(root, 'browser-session-ua.cjs')
|
||||
await Promise.all([
|
||||
buildModule(
|
||||
'src/main/browser/identity/browser-process-user-agent.ts',
|
||||
processIdentityModulePath
|
||||
),
|
||||
buildModule('src/main/browser/identity/requests/browser-session-ua.ts', exceptionModulePath)
|
||||
])
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
type ProbeArm = 'clean' | 'fallback-disabled'
|
||||
|
||||
type ProbeResult = Readonly<{
|
||||
arm: ProbeArm
|
||||
rawUserAgent: string
|
||||
cleanUserAgent: string
|
||||
navigatorUserAgent: string
|
||||
receipts: readonly WireProbeReceipt[]
|
||||
identities: readonly WireProbeJavaScriptIdentity[]
|
||||
cdpRequests: readonly BrowserSessionUaCdpRequest[]
|
||||
cdpDiagnostics: readonly string[]
|
||||
fixtureResult: string
|
||||
fixtureStderr: string
|
||||
}>
|
||||
|
||||
describe('browser session wire identity in cross-site frames and dedicated workers', () => {
|
||||
it('keeps OOPIF, dedicated-worker, and client-hint identities clean', async () => {
|
||||
const result = await runProbe('clean')
|
||||
assertCapturedContexts(result)
|
||||
const checks = identityChecks(result)
|
||||
expect(checks.crossSiteDocument).toBe(true)
|
||||
expect(checks.crossSiteFetch).toBe(true)
|
||||
expect(checks.dedicatedWorkerScript).toBe(true)
|
||||
expect(checks.dedicatedWorkerFetch).toBe(true)
|
||||
expect(checks.clientHints, JSON.stringify(receiptForPath(result.receipts, '/'))).toBe(true)
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
async function runProbe(arm: ProbeArm): Promise<ProbeResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), `orca-wire-cross-context-${arm}-`))
|
||||
fixtureRoots.push(root)
|
||||
const server = await startBrowserSessionUaWireProbeServer()
|
||||
const resultPath = join(root, 'result.json')
|
||||
const barrierPath = join(root, 'continue')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
const cdpPort = await reservePort()
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
fixtureMain({
|
||||
arm,
|
||||
barrierPath,
|
||||
exceptionModulePath,
|
||||
httpOrigin: server.httpOrigin,
|
||||
processIdentityModulePath,
|
||||
resultPath
|
||||
})
|
||||
)
|
||||
let process: ChildProcess | null = null
|
||||
let collector: BrowserSessionUaCdpCollector | null = null
|
||||
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | null = null
|
||||
try {
|
||||
process = launchFixture(fixturePath, root, cdpPort)
|
||||
await waitForBrowserCdpEndpoint(cdpPort)
|
||||
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
|
||||
collector = await BrowserSessionUaCdpCollector.connect(cdpPort)
|
||||
await collector.installAutoAttach()
|
||||
writeFileSync(barrierPath, '')
|
||||
const processResult = await waitForProcess(process)
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(
|
||||
processResult.code,
|
||||
`${fixtureResult}\n${processResult.stderr}\n${JSON.stringify({ diagnostics: collector.diagnostics, receipts: server.receipts, identities: server.identities })}`
|
||||
).toBe(0)
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; the fixture writes this exact shape before exiting.
|
||||
const parsed = JSON.parse(fixtureResult) as Omit<
|
||||
ProbeResult,
|
||||
| 'receipts'
|
||||
| 'identities'
|
||||
| 'cdpRequests'
|
||||
| 'cdpDiagnostics'
|
||||
| 'fixtureResult'
|
||||
| 'fixtureStderr'
|
||||
>
|
||||
return {
|
||||
...parsed,
|
||||
fixtureResult,
|
||||
fixtureStderr: processResult.stderr,
|
||||
receipts: [...server.receipts],
|
||||
identities: [...server.identities],
|
||||
cdpDiagnostics: [...collector.diagnostics],
|
||||
cdpRequests: collector.snapshot().filter(({ url }) => {
|
||||
return (
|
||||
url.startsWith(server.httpOrigin) ||
|
||||
url.startsWith(server.crossSiteOrigin) ||
|
||||
url.startsWith(server.httpsOrigin)
|
||||
)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
await collector?.close().catch(() => {})
|
||||
await browser?.close().catch(() => {})
|
||||
await server.close()
|
||||
if (process && process.exitCode === null) {
|
||||
process.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildModule(entry: string, outputPath: string): Promise<void> {
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: join(process.cwd(), entry),
|
||||
formats: ['cjs'],
|
||||
fileName: () => basename(outputPath)
|
||||
},
|
||||
outDir: join(outputPath, '..'),
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function launchFixture(fixturePath: string, root: string, cdpPort: number): ChildProcess {
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const args = [
|
||||
fixturePath,
|
||||
`--user-data-dir=${join(root, 'profile')}`,
|
||||
`--remote-debugging-port=${cdpPort}`,
|
||||
'--site-per-process'
|
||||
]
|
||||
if (process.platform === 'linux') {
|
||||
args.push('--no-sandbox')
|
||||
}
|
||||
return spawn(
|
||||
process.platform === 'linux' ? 'xvfb-run' : electronBinary,
|
||||
process.platform === 'linux' ? ['--auto-servernum', electronBinary, ...args] : args,
|
||||
{
|
||||
env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function fixtureMain(options: {
|
||||
arm: ProbeArm
|
||||
barrierPath: string
|
||||
exceptionModulePath: string
|
||||
httpOrigin: string
|
||||
processIdentityModulePath: string
|
||||
resultPath: string
|
||||
}): string {
|
||||
return String.raw`
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { existsSync, writeFileSync } = require('node:fs')
|
||||
const processIdentity = require(${JSON.stringify(options.processIdentityModulePath)})
|
||||
const { cleanElectronUserAgent } = require(${JSON.stringify(options.processIdentityModulePath)})
|
||||
const arm = ${JSON.stringify(options.arm)}
|
||||
app.setName('OrcaCrossContextFixture')
|
||||
app.commandLine.appendSwitch('site-per-process')
|
||||
const rawUserAgent = app.userAgentFallback
|
||||
if (arm === 'clean') processIdentity.initializeBrowserProcessUserAgent('clean')
|
||||
const waitForBarrier = async () => {
|
||||
const deadline = Date.now() + 15000
|
||||
while (!existsSync(${JSON.stringify(options.barrierPath)})) {
|
||||
if (Date.now() >= deadline) throw new Error('startup barrier timeout')
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
}
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: 'timeout' })); app.exit(2) }, 20000)
|
||||
await app.whenReady()
|
||||
await waitForBarrier()
|
||||
const sess = session.fromPartition('persist:wire-cross-context')
|
||||
sess.setCertificateVerifyProc((_request, callback) => callback(0))
|
||||
const windows = []
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-cross-context', sandbox: true } })
|
||||
windows.push(window)
|
||||
window.webContents.setWindowOpenHandler(() => ({
|
||||
action: 'allow',
|
||||
createWindow: options => {
|
||||
const popup = new BrowserWindow({ ...options, show: false })
|
||||
windows.push(popup)
|
||||
return popup.webContents
|
||||
}
|
||||
}))
|
||||
await window.loadURL(${JSON.stringify(options.httpOrigin)} + '/?cross-context=1')
|
||||
const [navigatorUserAgent] = await Promise.all([
|
||||
window.webContents.executeJavaScript('navigator.userAgent'),
|
||||
window.webContents.executeJavaScript('window.probePromise')
|
||||
])
|
||||
// Allow isolated-frame CDP events to flush before the fixture exits.
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ arm, rawUserAgent, cleanUserAgent: cleanElectronUserAgent(rawUserAgent), navigatorUserAgent }))
|
||||
for (const candidate of windows) if (!candidate.isDestroyed()) candidate.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
run().catch(error => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: String(error?.stack || error) })); app.exit(1) })
|
||||
`
|
||||
}
|
||||
|
||||
function assertCapturedContexts(result: ProbeResult): void {
|
||||
const paths = new Set(result.receipts.map(({ path }) => path))
|
||||
for (const path of [
|
||||
'/',
|
||||
'/cross-site-frame',
|
||||
'/cross-site-frame-fetch',
|
||||
'/dedicated-worker.js',
|
||||
'/dedicated-worker-fetch',
|
||||
'/report/cross-site-frame',
|
||||
'/report/dedicated-worker'
|
||||
]) {
|
||||
expect(
|
||||
paths,
|
||||
`${result.arm} omitted ${path}\n cdp: ${JSON.stringify(result.cdpDiagnostics)}\n receipts: ${JSON.stringify(result.receipts.map((r) => r.path))}\n fixture: ${result.fixtureResult}\n stderr: ${result.fixtureStderr}`
|
||||
).toContain(path)
|
||||
}
|
||||
expect(
|
||||
result.cdpDiagnostics.some((message) => message.startsWith('attached:iframe:')),
|
||||
JSON.stringify(result.cdpDiagnostics)
|
||||
).toBe(true)
|
||||
expect(
|
||||
result.cdpDiagnostics.some((message) => message.startsWith('attached:worker:')),
|
||||
JSON.stringify(result.cdpDiagnostics)
|
||||
).toBe(true)
|
||||
expect(
|
||||
result.receipts
|
||||
.filter(({ path }) => path === '/cross-site-frame')
|
||||
.map(({ protocol }) => protocol)
|
||||
).toEqual(['https'])
|
||||
expect(
|
||||
result.receipts
|
||||
.filter(({ path }) => path === '/cross-site-frame-fetch')
|
||||
.map(({ protocol }) => protocol)
|
||||
).toEqual(['https'])
|
||||
expect(
|
||||
result.cdpRequests.some(({ url, targetType }) => {
|
||||
return new URL(url).pathname === '/cross-site-frame-fetch' && targetType === 'iframe'
|
||||
}),
|
||||
JSON.stringify(result.cdpRequests.filter(({ url }) => url.includes('cross-site-frame-fetch')))
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
function identityChecks(result: ProbeResult): Readonly<Record<string, boolean>> {
|
||||
const frameIdentity = identityForContext(result.identities, 'cross-site-frame')
|
||||
const workerIdentity = identityForContext(result.identities, 'dedicated-worker')
|
||||
const frameReceipt = receiptForPath(result.receipts, '/cross-site-frame-fetch')
|
||||
const workerScriptReceipt = receiptForPath(result.receipts, '/dedicated-worker.js')
|
||||
const workerFetchReceipt = receiptForPath(result.receipts, '/dedicated-worker-fetch')
|
||||
// Compare hints on a subsequent fetch because initial navigation may omit them.
|
||||
const rootReceipt = receiptForPath(result.receipts, '/report/document')
|
||||
return {
|
||||
crossSiteDocument: frameIdentity.userAgent === result.cleanUserAgent,
|
||||
crossSiteFetch: frameReceipt.userAgent === result.cleanUserAgent,
|
||||
dedicatedWorkerScript: workerScriptReceipt.userAgent === result.cleanUserAgent,
|
||||
dedicatedWorkerFetch:
|
||||
workerIdentity.userAgent === result.cleanUserAgent &&
|
||||
workerFetchReceipt.userAgent === result.cleanUserAgent,
|
||||
clientHints: clientHintIdentityIsClean(
|
||||
rootReceipt,
|
||||
identityForContext(result.identities, 'document'),
|
||||
result.cleanUserAgent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function clientHintIdentityIsClean(
|
||||
receipt: WireProbeReceipt,
|
||||
identity: WireProbeJavaScriptIdentity,
|
||||
cleanUserAgent: string
|
||||
): boolean {
|
||||
// Native UA-CH can remain Chromium-shaped; require a clean legacy UA on the same request.
|
||||
const secChUa = receipt.clientHints['sec-ch-ua']
|
||||
const wireBrands = parseSecChUa(secChUa)
|
||||
const navigatorBrands = readNavigatorBrands(identity.userAgentData)
|
||||
if (!secChUa || wireBrands.length === 0 || navigatorBrands.length === 0) {
|
||||
return false
|
||||
}
|
||||
const token = /electron|orca/i
|
||||
return (
|
||||
receipt.userAgent === cleanUserAgent &&
|
||||
!token.test(receipt.userAgent ?? '') &&
|
||||
!token.test(secChUa) &&
|
||||
!navigatorBrands.some(({ brand, version }) => token.test(brand) || token.test(version)) &&
|
||||
sameBrands(wireBrands, navigatorBrands)
|
||||
)
|
||||
}
|
||||
|
||||
function parseSecChUa(value: string | undefined): { brand: string; version: string }[] {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
const brands: { brand: string; version: string }[] = []
|
||||
const pattern = /"([^"]+)"\s*;\s*v="([^"]*)"/g
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
const brand = match[1]
|
||||
const version = match[2]
|
||||
if (brand !== undefined && version !== undefined) {
|
||||
brands.push({ brand, version })
|
||||
}
|
||||
}
|
||||
return brands
|
||||
}
|
||||
|
||||
function readNavigatorBrands(value: unknown): { brand: string; version: string }[] {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return []
|
||||
}
|
||||
const brandsValue = Object.entries(value).find(([key]) => key === 'brands')?.[1]
|
||||
if (!Array.isArray(brandsValue)) {
|
||||
return []
|
||||
}
|
||||
const brands: { brand: string; version: string }[] = []
|
||||
for (const entry of brandsValue) {
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
continue
|
||||
}
|
||||
const fields = Object.fromEntries(Object.entries(entry))
|
||||
const brand = fields.brand
|
||||
const version = fields.version
|
||||
if (typeof brand === 'string' && typeof version === 'string') {
|
||||
brands.push({ brand, version })
|
||||
}
|
||||
}
|
||||
return brands
|
||||
}
|
||||
|
||||
function sameBrands(
|
||||
left: readonly { brand: string; version: string }[],
|
||||
right: readonly { brand: string; version: string }[]
|
||||
): boolean {
|
||||
const normalize = (brands: readonly { brand: string; version: string }[]) =>
|
||||
brands.map(({ brand, version }) => `${brand}\u0000${version}`).sort()
|
||||
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right))
|
||||
}
|
||||
|
||||
function identityForContext(
|
||||
identities: readonly WireProbeJavaScriptIdentity[],
|
||||
context: string
|
||||
): WireProbeJavaScriptIdentity {
|
||||
const matches = identities.filter((identity) => identity.context === context)
|
||||
expect(matches, context).toHaveLength(1)
|
||||
return matches[0]!
|
||||
}
|
||||
|
||||
function receiptForPath(receipts: readonly WireProbeReceipt[], path: string): WireProbeReceipt {
|
||||
const matches = receipts.filter((receipt) => receipt.path === path)
|
||||
expect(matches, path).not.toHaveLength(0)
|
||||
return matches[0]!
|
||||
}
|
||||
|
||||
async function reservePort(): Promise<number> {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('cdp port unavailable')
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
return address.port
|
||||
}
|
||||
|
||||
function waitForProcess(process: ChildProcess): Promise<{ code: number | null; stderr: string }> {
|
||||
let stderr = ''
|
||||
process.stderr?.setEncoding('utf8')
|
||||
process.stderr?.on('data', (chunk: string) => {
|
||||
stderr += chunk
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
process.once('error', reject)
|
||||
process.once('exit', (code) => resolve({ code, stderr }))
|
||||
})
|
||||
}
|
||||
-214
@@ -1,214 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { fromPartitionMock } = vi.hoisted(() => {
|
||||
const sessionsByPartition = new Map<string, Record<string, ReturnType<typeof vi.fn>>>()
|
||||
const fromPartitionMock = vi.fn((partition: string) => {
|
||||
const existing = sessionsByPartition.get(partition)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = {
|
||||
resolveProxy: vi.fn(async () => 'DIRECT'),
|
||||
setProxy: vi.fn(async () => {}),
|
||||
closeAllConnections: vi.fn(async () => {}),
|
||||
getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/43.0.0 Orca/1.0'),
|
||||
setUserAgent: vi.fn(),
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
on: vi.fn()
|
||||
}
|
||||
sessionsByPartition.set(partition, created)
|
||||
return created
|
||||
})
|
||||
return { sessionsByPartition, fromPartitionMock }
|
||||
})
|
||||
|
||||
const identityState = vi.hoisted(() => ({ unavailable: false }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
session: {
|
||||
defaultSession: { resolveProxy: vi.fn(async () => 'DIRECT'), setProxy: vi.fn(async () => {}) },
|
||||
fromPartition: fromPartitionMock
|
||||
}
|
||||
}))
|
||||
vi.mock('../../guest-lifecycle/manager/browser-manager', () => ({
|
||||
browserManager: {
|
||||
installCertificateRequestGuard: vi.fn(),
|
||||
removeCertificateRequestGuard: vi.fn(),
|
||||
notifyPermissionDenied: vi.fn(),
|
||||
handleGuestWillDownload: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('../../site-settings/permissions/browser-media-access', () => ({
|
||||
hasSystemMediaAccess: vi.fn(() => false),
|
||||
requestSystemMediaAccess: vi.fn(async () => false)
|
||||
}))
|
||||
vi.mock('../../identity/requests/browser-session-ua', () => ({
|
||||
installBrowserSessionUserAgentPolicy: vi.fn(() => vi.fn())
|
||||
}))
|
||||
vi.mock('../../identity/browser-process-user-agent', () => ({
|
||||
getBrowserProcessUserAgentIdentity: () => {
|
||||
if (identityState.unavailable) {
|
||||
throw new Error('Browser process user agent is not initialized')
|
||||
}
|
||||
return {
|
||||
mode: 'clean',
|
||||
userAgent: 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36'
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../webauthn/browser-webauthn-access', () => ({
|
||||
allowsBrowserWebAuthnPermission: vi.fn(() => false),
|
||||
clearBrowserWebAuthnAccessHandlers: vi.fn(),
|
||||
installBrowserWebAuthnAccessHandlers: vi.fn()
|
||||
}))
|
||||
|
||||
import {
|
||||
clearBrowserSessionPartitionPolicies,
|
||||
installBrowserSessionPartitionPolicies
|
||||
} from './browser-session-partition-policies'
|
||||
|
||||
import {
|
||||
applyBrowserSessionProxies,
|
||||
setBrowserNetworkProxySettingsResolver
|
||||
} from '../proxy/browser-session-proxy'
|
||||
import { handleElectronProxyLogin } from '../../../network/electron-proxy-credentials'
|
||||
|
||||
let partitionCounter = 0
|
||||
function nextProfile() {
|
||||
partitionCounter += 1
|
||||
const partition = `persist:orca-browser-session-install-${partitionCounter}`
|
||||
return {
|
||||
id: `p${partitionCounter}`,
|
||||
scope: 'isolated' as const,
|
||||
partition,
|
||||
label: 'p',
|
||||
source: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('installBrowserSessionPartitionPolicies proxy wiring', () => {
|
||||
beforeEach(() => {
|
||||
setBrowserNetworkProxySettingsResolver(null)
|
||||
// Ignore host-shell proxy variables when testing settings.
|
||||
for (const key of [
|
||||
'HTTP_PROXY',
|
||||
'http_proxy',
|
||||
'HTTPS_PROXY',
|
||||
'https_proxy',
|
||||
'ALL_PROXY',
|
||||
'all_proxy'
|
||||
]) {
|
||||
vi.stubEnv(key, '')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setBrowserNetworkProxySettingsResolver(null)
|
||||
vi.unstubAllEnvs()
|
||||
identityState.unavailable = false
|
||||
})
|
||||
|
||||
it('reports an unavailable process identity through the promise, not a synchronous throw', async () => {
|
||||
const profile = nextProfile()
|
||||
identityState.unavailable = true
|
||||
|
||||
let installation: Promise<void> | undefined
|
||||
expect(() => {
|
||||
installation = installBrowserSessionPartitionPolicies(profile)
|
||||
}).not.toThrow()
|
||||
await expect(installation).rejects.toThrow('Browser process user agent is not initialized')
|
||||
})
|
||||
|
||||
it('does not report a new partition ready until Electron finishes applying its proxy', async () => {
|
||||
let finishWrite: (() => void) | undefined
|
||||
setBrowserNetworkProxySettingsResolver(() => ({
|
||||
httpProxyUrl: 'socks5://127.0.0.1:1080',
|
||||
httpProxyBypassRules: ''
|
||||
}))
|
||||
const profile = nextProfile()
|
||||
const sess = fromPartitionMock(profile.partition)
|
||||
sess.setProxy.mockImplementation(() => new Promise<void>((resolve) => (finishWrite = resolve)))
|
||||
|
||||
let ready = false
|
||||
const installation = installBrowserSessionPartitionPolicies(profile).then(() => (ready = true))
|
||||
await vi.waitFor(() => expect(sess.setProxy).toHaveBeenCalledTimes(1))
|
||||
expect(ready).toBe(false)
|
||||
|
||||
finishWrite?.()
|
||||
await installation
|
||||
expect(ready).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers partition readiness after one transient proxy rejection', async () => {
|
||||
setBrowserNetworkProxySettingsResolver(() => ({
|
||||
httpProxyUrl: 'http://proxy.example:8080',
|
||||
httpProxyBypassRules: ''
|
||||
}))
|
||||
const profile = nextProfile()
|
||||
const sess = fromPartitionMock(profile.partition)
|
||||
sess.setProxy.mockRejectedValueOnce(new Error('transient proxy failure'))
|
||||
|
||||
await expect(installBrowserSessionPartitionPolicies(profile)).resolves.toBeUndefined()
|
||||
|
||||
expect(sess.setProxy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('forgets failed startup credentials and restores them after recovery', async () => {
|
||||
setBrowserNetworkProxySettingsResolver(() => ({
|
||||
httpProxyUrl: 'http://alice:secret@proxy.example:8080',
|
||||
httpProxyBypassRules: ''
|
||||
}))
|
||||
const profile = nextProfile()
|
||||
const sess = fromPartitionMock(profile.partition)
|
||||
sess.closeAllConnections.mockRejectedValue(new Error('proxy settlement failed'))
|
||||
|
||||
await expect(installBrowserSessionPartitionPolicies(profile)).rejects.toThrow(
|
||||
'proxy settlement failed'
|
||||
)
|
||||
const failedCallback = vi.fn()
|
||||
handleElectronProxyLogin(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
{ session: sess } as never,
|
||||
{} as never,
|
||||
{ isProxy: true, host: 'proxy.example', port: 8080 },
|
||||
failedCallback
|
||||
)
|
||||
expect(failedCallback).not.toHaveBeenCalled()
|
||||
|
||||
sess.closeAllConnections.mockResolvedValue(undefined)
|
||||
await expect(installBrowserSessionPartitionPolicies(profile)).resolves.toBeUndefined()
|
||||
const recoveredCallback = vi.fn()
|
||||
handleElectronProxyLogin(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
{ session: sess } as never,
|
||||
{} as never,
|
||||
{ isProxy: true, host: 'proxy.example', port: 8080 },
|
||||
recoveredCallback
|
||||
)
|
||||
expect(recoveredCallback).toHaveBeenCalledWith('alice', 'secret')
|
||||
})
|
||||
|
||||
it('cancels an in-flight policy retry when partition policies are cleared', async () => {
|
||||
let proxyUrl = 'http://old.example:8080'
|
||||
let finishWrite: (() => void) | undefined
|
||||
setBrowserNetworkProxySettingsResolver(() => ({ httpProxyUrl: proxyUrl }))
|
||||
const profile = nextProfile()
|
||||
const sess = fromPartitionMock(profile.partition)
|
||||
sess.setProxy.mockImplementationOnce(
|
||||
() => new Promise<void>((resolve) => (finishWrite = resolve))
|
||||
)
|
||||
|
||||
const installation = installBrowserSessionPartitionPolicies(profile)
|
||||
await vi.waitFor(() => expect(sess.setProxy).toHaveBeenCalledTimes(1))
|
||||
clearBrowserSessionPartitionPolicies(profile.partition, sess as never)
|
||||
proxyUrl = 'http://new.example:8080'
|
||||
await applyBrowserSessionProxies([], { httpProxyUrl: proxyUrl })
|
||||
finishWrite?.()
|
||||
await installation
|
||||
|
||||
expect(sess.setProxy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
|
||||
import {
|
||||
browserPageZoomLevelToPercent,
|
||||
normalizeBrowserPageZoomLevel
|
||||
} from '../../../../shared/browser-page-zoom'
|
||||
import { browserPageZoomLevelToPercent } from '../../../../shared/browser-page-zoom'
|
||||
import { BrowserSiteDisplayController } from './browser-site-display-controller'
|
||||
import {
|
||||
configureBrowserSiteDisplayStore,
|
||||
@@ -55,12 +52,6 @@ function fakeGuest(url: string): FakeGuest {
|
||||
return guest
|
||||
}
|
||||
|
||||
function commit(controller: BrowserSiteDisplayController, guest: FakeGuest, url: string): void {
|
||||
guest.url = url
|
||||
controller.onMainFrameNavigationCommitted(guest.id)
|
||||
guest.emit('dom-ready')
|
||||
}
|
||||
|
||||
function uiStore(initial: Partial<PersistedUIState> = {}): {
|
||||
getUI: () => PersistedUIState
|
||||
onUIChanged: (listener: (ui: PersistedUIState) => void) => () => void
|
||||
@@ -89,29 +80,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('BrowserSiteDisplayController zoom', () => {
|
||||
it('keeps zoom per site and browser profile, and resets it to Default Zoom', () => {
|
||||
const controller = new BrowserSiteDisplayController()
|
||||
const ui = uiStore({ browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(0.5) })
|
||||
controller.followZoomSettings(ui)
|
||||
const github = fakeGuest('https://github.com/a')
|
||||
const githubOtherPort = fakeGuest('https://github.com:8443/b')
|
||||
const githubWork = fakeGuest('https://github.com/c')
|
||||
const docs = fakeGuest('https://docs.example.com/')
|
||||
controller.onGuestRegistered(github, null)
|
||||
controller.onGuestRegistered(githubOtherPort, 'default')
|
||||
controller.onGuestRegistered(githubWork, 'work')
|
||||
controller.onGuestRegistered(docs, null)
|
||||
|
||||
controller.zoomPage(github.id, 'in')
|
||||
|
||||
expect([github, githubOtherPort, githubWork, docs].map(percent)).toEqual([125, 125, 110, 110])
|
||||
commit(controller, docs, 'https://github.com/d')
|
||||
expect(percent(docs)).toBe(125)
|
||||
|
||||
expect(controller.zoomPage(github.id, 'reset')).toMatchObject({ siteHasZoom: false })
|
||||
expect([github, githubOtherPort, docs].map(percent)).toEqual([110, 110, 110])
|
||||
})
|
||||
|
||||
it("restores the site's zoom after Orca's UI zoom is copied onto the page", () => {
|
||||
const controller = new BrowserSiteDisplayController()
|
||||
const ui = uiStore()
|
||||
@@ -141,41 +109,6 @@ describe('BrowserSiteDisplayController zoom', () => {
|
||||
})
|
||||
|
||||
describe('BrowserSiteDisplayController color scheme', () => {
|
||||
it('uses chat media for document guests without changing zoom, and upgrades browser registration', () => {
|
||||
const controller = new BrowserSiteDisplayController()
|
||||
const preview = fakeGuest('orca-preview://grant/theme.html')
|
||||
preview.level = 1
|
||||
controller.onGuestDocumentStart(preview, {
|
||||
url: preview.url,
|
||||
profileId: null,
|
||||
colorScheme: 'dark'
|
||||
})
|
||||
controller.onMainFrameNavigationCommitted(preview.id)
|
||||
expect(preview.mediaSent).toEqual(['dark', 'dark'])
|
||||
expect(preview.level).toBe(1)
|
||||
expect(preview.listenerCount('dom-ready')).toBe(0)
|
||||
|
||||
const local = fakeGuest('file:///chat-scratch/theme.html')
|
||||
controller.onGuestDocumentStart(local, {
|
||||
url: local.url,
|
||||
profileId: 'work',
|
||||
colorScheme: 'dark'
|
||||
})
|
||||
controller.onGuestRegistered(local, 'work')
|
||||
controller.setColorScheme(local.id, 'light')
|
||||
controller.setGuestColorSchemeFallback(local, 'light')
|
||||
controller.setGuestColorSchemeFallback(local, 'dark')
|
||||
expect(local.mediaSent.at(-1)).toBe('light')
|
||||
expect(controller.getPageDisplay(local.id)?.colorScheme).toBe('light')
|
||||
controller.setColorScheme(local.id, null)
|
||||
expect(local.mediaSent.at(-1)).toBe('dark')
|
||||
controller.setGuestColorSchemeFallback(local, undefined)
|
||||
commit(controller, local, 'https://ordinary.example/')
|
||||
expect(local.mediaSent.slice(-2)).toEqual(['dark', ''])
|
||||
controller.onGuestRetired(preview.id)
|
||||
expect(preview.debugger.isAttached()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ order: 'read-first', profileId: 'work', colorScheme: 'light', saved: 'dark' },
|
||||
{ order: 'registration-first', profileId: 'work', colorScheme: 'light', saved: 'dark' }
|
||||
|
||||
@@ -20,19 +20,12 @@ import {
|
||||
BROWSER_PAGE_PROMPT_TIMEOUT_MS,
|
||||
respondToBrowserPagePrompt
|
||||
} from '../../guest-lifecycle/prompts/browser-page-prompts'
|
||||
import {
|
||||
decideBrowserSitePermissions,
|
||||
isBrowserSitePermissionAllowed
|
||||
} from './browser-site-permission-prompt'
|
||||
import { decideBrowserSitePermissions } from './browser-site-permission-prompt'
|
||||
import {
|
||||
savedBrowserSitePermissions,
|
||||
sessionBrowserSitePermissions,
|
||||
type BrowserSitePermissionMemory
|
||||
} from './browser-site-permission-memory'
|
||||
import {
|
||||
configureBrowserSitePermissionStore,
|
||||
listBrowserSitePermissions
|
||||
} from './browser-site-permission-store'
|
||||
import { configureBrowserSitePermissionStore } from './browser-site-permission-store'
|
||||
|
||||
type MockGuest = Electron.WebContents & EventEmitter
|
||||
|
||||
@@ -83,20 +76,6 @@ function ask(
|
||||
return decideBrowserSitePermissions({ requester: guest, frameOrigin, memory, permissions })
|
||||
}
|
||||
|
||||
function allowedOnCheck(
|
||||
permissions: BrowserSitePermission[],
|
||||
{
|
||||
memory = savedBrowserSitePermissions('default'),
|
||||
frameOrigin = 'https://meet.example.com'
|
||||
}: { memory?: BrowserSitePermissionMemory; frameOrigin?: string } = {}
|
||||
): boolean {
|
||||
return isBrowserSitePermissionAllowed({
|
||||
source: { pageUrl: 'https://meet.example.com/room', frameOrigin },
|
||||
memory,
|
||||
permissions
|
||||
})
|
||||
}
|
||||
|
||||
async function answerLatest(value: unknown): Promise<void> {
|
||||
const request = sentRequests(renderer).at(-1)
|
||||
if (!request) {
|
||||
@@ -140,57 +119,4 @@ describe('browser site permission prompts', () => {
|
||||
await expect(ask(['camera', 'microphone'])).resolves.toBe(false)
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("keeps a paired server's answers for its session only, where checks still see them", async () => {
|
||||
const pairedSession = sessionBrowserSitePermissions('persist:paired-a')
|
||||
const call = ask(['notifications'], { memory: pairedSession })
|
||||
await answerLatest('allow')
|
||||
await expect(call).resolves.toBe(true)
|
||||
|
||||
expect(allowedOnCheck(['notifications'], { memory: pairedSession })).toBe(true)
|
||||
await expect(ask(['notifications'], { memory: pairedSession })).resolves.toBe(true)
|
||||
expect(sentRequests(renderer)).toHaveLength(1)
|
||||
expect(listBrowserSitePermissions('default')).toEqual([])
|
||||
void ask(['notifications'], { memory: sessionBrowserSitePermissions('persist:paired-b') })
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("gives another site's frame only its own notification answer, and never asks for it", async () => {
|
||||
const tab = ask(['notifications'])
|
||||
await answerLatest('allow')
|
||||
await tab
|
||||
const widgetPage = ask(['notifications'], { guest: mockGuest('https://widget.example.net/') })
|
||||
await answerLatest('allow')
|
||||
await widgetPage
|
||||
|
||||
expect(allowedOnCheck(['notifications'], { frameOrigin: 'https://widget.example.net' })).toBe(
|
||||
true
|
||||
)
|
||||
await expect(
|
||||
ask(['notifications'], { frameOrigin: 'https://widget.example.net' })
|
||||
).resolves.toBe(true)
|
||||
expect(allowedOnCheck(['notifications'], { frameOrigin: 'https://ads.example.net' })).toBe(
|
||||
false
|
||||
)
|
||||
await expect(ask(['notifications'], { frameOrigin: 'https://ads.example.net' })).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(sentRequests(renderer)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('remembers answers across a restart', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))
|
||||
configureBrowserSitePermissionStore(directory)
|
||||
const call = ask(['midi'])
|
||||
await answerLatest('allow')
|
||||
await call
|
||||
|
||||
// Switch directories to evict the cache and verify the next read comes from disk.
|
||||
configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-')))
|
||||
expect(listBrowserSitePermissions('default')).toEqual([])
|
||||
configureBrowserSitePermissionStore(directory)
|
||||
expect(listBrowserSitePermissions('default')).toEqual([
|
||||
{ origin: 'https://meet.example.com', decisions: { midi: 'allow' } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
const SAMPLING_INTERVAL_BYTES = 512 * 1024
|
||||
const START_HEAP_RATIO = 0.6
|
||||
const STOP_HEAP_RATIO = 0.5
|
||||
// Main refreshes independently because hidden renderers throttle memory samples.
|
||||
const PROFILE_REFRESH_MS = 30_000
|
||||
// Each profile read forces a full renderer GC, so after the first read require this much growth.
|
||||
const PROFILE_READ_GROWTH_RATIO = 0.05
|
||||
// Main checks the heap itself because hidden renderers throttle memory samples.
|
||||
const HEAP_CHECK_MS = 30_000
|
||||
|
||||
export type ProfiledDebugger = {
|
||||
isAttached(): boolean
|
||||
@@ -61,8 +63,8 @@ export function profiledRendererFor(contents: WebContents): ProfiledRenderer {
|
||||
|
||||
export type RendererHeapReading = {
|
||||
heapRatio: number
|
||||
heapLimitBytes: number
|
||||
rendererSurface: string
|
||||
isHighwaterMark: boolean
|
||||
}
|
||||
|
||||
type SamplingSession = {
|
||||
@@ -70,9 +72,13 @@ type SamplingSession = {
|
||||
startedAtPct: number
|
||||
startedAtMs: number
|
||||
trustedScriptPrefix: string | null
|
||||
heapLimitBytes: number
|
||||
admittedHeapRatio: number
|
||||
profileReadAdmitted: boolean
|
||||
// Wait for startSampling before requesting a profile.
|
||||
ready: boolean
|
||||
reading: boolean
|
||||
checking: boolean
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
@@ -94,23 +100,31 @@ export function noteRendererHeapReading(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (reading.heapRatio < STOP_HEAP_RATIO) {
|
||||
noteSessionHeapRatio(renderer, session, reading.heapRatio, false, record)
|
||||
}
|
||||
|
||||
function noteSessionHeapRatio(
|
||||
renderer: ProfiledRenderer,
|
||||
session: SamplingSession,
|
||||
heapRatio: number,
|
||||
fromHeapCheck: boolean,
|
||||
record: (data: CrashReportBreadcrumbData) => void
|
||||
): void {
|
||||
if (heapRatio < STOP_HEAP_RATIO) {
|
||||
session.stop()
|
||||
return
|
||||
}
|
||||
if (reading.isHighwaterMark) {
|
||||
refreshProfile(renderer, session, record)
|
||||
}
|
||||
}
|
||||
|
||||
function refreshProfile(
|
||||
renderer: ProfiledRenderer,
|
||||
session: SamplingSession,
|
||||
record: (data: CrashReportBreadcrumbData) => void
|
||||
): void {
|
||||
if (!session.ready || session.reading) {
|
||||
return
|
||||
}
|
||||
// The first heap check reads even without growth so a steady high heap still has evidence.
|
||||
const firstReadDue = fromHeapCheck && !session.profileReadAdmitted
|
||||
if (!firstReadDue && heapRatio < session.admittedHeapRatio + PROFILE_READ_GROWTH_RATIO) {
|
||||
return
|
||||
}
|
||||
// Admission, not success, consumes the allowance: a failed read may already have paid for its GC.
|
||||
session.profileReadAdmitted = true
|
||||
session.admittedHeapRatio = Math.max(session.admittedHeapRatio, heapRatio)
|
||||
session.reading = true
|
||||
void renderer.debugger
|
||||
.sendCommand('HeapProfiler.getSamplingProfile')
|
||||
@@ -137,7 +151,7 @@ function startSampling(
|
||||
record: (data: CrashReportBreadcrumbData) => void
|
||||
): void {
|
||||
const debug = renderer.debugger
|
||||
// Do not take over a debugger attached by DevTools or another diagnostic client.
|
||||
// Only sees Orca's own debugger client (the lag capture); other CDP sessions are not visible here.
|
||||
if (debug.isAttached()) {
|
||||
return
|
||||
}
|
||||
@@ -159,7 +173,7 @@ function startSampling(
|
||||
debug.removeListener('detach', onDetach)
|
||||
unwatch()
|
||||
if (!renderer.isDestroyed() && debug.isAttached()) {
|
||||
void debug.sendCommand('HeapProfiler.stopSampling').catch(() => undefined)
|
||||
// Detaching ends the inspector session, which stops sampling without stopSampling's forced GC.
|
||||
try {
|
||||
debug.detach()
|
||||
} catch {
|
||||
@@ -175,8 +189,12 @@ function startSampling(
|
||||
startedAtPct: Math.round(reading.heapRatio * 100),
|
||||
startedAtMs: Date.now(),
|
||||
trustedScriptPrefix: trustedScriptPrefixFor(renderer.getURL()),
|
||||
heapLimitBytes: reading.heapLimitBytes,
|
||||
admittedHeapRatio: reading.heapRatio,
|
||||
profileReadAdmitted: false,
|
||||
ready: false,
|
||||
reading: false,
|
||||
checking: false,
|
||||
stop
|
||||
}
|
||||
sessions.set(renderer.id, session)
|
||||
@@ -192,15 +210,40 @@ function startSampling(
|
||||
return
|
||||
}
|
||||
session.ready = true
|
||||
refreshTimer = setInterval(
|
||||
() => refreshProfile(renderer, session, record),
|
||||
PROFILE_REFRESH_MS
|
||||
)
|
||||
refreshTimer = setInterval(() => checkHeap(renderer, session, record), HEAP_CHECK_MS)
|
||||
refreshTimer.unref?.()
|
||||
})
|
||||
.catch(stop)
|
||||
}
|
||||
|
||||
function checkHeap(
|
||||
renderer: ProfiledRenderer,
|
||||
session: SamplingSession,
|
||||
record: (data: CrashReportBreadcrumbData) => void
|
||||
): void {
|
||||
if (session.checking) {
|
||||
return
|
||||
}
|
||||
session.checking = true
|
||||
void renderer.debugger
|
||||
.sendCommand('Runtime.getHeapUsage')
|
||||
.then((result) => {
|
||||
const used =
|
||||
result && typeof result === 'object' && 'usedSize' in result ? result.usedSize : undefined
|
||||
if (
|
||||
typeof used === 'number' &&
|
||||
Number.isFinite(used) &&
|
||||
sessions.get(renderer.id) === session
|
||||
) {
|
||||
noteSessionHeapRatio(renderer, session, used / session.heapLimitBytes, true, record)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
session.checking = false
|
||||
})
|
||||
}
|
||||
|
||||
export function profileRendererMemoryBreadcrumb(
|
||||
renderer: ProfiledRenderer,
|
||||
args: { name?: unknown; data?: unknown } | undefined,
|
||||
@@ -217,21 +260,29 @@ export function profileRendererMemoryBreadcrumb(
|
||||
function readHeapReading(
|
||||
args: { name?: unknown; data?: unknown } | undefined
|
||||
): RendererHeapReading | null {
|
||||
const isHighwaterMark = args?.name === 'renderer_memory_highwater'
|
||||
const data = args?.data
|
||||
if ((!isHighwaterMark && args?.name !== 'renderer_memory') || !data || typeof data !== 'object') {
|
||||
if (
|
||||
(args?.name !== 'renderer_memory_highwater' && args?.name !== 'renderer_memory') ||
|
||||
!data ||
|
||||
typeof data !== 'object'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const used = 'usedHeapMB' in data ? data.usedHeapMB : undefined
|
||||
const limit = 'heapLimitMB' in data ? data.heapLimitMB : undefined
|
||||
if (typeof used !== 'number' || typeof limit !== 'number' || !(limit > 0)) {
|
||||
if (
|
||||
typeof used !== 'number' ||
|
||||
!Number.isFinite(used) ||
|
||||
typeof limit !== 'number' ||
|
||||
!(limit > 0)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const surface = 'rendererSurface' in data ? data.rendererSurface : undefined
|
||||
return {
|
||||
heapRatio: used / limit,
|
||||
rendererSurface: typeof surface === 'string' ? surface : 'unknown',
|
||||
isHighwaterMark
|
||||
heapLimitBytes: limit * 1024 * 1024,
|
||||
rendererSurface: typeof surface === 'string' ? surface : 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,12 @@ import { startDaemonScopeDeathWatch } from './process-scopes/daemon-scope-death-
|
||||
import { isWindowsProcessTableAvailable } from '../windows/windows-process-table'
|
||||
import { leaseAppImageGenerationForCurrentProcess } from '../cli/appimage/appimage-generation-lease'
|
||||
import { createDaemonShutdownDeadline } from './launch/daemon-shutdown-deadline'
|
||||
import { setDaemonProcessName } from './daemon-process-name'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// The parent closes startup stderr after readiness; later EPIPE errors must not kill the daemon.
|
||||
process.stderr.on('error', () => {})
|
||||
setDaemonProcessName()
|
||||
|
||||
const {
|
||||
socketPath,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
export function setDaemonProcessName(): void {
|
||||
if (process.platform !== 'linux') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// process.title overwrites argv, which daemon adoption and recovery use as identity evidence.
|
||||
writeFileSync('/proc/self/comm', 'orca-agents')
|
||||
} catch {
|
||||
// Naming must not prevent terminals starting when procfs is unavailable or read-only.
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { rm } from '../../../filesystem/asar-transparent-fs'
|
||||
import { macDaemonCopyBundlePath } from './macos-daemon-bundle'
|
||||
import { MAC_TERMINAL_HOST_BUNDLE } from './macos-daemon-bundle'
|
||||
|
||||
const LSREGISTER =
|
||||
'/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'
|
||||
@@ -27,13 +27,20 @@ export async function retireUnusedMacDaemonCopy(directory: string): Promise<void
|
||||
if (!unused) {
|
||||
return
|
||||
}
|
||||
// Direct execution may register this copy with LaunchServices.
|
||||
await runProcess({
|
||||
program: LSREGISTER,
|
||||
args: ['-u', macDaemonCopyBundlePath(directory)],
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 8192
|
||||
}).catch(() => {})
|
||||
// Old copies retain their original bundle name when the helper is renamed.
|
||||
const parent = join(directory, 'app.noindex')
|
||||
const bundles = await readdir(parent).catch(() => [])
|
||||
for (const bundle of bundles) {
|
||||
if (bundle !== MAC_TERMINAL_HOST_BUNDLE && bundle !== 'Orca Terminal Host.app') {
|
||||
continue
|
||||
}
|
||||
await runProcess({
|
||||
program: LSREGISTER,
|
||||
args: ['-u', join(parent, bundle)],
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 8192
|
||||
}).catch(() => {})
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Retain code when its use cannot be verified.
|
||||
|
||||
@@ -7,9 +7,9 @@ import { rm } from '../../../filesystem/asar-transparent-fs'
|
||||
import { ensurePrivateDir } from '../../endpoint/publication/daemon-private-file-modes'
|
||||
import { inspectMacProcessCodeIdentity } from './daemon-mac-code-identity'
|
||||
|
||||
/** Plain Node helper packaged by config/scripts/macos-terminal-host-bundle.cjs. */
|
||||
export const MAC_TERMINAL_HOST_BUNDLE = 'Orca Terminal Host.app'
|
||||
export const MAC_TERMINAL_HOST_EXECUTABLE = 'orca-terminal-host'
|
||||
/** Plain Node helper packaged by config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs. */
|
||||
export const MAC_TERMINAL_HOST_BUNDLE = 'Orca Agents.app'
|
||||
export const MAC_TERMINAL_HOST_EXECUTABLE = 'Orca Agents'
|
||||
export const MAC_DAEMON_COPY_PREFIX = 'terminal-host-'
|
||||
|
||||
export type MacDaemonBundle = {
|
||||
|
||||
+23
-2
@@ -29,9 +29,9 @@ afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function copy(name: string): Promise<string> {
|
||||
async function copy(name: string, bundle = 'Orca Agents.app'): Promise<string> {
|
||||
const directory = join(root, name)
|
||||
await mkdir(join(directory, 'app.noindex', 'Orca Terminal Host.app'), { recursive: true })
|
||||
await mkdir(join(directory, 'app.noindex', bundle), { recursive: true })
|
||||
return directory
|
||||
}
|
||||
|
||||
@@ -68,6 +68,27 @@ it.each([
|
||||
expect(await exists(directory)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['Orca Agents.app', 'Orca Terminal Host.app'])(
|
||||
'unregisters an unused %s before deleting its copy',
|
||||
async (bundle) => {
|
||||
const directory = await copy('terminal-host-old', bundle)
|
||||
run.mockImplementation(async (spec) => {
|
||||
if (spec.program === LSREGISTER) {
|
||||
expect(await exists(directory)).toBe(true)
|
||||
}
|
||||
return unused
|
||||
})
|
||||
await retireUnusedMacDaemonCopy(directory)
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
program: LSREGISTER,
|
||||
args: ['-u', join(directory, 'app.noindex', bundle)]
|
||||
})
|
||||
)
|
||||
expect(await exists(directory)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it("retires every unused copy and staging dir except the current build's and this process's staging", async () => {
|
||||
const current = await copy('terminal-host-current')
|
||||
const oldUnused = await copy('terminal-host-old')
|
||||
|
||||
@@ -35,13 +35,13 @@ let verifyCode: number
|
||||
let onCopy: (() => Promise<void>) | null
|
||||
|
||||
const signal = (): AbortSignal => new AbortController().signal
|
||||
const helperExecutable = (): string => join(helper, 'Contents', 'MacOS', 'orca-terminal-host')
|
||||
const helperExecutable = (): string => join(helper, 'Contents', 'MacOS', 'Orca Agents')
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-mac-runtime-')))
|
||||
// Model an update that moved the running bundle and replaced its original path.
|
||||
source = join(root, 'com.stablyai.orca.ShipIt.old', 'Orca.app')
|
||||
helper = join(source, 'Contents', 'Helpers', 'Orca Terminal Host.app')
|
||||
helper = join(source, 'Contents', 'Helpers', 'Orca Agents.app')
|
||||
copies = join(root, 'profile', 'daemon-host', 'macos')
|
||||
await mkdir(join(source, 'Contents', 'MacOS'), { recursive: true })
|
||||
await mkdir(join(helper, 'Contents', 'MacOS'), { recursive: true })
|
||||
@@ -101,14 +101,7 @@ it('publishes one verified copy per helper build, from the running app rather th
|
||||
await rm(source, { recursive: true })
|
||||
expect(copy.directory).toBe(join(copies, `terminal-host-${cdhash}`))
|
||||
expect(copy.execPath).toBe(
|
||||
join(
|
||||
copy.directory,
|
||||
'app.noindex',
|
||||
'Orca Terminal Host.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'orca-terminal-host'
|
||||
)
|
||||
join(copy.directory, 'app.noindex', 'Orca Agents.app', 'Contents', 'MacOS', 'Orca Agents')
|
||||
)
|
||||
expect(await readFile(copy.execPath, 'utf8')).toBe('signed-node')
|
||||
expect(await readFile(copy.entryPath, 'utf8')).toBe('daemon-code')
|
||||
@@ -136,9 +129,9 @@ it("rejects a copy that does not satisfy the running app's requirement and publi
|
||||
it('keeps the winning copy when a concurrent publisher renames first', async () => {
|
||||
onCopy = async () => {
|
||||
onCopy = null
|
||||
const winner = join(copies, `terminal-host-${cdhash}`, 'app.noindex', 'Orca Terminal Host.app')
|
||||
const winner = join(copies, `terminal-host-${cdhash}`, 'app.noindex', 'Orca Agents.app')
|
||||
await cp(helper, winner, { recursive: true })
|
||||
await writeFile(join(winner, 'Contents', 'MacOS', 'orca-terminal-host'), 'winner')
|
||||
await writeFile(join(winner, 'Contents', 'MacOS', 'Orca Agents'), 'winner')
|
||||
}
|
||||
const copy = await materializeMacDaemonBundle(copies, signal())
|
||||
expect(await readFile(copy.execPath, 'utf8')).toBe('winner')
|
||||
@@ -155,7 +148,7 @@ it('stops at the shared deadline and removes the staging copy', async () => {
|
||||
return controller.signal.aborted ? { ...ok, code: null } : (signed?.(spec) ?? ok)
|
||||
})
|
||||
await expect(materializeMacDaemonBundle(copies, controller.signal)).rejects.toThrow(
|
||||
'Could not copy Orca Terminal Host.app'
|
||||
'Could not copy Orca Agents.app'
|
||||
)
|
||||
expect(await readdir(copies)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ const nativePlatform = process.platform
|
||||
const originalGetuid = Object.getOwnPropertyDescriptor(process, 'getuid')
|
||||
const originalGetSystemVersion = Object.getOwnPropertyDescriptor(process, 'getSystemVersion')
|
||||
const roomyDeadline = (): number => Date.now() + 60_000
|
||||
const helperExec = (): string => join(copyDir, 'MacOS', 'orca-terminal-host')
|
||||
const helperExec = (): string => join(copyDir, 'MacOS', 'Orca Agents')
|
||||
const helperEntry = (): string => join(copyDir, 'daemon-entry.js')
|
||||
const service = (): string => `gui/501/${macDaemonJobLabel(state.root)}`
|
||||
const launchctlCalls = (verb: string): ProcessSpec[] =>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { once } from 'node:events'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { spawnProcess } from '@orca/process-host'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { inspectDaemonProcessIdentity } from '../endpoint/identity/daemon-pid-identity'
|
||||
|
||||
const namingModule = new URL('../daemon-process-name.ts', import.meta.url)
|
||||
|
||||
describe.skipIf(process.platform !== 'linux')('Linux daemon process naming', () => {
|
||||
it.each([false, true])(
|
||||
'preserves live daemon identity when naming writes are denied: %s',
|
||||
async (denyWrites) => {
|
||||
const socketPath = '/tmp/orca naming contract/daemon.sock'
|
||||
const tokenPath = `/tmp/orca naming contract/${'long endpoint component/'.repeat(12)}daemon.token`
|
||||
const script = `
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { setDaemonProcessName } from ${JSON.stringify(namingModule.href)}
|
||||
const before = readFileSync('/proc/self/cmdline').toString('base64')
|
||||
const beforeName = readFileSync('/proc/self/comm').toString('base64')
|
||||
setDaemonProcessName()
|
||||
process.stdin.resume()
|
||||
console.log([
|
||||
before,
|
||||
readFileSync('/proc/self/cmdline').toString('base64'),
|
||||
beforeName,
|
||||
readFileSync('/proc/self/comm').toString('base64')
|
||||
].join(' '))
|
||||
`
|
||||
const child = spawnProcess({
|
||||
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
||||
args: [
|
||||
...(denyWrites ? ['--permission', '--allow-fs-read=*'] : []),
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
script,
|
||||
'--',
|
||||
'daemon-entry.js',
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--token',
|
||||
tokenPath
|
||||
]
|
||||
})
|
||||
const exited = once(child, 'exit')
|
||||
const lines = createInterface({ input: child.stdout })
|
||||
try {
|
||||
const [line] = await Promise.race([
|
||||
once(lines, 'line'),
|
||||
exited.then(() => {
|
||||
throw new Error('Naming fixture exited before publishing its process identity')
|
||||
})
|
||||
])
|
||||
const [before, after, beforeName, afterName] = String(line).split(' ')
|
||||
expect(Buffer.from(before, 'base64').toString()).toContain(tokenPath)
|
||||
expect(after).toBe(before)
|
||||
expect(Buffer.from(afterName, 'base64').toString()).toBe(
|
||||
denyWrites ? Buffer.from(beforeName, 'base64').toString() : 'orca-agents\n'
|
||||
)
|
||||
expect(child.pid).toBeDefined()
|
||||
expect(
|
||||
await inspectDaemonProcessIdentity(child.pid ?? 0, socketPath, tokenPath, null)
|
||||
).toBe('match')
|
||||
} finally {
|
||||
lines.close()
|
||||
child.stdin.end()
|
||||
await exited
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -137,6 +137,9 @@ async function gitExecFileAsyncUnlocked(
|
||||
) {
|
||||
await terminationState.current
|
||||
const wasMissing = invalidateMissingDirectWslGit(error, resolved)
|
||||
if (options.canStart?.() === false) {
|
||||
throw createAbortError()
|
||||
}
|
||||
const fallback = resolveGitCommand(args, effectiveOptions, true, true)
|
||||
result = await capture(fallback)
|
||||
// Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient.
|
||||
|
||||
@@ -5,12 +5,15 @@ import { resolveDefaultBaseRefViaExec } from '../../../../shared/git/refs/git-de
|
||||
export type LocalGitExecOptions = {
|
||||
wslDistro?: string
|
||||
admissionTier?: GitAdmissionTier
|
||||
/** Checked at admission; a bounded optional read refuses git that would start past its deadline. */
|
||||
canStart?: () => boolean
|
||||
}
|
||||
|
||||
export type LocalDefaultBaseRefGitOptions = {
|
||||
cwd: string
|
||||
wslDistro?: string
|
||||
admissionTier?: GitAdmissionTier
|
||||
canStart?: () => boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000
|
||||
@@ -22,7 +25,8 @@ export function gitExecOptions(
|
||||
return {
|
||||
cwd,
|
||||
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
|
||||
...(options.admissionTier ? { admissionTier: options.admissionTier } : {})
|
||||
...(options.admissionTier ? { admissionTier: options.admissionTier } : {}),
|
||||
...(options.canStart ? { canStart: options.canStart } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolveWorktreeAddBaseRef } from '../../../../shared/worktree/base-refs
|
||||
type GitExecOptions = {
|
||||
wslDistro?: string
|
||||
admissionTier?: GitAdmissionTier
|
||||
canStart?: () => boolean
|
||||
}
|
||||
|
||||
/** Return the probe's commit OID so callers need not resolve it again. */
|
||||
|
||||
@@ -10,13 +10,24 @@ import { hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../work
|
||||
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
|
||||
import { blockedWslDistroForPath } from '../../wsl/filesystem/wsl-unc-access-gate'
|
||||
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
|
||||
import type { SetupHooksCreateSource } from '../../../shared/worktree/setup-hooks-create-source'
|
||||
import { readCreateSourceSetupHooks } from '../../workspace-hooks/create-source-setup-hooks'
|
||||
import { getWorktreeCreatePrefetchGitOptions } from '../../workspaces/execution-host/project-runtime-git-options'
|
||||
|
||||
export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): void {
|
||||
const { store } = context
|
||||
const { store, runtime } = context
|
||||
|
||||
ipcMain.handle(
|
||||
'hooks:check',
|
||||
async (_event, args: { repoId: string; hostId?: ExecutionHostId; background?: boolean }) => {
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
repoId: string
|
||||
hostId?: ExecutionHostId
|
||||
background?: boolean
|
||||
createSource?: SetupHooksCreateSource
|
||||
}
|
||||
) => {
|
||||
const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId)
|
||||
if (!repo) {
|
||||
const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId)
|
||||
@@ -58,6 +69,23 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v
|
||||
}
|
||||
}
|
||||
|
||||
if (args.createSource && !args.background) {
|
||||
const created = await readCreateSourceSetupHooks({
|
||||
repo,
|
||||
source: args.createSource,
|
||||
runtime,
|
||||
gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo)
|
||||
})
|
||||
if (created) {
|
||||
return {
|
||||
status: 'ok',
|
||||
hasHooks: created.hooks !== null,
|
||||
hooks: created.hooks,
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const has = hasHooksFile(repo.path)
|
||||
const hooks = scopeHooksToRepoKind(repo, has ? loadHooks(repo.path) : null)
|
||||
// Well-formed unknown keys may come from a newer Orca; suggest updating instead of parse repair.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { hasActiveTraceSink, startSpan, withSpan, type ActiveSpan } from './tracer'
|
||||
import { getGitOperationCause } from './git-operation-cause'
|
||||
import { resolveGitAdmissionTier } from '../git/command-runner/admission/git-operation-executor'
|
||||
import type { GitAdmissionTier } from '../../shared/rpc-contract/git-admission-tier-params'
|
||||
import type { GitAdmissionTier } from '../../shared/rpc-contract/source-control/git-admission-tier-params'
|
||||
|
||||
const GIT_ACTION_NAMES: Record<string, readonly string[]> = {
|
||||
worktree: ['list', 'add', 'remove', 'move', 'prune', 'lock', 'unlock', 'repair'],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorkspaceMetadataSet } from '../../../../shared/worktree/metadata/workspace-metadata-set'
|
||||
import type { WorkspaceCleanupRunFinishedEvent } from '../../../../shared/workspace-cleanup/workspace-cleanup-run'
|
||||
import type {
|
||||
RuntimeEditorTabReveal,
|
||||
RuntimeFileOpenPosition
|
||||
@@ -55,6 +56,7 @@ export type RuntimeNotifier = {
|
||||
worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void
|
||||
reposChanged(): void
|
||||
browserProfilesChanged?(): void
|
||||
workspaceCleanupRunFinished?(run: WorkspaceCleanupRunFinishedEvent['run']): void
|
||||
activateWorktree(
|
||||
repoId: string,
|
||||
worktreeId: string,
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
storeBackedBranchSwitchSources
|
||||
} from '../integrations/git/runtime-branch-switch-host'
|
||||
import { OrcaRuntimeWithFileCommands } from './runtime-file-command-state'
|
||||
import { readCreateSourceSetupHooks } from '../../workspace-hooks/create-source-setup-hooks'
|
||||
import { getWorktreeCreatePrefetchGitOptions } from '../../workspaces/execution-host/project-runtime-git-options'
|
||||
|
||||
export abstract class OrcaRuntimeWithRepositoryCommands extends OrcaRuntimeWithFileCommands {
|
||||
protected readonly gitCommands = new RuntimeGitCommands({
|
||||
@@ -156,7 +158,14 @@ export abstract class OrcaRuntimeWithRepositoryCommands extends OrcaRuntimeWithF
|
||||
protected readonly gitHubProjectCommands = new RuntimeGitHubProjectCommands()
|
||||
|
||||
protected readonly repositoryHooks = new RuntimeRepositoryHooksCommands({
|
||||
resolveRepo: (selector) => this.resolveRepoSelector(selector)
|
||||
resolveRepo: (selector) => this.resolveRepoSelector(selector),
|
||||
readCreateSourceHooks: (repo, source) =>
|
||||
readCreateSourceSetupHooks({
|
||||
repo,
|
||||
source,
|
||||
runtime: this.remoteFetches,
|
||||
gitOptions: getWorktreeCreatePrefetchGitOptions(this.requireStore(), repo)
|
||||
})
|
||||
})
|
||||
|
||||
protected readonly repositoryIssueCommand = new RuntimeRepositoryIssueCommand({
|
||||
|
||||
@@ -75,17 +75,6 @@ export function listHomePeerMailEnvironments(this: OrchestrationDb): string[] {
|
||||
return rows.map((row) => row.environment_id)
|
||||
}
|
||||
|
||||
/** One server's check, for its pull loop: a seek on that server's home rows, not every server's. */
|
||||
export function hasHomePeerMailEnvironment(this: OrchestrationDb, environmentId: string): boolean {
|
||||
return (
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM peer_mail_conversations WHERE role = 'home' AND environment_id = ? LIMIT 1`
|
||||
)
|
||||
.get(environmentId) !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** Conversations a paired Orca opened here that hold replies it has not acknowledged yet. */
|
||||
export function listPeerConversationIdsWithPendingReplies(
|
||||
this: OrchestrationDb,
|
||||
@@ -189,7 +178,6 @@ export type PeerMailStoreMethods = {
|
||||
openHomePeerMailConversation: typeof openHomePeerMailConversation
|
||||
getPeerMailConversation: typeof getPeerMailConversation
|
||||
listHomePeerMailEnvironments: typeof listHomePeerMailEnvironments
|
||||
hasHomePeerMailEnvironment: typeof hasHomePeerMailEnvironment
|
||||
listPeerConversationIdsWithPendingReplies: typeof listPeerConversationIdsWithPendingReplies
|
||||
getPeerMailMessage: typeof getPeerMailMessage
|
||||
isPeerMailSenderLabel: typeof isPeerMailSenderLabel
|
||||
@@ -201,7 +189,6 @@ export function attachPeerMailStore(ctor: { prototype: object }): void {
|
||||
openHomePeerMailConversation,
|
||||
getPeerMailConversation,
|
||||
listHomePeerMailEnvironments,
|
||||
hasHomePeerMailEnvironment,
|
||||
listPeerConversationIdsWithPendingReplies,
|
||||
getPeerMailMessage,
|
||||
isPeerMailSenderLabel,
|
||||
|
||||
+13
-15
@@ -6,24 +6,22 @@ export function peerMailRelayEnvironments(db: OrchestrationDb): Set<string> {
|
||||
return new Set([...db.listHomePeerMailEnvironments(), ...listLiveRemoteRunLinkEnvironments(db)])
|
||||
}
|
||||
|
||||
/** Whether a server's pull loop still has a reason to run; a store read error keeps it running. Runs
|
||||
* every pull, so it asks about this one server only: closed links' history never enters it. */
|
||||
// Every pull asks about one server only, so closed links' history never enters it: an index seek
|
||||
// for a home conversation, then one per live link state, stopping at the first row. One equality
|
||||
// per state, because an IN list builds a temporary set on every call.
|
||||
const RELAY_WANTS_SQL = `
|
||||
SELECT 1 FROM peer_mail_conversations WHERE role = 'home' AND environment_id = ?1
|
||||
UNION ALL
|
||||
SELECT 1 FROM remote_run_links WHERE environment_id = ?1 AND state = 'open'
|
||||
UNION ALL
|
||||
SELECT 1 FROM remote_run_links WHERE environment_id = ?1 AND state = 'cancel_owed'
|
||||
LIMIT 1`
|
||||
|
||||
/** Whether a server's pull loop still has a reason to run; a store read error keeps it running. */
|
||||
export function peerMailRelayWants(db: OrchestrationDb, environmentId: string): boolean {
|
||||
try {
|
||||
return db.hasHomePeerMailEnvironment(environmentId) || hasLiveRemoteRunLink(db, environmentId)
|
||||
return db.db.prepare(RELAY_WANTS_SQL).get(environmentId) !== undefined
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** A seek on this server's own (environment_id, state) index rows, however many closed links it keeps. */
|
||||
function hasLiveRemoteRunLink(db: OrchestrationDb, environmentId: string): boolean {
|
||||
return (
|
||||
db.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM remote_run_links
|
||||
WHERE environment_id = ? AND state IN ('open', 'cancel_owed') LIMIT 1`
|
||||
)
|
||||
.get(environmentId) !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OrchestrationDb } from '../../db/orchestration-db'
|
||||
import type { WorkerDispatchRow } from '../../db/orchestration-records'
|
||||
import type { DispatchContextRow, WorkerDispatchRow } from '../../db/orchestration-records'
|
||||
import { reportWorkerTurnStop } from './worker-turn-stop-report'
|
||||
|
||||
/** What a failed start left behind, as its residual resources name it. */
|
||||
@@ -28,6 +28,32 @@ function describeLeftover(residualResources: string): string | undefined {
|
||||
return described.length > 0 ? described.join(', ') : undefined
|
||||
}
|
||||
|
||||
function reusesTerminal(worker: WorkerDispatchRow): boolean {
|
||||
try {
|
||||
const options: unknown = JSON.parse(worker.start_options)
|
||||
return (
|
||||
typeof options === 'object' && options !== null && 'terminal' in options && !!options.terminal
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** A start that failed after its terminal opened still has output to read; a local reuse start
|
||||
* that failed before binding never ran in the terminal it named. Federated rows never bind here. */
|
||||
export function failedWorkerStartHasOutput(
|
||||
db: OrchestrationDb,
|
||||
worker: WorkerDispatchRow,
|
||||
dispatch: DispatchContextRow | undefined
|
||||
): boolean {
|
||||
const unboundReuse =
|
||||
reusesTerminal(worker) &&
|
||||
!dispatch?.assignee_handle &&
|
||||
!dispatch?.assignee_orca_session_id &&
|
||||
!db.getFederatedDispatch(worker.dispatch_id)
|
||||
return Boolean(worker.agent_terminal_handle || dispatch?.assignee_handle) && !unboundReuse
|
||||
}
|
||||
|
||||
/** A failed start leaves its task unowned, and the caller may never have read the start's receipt. */
|
||||
export function reportFailedWorkerStart(
|
||||
db: OrchestrationDb,
|
||||
@@ -51,8 +77,7 @@ export function reportFailedWorkerStart(
|
||||
cause: `it never started: ${worker.last_error ?? 'its start failed'}`,
|
||||
lastOutput: undefined,
|
||||
leftover: describeLeftover(worker.residual_resources),
|
||||
// A start that failed after its terminal opened still has output to read.
|
||||
...(worker.agent_terminal_handle || dispatch.assignee_handle ? {} : { neverStarted: true }),
|
||||
...(failedWorkerStartHasOutput(db, worker, dispatch) ? {} : { neverStarted: true }),
|
||||
...(settled ? { settled } : {})
|
||||
},
|
||||
{ getDb: () => db, isWaitingForMail: () => false, notify }
|
||||
|
||||
@@ -19,9 +19,14 @@ import { inspectSetupScriptImportCandidates } from '../../../shared/workspace-se
|
||||
import { joinWorktreeRelativePath } from '../files/paths/runtime-relative-paths'
|
||||
import { blockedWslDistroForPath } from '../../wsl/filesystem/wsl-unc-access-gate'
|
||||
import { hashOrcaHookTrustContent } from '../../workspace-hooks/created-worktree-setup-trust'
|
||||
import type { SetupHooksCreateSource } from '../../../shared/worktree/setup-hooks-create-source'
|
||||
|
||||
type RuntimeRepositoryHooksCommandsDeps = {
|
||||
resolveRepo: (selector: string) => Promise<Repo>
|
||||
readCreateSourceHooks: (
|
||||
repo: Repo,
|
||||
source: SetupHooksCreateSource
|
||||
) => Promise<{ hooks: OrcaHooks | null } | undefined>
|
||||
}
|
||||
|
||||
export class RuntimeRepositoryHooksCommands {
|
||||
@@ -60,7 +65,10 @@ export class RuntimeRepositoryHooksCommands {
|
||||
}
|
||||
}
|
||||
|
||||
async checkRepoHooks(repoSelector: string, options: { background?: boolean } = {}) {
|
||||
async checkRepoHooks(
|
||||
repoSelector: string,
|
||||
options: { background?: boolean; createSource?: SetupHooksCreateSource } = {}
|
||||
) {
|
||||
const repo = await this.deps.resolveRepo(repoSelector)
|
||||
if (isFolderRepo(repo) && !isRepoOnCatalogHost(repo)) {
|
||||
return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
@@ -83,6 +91,17 @@ export class RuntimeRepositoryHooksCommands {
|
||||
: ('wsl-distro-unverifiable' as const)
|
||||
}
|
||||
}
|
||||
if (options.createSource && !options.background) {
|
||||
const created = await this.deps.readCreateSourceHooks(repo, options.createSource)
|
||||
if (created) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
hasHooks: created.hooks !== null,
|
||||
hooks: created.hooks,
|
||||
mayNeedUpdate: false
|
||||
}
|
||||
}
|
||||
}
|
||||
const has = hasHooksFile(repo.path)
|
||||
const hooks = scopeHooksToRepoKind(repo, has ? loadHooks(repo.path) : null)
|
||||
return {
|
||||
|
||||
+28
@@ -51,4 +51,32 @@ describe('a worker-start that fails', () => {
|
||||
expect(lost?.from_handle).toMatch(/^dispatch:/)
|
||||
expect(h.runtime.orchestrationSenderNames.sender(lost!.from_handle).agent).toBe('claude')
|
||||
})
|
||||
|
||||
it('treats a reuse start that failed before binding as never started', async () => {
|
||||
h.setup()
|
||||
vi.spyOn(h.runtime, 'waitForTerminal').mockResolvedValue({
|
||||
handle: 'term_reused',
|
||||
condition: 'tui-idle',
|
||||
satisfied: false,
|
||||
status: 'running',
|
||||
exitCode: null
|
||||
})
|
||||
const task = h.db.createTask({ spec: 'follow up on the PR', runId: h.activeRunId })
|
||||
const receipt = await h.call('orchestration.workerStart', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_reused'
|
||||
})
|
||||
expect(receipt).toMatchObject({ state: 'failed' })
|
||||
const dispatchId = h.db.getDispatchContext(task.id)?.id
|
||||
|
||||
const [lost] = h.db.getUnreadRunMailbox(h.activeRunId, 100, ['escalation'])
|
||||
expect(lost?.body).not.toContain('worker-read')
|
||||
await expect(
|
||||
h.call('orchestration.workerRead', { dispatch: dispatchId })
|
||||
).rejects.toMatchObject({
|
||||
code: 'worker_never_started',
|
||||
message: expect.stringContaining('never started: Agent did not become ready')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import { sessionIdFromStructuredWorkerIncarnation } from '../../../../orchestrat
|
||||
import { readExactWorkerOutput } from './output/worker-output'
|
||||
import { exposeWorkerTerminalResource } from './release/worker-terminal-resource-presentation'
|
||||
import { liveWorkerTerminalAutoReleaseAt } from '../../../../orchestration/terminal-authority/release/worker-terminal-ownership'
|
||||
import { failedWorkerStartHasOutput } from '../../../../orchestration/workers/turn-stops/failed-worker-start-report'
|
||||
import { readFederatedWorkerOutput } from '../../../../orchestration/legacy-remote-home/federated-worker-read'
|
||||
import { showFederatedWorker } from '../../../../orchestration/legacy-remote-home/federated-worker-show'
|
||||
import {
|
||||
@@ -93,8 +94,8 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [
|
||||
const dispatch = db.getDispatchContextById(params.dispatch)
|
||||
const worker = db.getWorkerDispatch(params.dispatch)
|
||||
const terminalHandle = worker?.agent_terminal_handle ?? dispatch?.assignee_handle
|
||||
// A start that never got a terminal has only its failure, on any host.
|
||||
if (worker?.state === 'failed' && !terminalHandle) {
|
||||
// A start that never ran in a terminal has only its failure, on any host.
|
||||
if (worker?.state === 'failed' && !failedWorkerStartHasOutput(db, worker, dispatch)) {
|
||||
throw new OrchestrationError(
|
||||
'worker_never_started',
|
||||
`Worker Dispatch ${params.dispatch} never started: ${worker.last_error ?? 'its start failed'}`
|
||||
|
||||
@@ -233,7 +233,10 @@ export const REPO_METHODS = [
|
||||
permission: 'workspace',
|
||||
params: RepoHooksCheck,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.checkRepoHooks(params.repo, { background: repoHooksCheckIsBackground(params) })
|
||||
runtime.checkRepoHooks(params.repo, {
|
||||
background: repoHooksCheckIsBackground(params),
|
||||
createSource: params.createSource
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.setupScriptImports',
|
||||
|
||||
@@ -69,6 +69,7 @@ export abstract class RuntimeWorkspaceChangeEvents extends NativeChatLaunchDraft
|
||||
|
||||
/** The cleanup runner finished a run on this host; clients may toast. */
|
||||
notifyWorkspaceCleanupRunFinished(run: WorkspaceCleanupRunFinishedEvent['run']): void {
|
||||
this.notifier?.workspaceCleanupRunFinished?.(run)
|
||||
this.emitClientEvent({ type: 'workspaceCleanupRunFinished', run })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export function registerRuntimeWindowLifecycle(
|
||||
reposChanged: () => send('repos:changed'),
|
||||
browserProfilesChanged: () => send('browser:session:profilesChanged'),
|
||||
automationsChanged: (payload) => send('automations:changed', payload),
|
||||
workspaceCleanupRunFinished: (run) => send('workspaceCleanup:runFinished', run),
|
||||
activateWorktree: (
|
||||
repoId,
|
||||
worktreeId,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { gitExecFileAsync } from '../git/command-runner/git-exec-file'
|
||||
import { RuntimeRemoteFetchController } from '../runtime/repositories/runtime-remote-fetch-controller'
|
||||
import { readCreateSourceSetupHooks } from './create-source-setup-hooks'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
const commitArgs = ['-c', 'user.name=Test', '-c', 'user.email=test@example.com']
|
||||
|
||||
async function git(cwd: string, ...args: string[]): Promise<void> {
|
||||
await gitExecFileAsync(args, { cwd })
|
||||
}
|
||||
|
||||
async function commitOrcaYaml(cwd: string, setupLine: string, eol = '\n'): Promise<void> {
|
||||
const yaml = ['scripts:', ' setup: |', ` ${setupLine}`, ''].join(eol)
|
||||
await writeFile(join(cwd, 'orca.yaml'), yaml)
|
||||
await git(cwd, 'add', 'orca.yaml')
|
||||
await git(cwd, ...commitArgs, 'commit', '-m', setupLine)
|
||||
}
|
||||
|
||||
// A project checkout left on old setup commands while origin/main moved on.
|
||||
async function staleCheckout(): Promise<{ repo: Repo; upstream: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-create-source-hooks-'))
|
||||
roots.push(root)
|
||||
const upstream = join(root, 'upstream')
|
||||
const checkout = join(root, 'checkout')
|
||||
await git(root, 'init', '-b', 'main', upstream)
|
||||
await git(upstream, 'config', 'core.autocrlf', 'false')
|
||||
await commitOrcaYaml(upstream, 'node old-setup.mjs')
|
||||
await git(root, 'clone', upstream, checkout)
|
||||
await commitOrcaYaml(upstream, 'node new-setup.mjs', '\r\n')
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: checkout,
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
return { repo, upstream }
|
||||
}
|
||||
|
||||
function read(repo: Repo, source: Parameters<typeof readCreateSourceSetupHooks>[0]['source']) {
|
||||
return readCreateSourceSetupHooks({
|
||||
repo,
|
||||
source,
|
||||
runtime: new RuntimeRemoteFetchController(),
|
||||
gitOptions: {}
|
||||
})
|
||||
}
|
||||
|
||||
it('reads the refreshed base instead of the stale project checkout', async () => {
|
||||
const { repo } = await staleCheckout()
|
||||
|
||||
const result = await read(repo, { kind: 'base', baseBranch: 'origin/main' })
|
||||
|
||||
expect(result?.hooks?.scripts.setup).toBe('node new-setup.mjs')
|
||||
})
|
||||
|
||||
it('reads an explicitly reused local branch, and the base when that branch is absent', async () => {
|
||||
const { repo } = await staleCheckout()
|
||||
await git(repo.path, 'branch', 'feature', 'main')
|
||||
|
||||
const reused = await read(repo, { kind: 'branch', ref: 'feature', baseBranch: 'origin/main' })
|
||||
const created = await read(repo, { kind: 'branch', ref: 'missing', baseBranch: 'origin/main' })
|
||||
|
||||
expect(reused?.hooks?.scripts.setup).toBe('node old-setup.mjs')
|
||||
expect(created?.hooks?.scripts.setup).toBe('node new-setup.mjs')
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { OrcaHooks } from '../../shared/orca-yaml-hook-types'
|
||||
import { parseOrcaYaml } from '../../shared/orca-yaml'
|
||||
import { scopeHooksToRepoKind } from '../../shared/folder-workspace-hooks'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-refs/base-ref'
|
||||
import type { SetupHooksCreateSource } from '../../shared/worktree/setup-hooks-create-source'
|
||||
import { gitExecFileAsync } from '../git/command-runner/git-exec-file'
|
||||
import {
|
||||
getDefaultBaseRefAsync,
|
||||
type LocalGitExecOptions
|
||||
} from '../git/refs/resolution/repo-default-base-ref'
|
||||
import {
|
||||
hasLocalWorktreeBaseRef,
|
||||
hasWorktreeBaseCommitRef
|
||||
} from '../git/worktrees/creation/worktree-base-ref-probe'
|
||||
import { resolveWorktreeCreateBase } from '../workspaces/creation/worktree-create-base'
|
||||
import type {
|
||||
RemoteFetchResult,
|
||||
RemoteTrackingBase
|
||||
} from '../runtime/repositories/runtime-remote-fetch-controller'
|
||||
|
||||
// Leaves the 15 s hooks-check RPC room to fall back to the checkout read.
|
||||
const CREATE_SOURCE_READ_DEADLINE_MS = 8_000
|
||||
|
||||
export type CreateSourceSetupHooksRuntime = {
|
||||
resolveRemoteTrackingBase: (
|
||||
repoPath: string,
|
||||
baseBranch: string,
|
||||
options?: LocalGitExecOptions
|
||||
) => Promise<RemoteTrackingBase | null>
|
||||
hasRemoteTrackingRef: (
|
||||
repoPath: string,
|
||||
base: RemoteTrackingBase,
|
||||
options?: LocalGitExecOptions
|
||||
) => Promise<boolean>
|
||||
getOrStartRemoteTrackingBaseRefresh: (
|
||||
repoPath: string,
|
||||
base: RemoteTrackingBase,
|
||||
options?: LocalGitExecOptions
|
||||
) => Promise<RemoteFetchResult>
|
||||
}
|
||||
|
||||
class DeadlinePassed extends Error {}
|
||||
|
||||
/**
|
||||
* The orca.yaml hooks at the ref a create will check out. Undefined when that ref can't be read in
|
||||
* time; callers then read the project checkout, as before, and the host's created-worktree check stays the backstop.
|
||||
*/
|
||||
export async function readCreateSourceSetupHooks(args: {
|
||||
repo: Repo
|
||||
source: SetupHooksCreateSource
|
||||
runtime: CreateSourceSetupHooksRuntime
|
||||
gitOptions: LocalGitExecOptions
|
||||
}): Promise<{ hooks: OrcaHooks | null } | undefined> {
|
||||
if (isFolderRepo(args.repo)) {
|
||||
return undefined
|
||||
}
|
||||
const deadline = Date.now() + CREATE_SOURCE_READ_DEADLINE_MS
|
||||
// Git admission refuses past the deadline, even for a step the race already abandoned.
|
||||
const gated = { ...args.gitOptions, canStart: () => Date.now() < deadline }
|
||||
const step = async <T>(work: () => Promise<T>): Promise<T> => {
|
||||
const remaining = deadline - Date.now()
|
||||
if (remaining <= 0) {
|
||||
throw new DeadlinePassed()
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
work(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new DeadlinePassed()), remaining)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const ref = await step(() => resolveCreateSourceRef(args, gated, step))
|
||||
return ref ? await step(() => readOrcaYamlAtRef(args.repo, ref, gated)) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCreateSourceRef(
|
||||
args: Parameters<typeof readCreateSourceSetupHooks>[0],
|
||||
gitOptions: LocalGitExecOptions,
|
||||
step: <T>(work: () => Promise<T>) => Promise<T>
|
||||
): Promise<string | null> {
|
||||
const { repo, source, runtime } = args
|
||||
// Explicit reuse checks out exactly the local branch when it exists, otherwise creates it from the base.
|
||||
if (source.kind === 'branch') {
|
||||
const localRef = `refs/heads/${source.ref}`
|
||||
if (await step(() => hasWorktreeBaseCommitRef(repo.path, localRef, gitOptions))) {
|
||||
return localRef
|
||||
}
|
||||
}
|
||||
const baseBranch = await step(() =>
|
||||
resolveWorktreeCreateBase({
|
||||
requestedBaseBranch: source.baseBranch,
|
||||
repoWorktreeBaseRef: repo.worktreeBaseRef,
|
||||
resolveDefaultBaseRef: () => getDefaultBaseRefAsync(repo.path, gitOptions),
|
||||
isBaseUsable: async (candidate) => {
|
||||
const remoteBase = await runtime.resolveRemoteTrackingBase(repo.path, candidate, gitOptions)
|
||||
if (remoteBase && (await runtime.hasRemoteTrackingRef(repo.path, remoteBase, gitOptions))) {
|
||||
return true
|
||||
}
|
||||
return hasLocalWorktreeBaseRef(repo.path, candidate, gitOptions)
|
||||
}
|
||||
})
|
||||
)
|
||||
if (!baseBranch) {
|
||||
return null
|
||||
}
|
||||
// Refresh exactly when create would, sharing its in-flight fetch and freshness.
|
||||
const remoteBase = await step(() =>
|
||||
runtime.resolveRemoteTrackingBase(repo.path, baseBranch, gitOptions)
|
||||
)
|
||||
if (remoteBase) {
|
||||
const [hasRemoteRef, hasLocalRef] = await step(() =>
|
||||
Promise.all([
|
||||
runtime.hasRemoteTrackingRef(repo.path, remoteBase, gitOptions),
|
||||
hasLocalWorktreeBaseRef(repo.path, baseBranch, gitOptions)
|
||||
])
|
||||
)
|
||||
if (hasRemoteRef || !hasLocalRef) {
|
||||
// Ungated and never aborted: past the deadline create joins this same fetch.
|
||||
await step(() =>
|
||||
runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteBase, args.gitOptions)
|
||||
)
|
||||
}
|
||||
}
|
||||
const qualified = await step(() =>
|
||||
resolveWorktreeAddBaseRef(baseBranch, (candidate) =>
|
||||
hasWorktreeBaseCommitRef(repo.path, candidate, gitOptions)
|
||||
)
|
||||
)
|
||||
return (await step(() => hasWorktreeBaseCommitRef(repo.path, qualified, gitOptions)))
|
||||
? qualified
|
||||
: null
|
||||
}
|
||||
|
||||
async function readOrcaYamlAtRef(
|
||||
repo: Repo,
|
||||
ref: string,
|
||||
gitOptions: LocalGitExecOptions
|
||||
): Promise<{ hooks: OrcaHooks | null } | undefined> {
|
||||
const { stdout: entry } = await gitExecFileAsync(['ls-tree', ref, '--', 'orca.yaml'], {
|
||||
cwd: repo.path,
|
||||
...gitOptions
|
||||
})
|
||||
if (!entry.trim()) {
|
||||
return { hooks: null }
|
||||
}
|
||||
// A symlink's blob is its target path; the checkout follows it, so read the checkout instead.
|
||||
if (!entry.startsWith('100')) {
|
||||
return undefined
|
||||
}
|
||||
const { stdout } = await gitExecFileAsync(['show', `${ref}:orca.yaml`], {
|
||||
cwd: repo.path,
|
||||
...gitOptions
|
||||
})
|
||||
return { hooks: scopeHooksToRepoKind(repo, parseOrcaYaml(stdout)) }
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { WorkspaceCleanupEvaluation } from '../../../shared/workspace-cleanup/workspace-cleanup-evaluation'
|
||||
import type {
|
||||
WorkspaceCleanupEvaluation,
|
||||
WorkspaceCleanupEvaluationInput
|
||||
} from '../../../shared/workspace-cleanup/workspace-cleanup-evaluation'
|
||||
import { hasWorktreeAtRiskLocalFiles } from '../../../shared/worktree/removal/at-risk-local-files'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_REPORT_ROW_LIMIT,
|
||||
WORKSPACE_CLEANUP_ROW_IGNORED_FILE_LIMIT,
|
||||
type WorkspaceCleanupRowDetails,
|
||||
WORKSPACE_CLEANUP_UNWATCHED_CAUSES,
|
||||
type WorkspaceCleanupRun,
|
||||
type WorkspaceCleanupRunOutcome,
|
||||
@@ -12,6 +18,26 @@ export type WorkspaceCleanupKeptOutcome = Pick<
|
||||
'outcome' | 'reason' | 'keepCause' | 'waiting'
|
||||
>
|
||||
|
||||
/** What the evidence already says about a row, for its label; no new Git reads. */
|
||||
export function workspaceCleanupRowDetails(
|
||||
input: Pick<WorkspaceCleanupEvaluationInput, 'git'>
|
||||
): WorkspaceCleanupRowDetails {
|
||||
const review = input.git.merge?.mergedReview
|
||||
const files = input.git.atRiskLocalFiles
|
||||
return {
|
||||
...(review?.containsHead ? { prNumber: review.number } : {}),
|
||||
...(files && hasWorktreeAtRiskLocalFiles(files)
|
||||
? {
|
||||
ignoredFiles: {
|
||||
count: files.count,
|
||||
samplePaths: files.samplePaths.slice(0, WORKSPACE_CLEANUP_ROW_IGNORED_FILE_LIMIT),
|
||||
complete: files.complete
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function keptRunOutcome(
|
||||
evaluation: WorkspaceCleanupEvaluation
|
||||
): WorkspaceCleanupKeptOutcome {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../runtime/orca-runtime'
|
||||
import type { RuntimeStore } from '../../runtime/construction/runtime-store-contract'
|
||||
import { getDefaultWorkspaceCleanupRules } from '../../../shared/workspace-cleanup/workspace-cleanup-policy'
|
||||
|
||||
const { collectWorkspaces } = vi.hoisted(() => ({ collectWorkspaces: vi.fn() }))
|
||||
|
||||
vi.mock('./workspace-cleanup-run-evidence', () => ({
|
||||
collectWorkspaceCleanupWorkspaces: collectWorkspaces
|
||||
}))
|
||||
vi.mock('../space-analysis/workspace-space-analysis-snapshot', () => ({
|
||||
readWorkspaceSpaceAnalysisSnapshot: () => Promise.resolve(null)
|
||||
}))
|
||||
|
||||
const { WorkspaceCleanupRunner } = await import('./workspace-cleanup-runner')
|
||||
|
||||
function createRunner(): InstanceType<typeof WorkspaceCleanupRunner> {
|
||||
const store = {
|
||||
getSettings: () => ({ workspaceCleanupPolicy: getDefaultWorkspaceCleanupRules() })
|
||||
}
|
||||
return new WorkspaceCleanupRunner({
|
||||
// SAFETY: preview only reads settings from the store and passes the runtime through to mocked evidence.
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above
|
||||
store: store as unknown as RuntimeStore,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above
|
||||
runtime: {} as OrcaRuntimeService,
|
||||
historyDirectory: '/unused'
|
||||
})
|
||||
}
|
||||
|
||||
describe('workspace cleanup runner preview', () => {
|
||||
afterEach(() => {
|
||||
collectWorkspaces.mockReset()
|
||||
})
|
||||
|
||||
it('shares one evaluation between concurrent previews of the same rules', async () => {
|
||||
let finish: (value: []) => void = () => {}
|
||||
collectWorkspaces.mockReturnValue(new Promise<[]>((resolve) => (finish = resolve)))
|
||||
const runner = createRunner()
|
||||
|
||||
const first = runner.preview()
|
||||
const second = runner.preview(getDefaultWorkspaceCleanupRules())
|
||||
finish([])
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(collectWorkspaces).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
WorkspaceCleanupReport,
|
||||
WorkspaceCleanupRun,
|
||||
WorkspaceCleanupRunRow,
|
||||
WorkspaceCleanupRowDetails,
|
||||
WorkspaceCleanupRunTrigger,
|
||||
WorkspaceCleanupWorkspaceRef
|
||||
} from '../../../shared/workspace-cleanup/workspace-cleanup-run'
|
||||
@@ -24,7 +25,11 @@ import {
|
||||
type WorkspaceCleanupWorkspace
|
||||
} from './workspace-cleanup-run-evidence'
|
||||
import { decideWorkspaceCleanup, removeWorkspaceForCleanup } from './workspace-cleanup-removal-step'
|
||||
import { keptRunOutcome, summarizeWorkspaceCleanupRun } from './workspace-cleanup-run-summary'
|
||||
import {
|
||||
keptRunOutcome,
|
||||
summarizeWorkspaceCleanupRun,
|
||||
workspaceCleanupRowDetails
|
||||
} from './workspace-cleanup-run-summary'
|
||||
import {
|
||||
appendWorkspaceCleanupRun,
|
||||
readWorkspaceCleanupRunHistory
|
||||
@@ -39,7 +44,7 @@ const SETTINGS_DEBOUNCE_MS = 10 * 1000
|
||||
|
||||
type DecidedWorkspace = {
|
||||
workspace: WorkspaceCleanupWorkspace
|
||||
ref: WorkspaceCleanupWorkspaceRef & { sizeBytes?: number }
|
||||
ref: WorkspaceCleanupWorkspaceRef & WorkspaceCleanupRowDetails & { sizeBytes?: number }
|
||||
evaluation: WorkspaceCleanupEvaluation
|
||||
}
|
||||
|
||||
@@ -54,6 +59,8 @@ export type WorkspaceCleanupRunnerDeps = {
|
||||
export class WorkspaceCleanupRunner {
|
||||
private readonly deps: WorkspaceCleanupRunnerDeps
|
||||
private current: Promise<WorkspaceCleanupRun> | null = null
|
||||
// Every caller asking for the same rules while one evaluation runs shares it: reopened pages, several clients.
|
||||
private readonly previews = new Map<string, Promise<WorkspaceCleanupPreview>>()
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private nextRunAt: number | null = null
|
||||
private enabledRules = new Set<string>()
|
||||
@@ -107,9 +114,28 @@ export class WorkspaceCleanupRunner {
|
||||
}
|
||||
|
||||
/** What a run would do now, removing nothing; `rules` previews other global rules. */
|
||||
async preview(rules?: WorkspaceCleanupRules): Promise<WorkspaceCleanupPreview> {
|
||||
preview(rules?: WorkspaceCleanupRules): Promise<WorkspaceCleanupPreview> {
|
||||
const effective = rules ?? this.globalRules()
|
||||
const key = JSON.stringify(effective)
|
||||
const shared = this.previews.get(key)
|
||||
if (shared) {
|
||||
return shared
|
||||
}
|
||||
const evaluation = this.evaluatePreview(effective)
|
||||
this.previews.set(key, evaluation)
|
||||
void evaluation
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.previews.get(key) === evaluation) {
|
||||
this.previews.delete(key)
|
||||
}
|
||||
})
|
||||
return evaluation
|
||||
}
|
||||
|
||||
private async evaluatePreview(rules: WorkspaceCleanupRules): Promise<WorkspaceCleanupPreview> {
|
||||
const evaluatedAt = Date.now()
|
||||
const decided = await this.evaluate(rules ?? this.globalRules(), evaluatedAt)
|
||||
const decided = await this.evaluate(rules, evaluatedAt)
|
||||
return { evaluatedAt, rows: decided.map(({ ref, evaluation }) => ({ ...ref, evaluation })) }
|
||||
}
|
||||
|
||||
@@ -193,7 +219,11 @@ export class WorkspaceCleanupRunner {
|
||||
const sizeBytes = sizes.get(workspace.ref.worktreeId)
|
||||
decided.push({
|
||||
workspace,
|
||||
ref: { ...workspace.ref, ...(sizeBytes !== undefined ? { sizeBytes } : {}) },
|
||||
ref: {
|
||||
...workspace.ref,
|
||||
...workspaceCleanupRowDetails(workspace.input),
|
||||
...(sizeBytes !== undefined ? { sizeBytes } : {})
|
||||
},
|
||||
evaluation: await decideWorkspaceCleanup(this.deps.runtime, workspace, now)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { OrcaHooks } from '../../../shared/orca-yaml-hook-types'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { SetupScriptImportCandidate } from '../../../shared/workspace-setup/setup-script-imports'
|
||||
import type { SetupHooksCreateSource } from '../../../shared/worktree/setup-hooks-create-source'
|
||||
|
||||
export type HooksApi = {
|
||||
/** `background`: a render-driven probe, refused (status 'error') rather than booting a stopped WSL distro. */
|
||||
check: (args: { repoId: string; hostId?: ExecutionHostId; background?: boolean }) => Promise<{
|
||||
/** `background`: a render-driven probe, refused (status 'error') rather than booting a stopped WSL distro. `createSource`: read orca.yaml at the ref a create checks out. */
|
||||
check: (args: {
|
||||
repoId: string
|
||||
hostId?: ExecutionHostId
|
||||
background?: boolean
|
||||
createSource?: SetupHooksCreateSource
|
||||
}) => Promise<{
|
||||
status?: 'ok' | 'error'
|
||||
hasHooks: boolean
|
||||
hooks: OrcaHooks | null
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { PreloadApi } from '../../api-types'
|
||||
import type { SetupHooksCreateSource } from '../../../shared/worktree/setup-hooks-create-source'
|
||||
|
||||
export const hooksApi = {
|
||||
check: (args: { repoId: string; hostId?: ExecutionHostId; background?: boolean }) =>
|
||||
ipcRenderer.invoke('hooks:check', args),
|
||||
check: (args: {
|
||||
repoId: string
|
||||
hostId?: ExecutionHostId
|
||||
background?: boolean
|
||||
createSource?: SetupHooksCreateSource
|
||||
}) => ipcRenderer.invoke('hooks:check', args),
|
||||
|
||||
inspectSetupScriptImports: (args: { repoId: string; hostId?: ExecutionHostId }) =>
|
||||
ipcRenderer.invoke('hooks:inspectSetupScriptImports', args),
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
WorkspaceSpaceScanProgress
|
||||
} from '../../../shared/workspace-disk-space/workspace-space-types'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { WorkspaceCleanupRunFinishedEvent } from '../../../shared/workspace-cleanup/workspace-cleanup-run'
|
||||
import type { WorkspaceSpaceChange } from '../../../shared/workspace-disk-space/workspace-space-change'
|
||||
|
||||
export type WorkspaceCleanupApi = {
|
||||
@@ -27,6 +28,8 @@ export type WorkspaceCleanupApi = {
|
||||
beginRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise<void>
|
||||
recordRemovalSnapshotPrune?: (args: WorkspaceCleanupSnapshotPruneRecordArgs) => Promise<void>
|
||||
finishRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise<void>
|
||||
/** This app's own cleanup runner finished a run. */
|
||||
onRunFinished: (callback: (run: WorkspaceCleanupRunFinishedEvent['run']) => void) => () => void
|
||||
}
|
||||
|
||||
export type WorkspaceSpaceApi = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type { WorkspaceCleanupScanProgress } from '../../../shared/workspace-cleanup/workspace-cleanup'
|
||||
import type { WorkspaceCleanupRunFinishedEvent } from '../../../shared/workspace-cleanup/workspace-cleanup-run'
|
||||
import type { PreloadApi } from '../../api-types'
|
||||
|
||||
export const workspaceCleanupApi = {
|
||||
@@ -30,5 +31,13 @@ export const workspaceCleanupApi = {
|
||||
recordRemovalSnapshotPrune: (args) =>
|
||||
ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args),
|
||||
finishRemovalSnapshotPruneBatch: (args) =>
|
||||
ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args)
|
||||
ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args),
|
||||
onRunFinished: (callback) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
run: WorkspaceCleanupRunFinishedEvent['run']
|
||||
): void => callback(run)
|
||||
ipcRenderer.on('workspaceCleanup:runFinished', listener)
|
||||
return () => ipcRenderer.removeListener('workspaceCleanup:runFinished', listener)
|
||||
}
|
||||
} satisfies PreloadApi['workspaceCleanup']
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
AutomationUpdateInput
|
||||
} from '../../../../../shared/automations/automation-definition'
|
||||
import { getRepoExecutionHostId } from '../../../../../shared/execution-host'
|
||||
import { ensureHooksApproved } from '@/runtime/repo-hooks/ensure-hooks-confirmed'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import { listAutomationsForTarget } from '../hosts/automation-host-client'
|
||||
@@ -28,7 +27,7 @@ import {
|
||||
import { buildDraftPrecheck, buildDraftSchedule } from './automation-draft-model'
|
||||
import { buildAutomationRunContextForRepo } from './automation-run-context'
|
||||
import { resolveAutomationSetupDecisionForSave } from './automation-setup-decision'
|
||||
import { reconfirmSkippedSetup } from './automation-setup-review'
|
||||
import { confirmAutomationSaveSetup } from './automation-setup-review'
|
||||
import {
|
||||
createAutomationOnDestination,
|
||||
moveAutomationToDestination,
|
||||
@@ -135,14 +134,17 @@ export async function saveOrcaAutomation(
|
||||
draftSetupDecision: draft.setupDecision
|
||||
})
|
||||
: currentAutomation.setupDecision
|
||||
const trust =
|
||||
setupDecision === 'run' && ((targetEdited && !recordedContext) || edited('setupDecision'))
|
||||
? await ensureHooksApproved(useAppStore.getState(), draft.projectId, 'setup', setupHostId)
|
||||
: null
|
||||
// Stored with `run` so later runs execute only this content.
|
||||
const setupTrustHash =
|
||||
trust?.approvedContentHash ??
|
||||
(await reconfirmSkippedSetup(context, trust, setupDecision, currentAutomation, setupHostId))
|
||||
const { trust, setupTrustHash } = await confirmAutomationSaveSetup(context, {
|
||||
// A new base can bring different orca.yaml setup, so it needs its own approval.
|
||||
prompt:
|
||||
setupDecision === 'run' &&
|
||||
((targetEdited && !recordedContext) || edited('setupDecision') || edited('baseBranch')),
|
||||
repoId: draft.projectId,
|
||||
hostId: setupHostId,
|
||||
baseBranch: edited('baseBranch') ? draft.baseBranch : currentAutomation?.baseBranch,
|
||||
setupDecision,
|
||||
automation: currentAutomation
|
||||
})
|
||||
if (trust?.decision === 'skip') {
|
||||
setupDecision = 'skip'
|
||||
}
|
||||
|
||||
@@ -13,6 +13,25 @@ import { getAutomationRunRepoId } from '../../../../../shared/automations/histor
|
||||
import type { SetupDecision, SetupTrustMismatch } from '../../../../../shared/worktree/create-types'
|
||||
import type { ExecutionHostId } from '../../../../../shared/execution-host'
|
||||
|
||||
/** Runs create from the automation's base, so approve the orca.yaml there, not the project checkout's. */
|
||||
export function approveAutomationSetup(
|
||||
repoId: string,
|
||||
hostId: ExecutionHostId | undefined,
|
||||
baseBranch: string | null | undefined
|
||||
): Promise<HookTrustApproval> {
|
||||
const createSource = { kind: 'base' as const, baseBranch: baseBranch?.trim() || undefined }
|
||||
return ensureHooksApproved(
|
||||
useAppStore.getState(),
|
||||
repoId,
|
||||
'setup',
|
||||
hostId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
createSource
|
||||
)
|
||||
}
|
||||
|
||||
/** A skipped setup still waiting for approval; null once the automation approves that content. */
|
||||
export type PendingAutomationSetupReview = { content: SetupTrustMismatch | null }
|
||||
|
||||
@@ -71,15 +90,48 @@ export async function reviewAutomationSkippedSetup(
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving an automation whose last run skipped changed setup re-confirms that setup: the skipped
|
||||
* content when the host recorded it, else the repo's current one. Returns the hash to store.
|
||||
* A save's setup approval: prompt when the target or setup choice changed, else re-review a skipped run.
|
||||
* The hash is stored with `run` so later runs execute only this content.
|
||||
*/
|
||||
export async function reconfirmSkippedSetup(
|
||||
export async function confirmAutomationSaveSetup(
|
||||
context: Pick<AutomationSaveContext, 'local' | 'list'>,
|
||||
args: {
|
||||
prompt: boolean
|
||||
repoId: string
|
||||
hostId: ExecutionHostId | undefined
|
||||
/** The base this save persists, which an edit may have just changed. */
|
||||
baseBranch: string | null | undefined
|
||||
setupDecision: SetupDecision | undefined
|
||||
automation: Automation | null
|
||||
}
|
||||
): Promise<{ trust: HookTrustApproval | null; setupTrustHash: string | undefined }> {
|
||||
const trust = args.prompt
|
||||
? await approveAutomationSetup(args.repoId, args.hostId, args.baseBranch)
|
||||
: null
|
||||
const setupTrustHash =
|
||||
trust?.approvedContentHash ??
|
||||
(await reconfirmSkippedSetup(
|
||||
context,
|
||||
trust,
|
||||
args.setupDecision,
|
||||
args.automation,
|
||||
args.hostId,
|
||||
args.baseBranch
|
||||
))
|
||||
return { trust, setupTrustHash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving an automation whose last run skipped changed setup re-confirms that setup: the skipped
|
||||
* content when the host recorded it, else the content at the automation's base. Returns the hash to store.
|
||||
*/
|
||||
async function reconfirmSkippedSetup(
|
||||
context: Pick<AutomationSaveContext, 'local' | 'list'>,
|
||||
trust: HookTrustApproval | null,
|
||||
setupDecision: SetupDecision | undefined,
|
||||
automation: Automation | null,
|
||||
hostId: ExecutionHostId | undefined
|
||||
hostId: ExecutionHostId | undefined,
|
||||
baseBranch: string | null | undefined
|
||||
): Promise<string | undefined> {
|
||||
// A changed target or setup choice was just confirmed by the regular save prompt.
|
||||
if (trust || context.local.targetEdited || setupDecision !== 'run' || !automation) {
|
||||
@@ -100,7 +152,7 @@ export async function reconfirmSkippedSetup(
|
||||
hostId,
|
||||
'automation'
|
||||
)
|
||||
: await ensureHooksApproved(useAppStore.getState(), repoId, 'setup', hostId)
|
||||
: await approveAutomationSetup(repoId, hostId, baseBranch)
|
||||
return approval.decision === 'run' && approval.approvedContentHash !== automation.setupTrustHash
|
||||
? approval.approvedContentHash
|
||||
: undefined
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user