mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* [comment audit] Align legacy failure-read counts with cached display * [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci] * [comment audit] Condense shared generation, path and preview comments [skip ci] * [comment audit] Condense host, file and Git contract rationale [skip ci] * [comment audit] Tighten Git lifecycle and provider contract comments [skip ci] * [comment audit] Clarify shared host authority and launch isolation [skip ci] * [comment audit] simplify local access mobile and native chat contracts [skip ci] * [comment audit] simplify native chat presentation and state contracts [skip ci] * [comment audit] simplify native runtime and provider contracts [skip ci] * [comment audit] simplify migration and orchestration contracts [skip ci] * [comment audit] simplify plugin trust and process capture rationale [skip ci] * [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci] * [comment audit] clarify remote recovery and launch wire rationale [skip ci] * [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci] * [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci] * [comment audit] clarify sidebar, skill and SSH invariants [skip ci] * [comment audit] condense structured chat delivery and timing rationale [skip ci] * [comment audit] condense terminal replay and telemetry invariants [skip ci] * [comment audit] clarify launch permission and terminal delivery rationale [skip ci] * [comment audit] clarify workspace ownership and layout contracts [skip ci] * [comment audit] wait for fitted and painted SSH terminal links * [comment audit] clarify layout persistence and session ownership [skip ci] * [comment audit] clarify host routing and workspace lifecycle contracts [skip ci] * [comment audit] clarify startup persistence and app lifetime comments [skip ci] * [comment audit] clarify recovery and activity boundaries [skip ci] * [comment audit] condense activity and artifact invariants [skip ci] * [comment audit] condense automation authority contracts [skip ci] * [comment audit] simplify automation scheduling and history contracts [skip ci] * [comment audit] clarify browser chrome and annotation lifecycles [skip ci] * [comment audit] clarify browser routing and retained guest ownership [skip ci] * [comment audit] clarify browser recovery and document ownership comments [skip ci] * [comment audit] restore hosted runners required by native IME fixtures * [comment audit] shorten preview, palette and dashboard invariants [skip ci] * [comment audit] clarify terminal preview, dashboard and retention comments [skip ci] * [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci] * [comment audit] shorten rich-text and combined-diff ownership comments [skip ci] * [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci] * [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci] * [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci] * [comment audit] clarify rich Markdown source and selection safeguards [skip ci] * [comment audit] clarify editor loading and emulator stream lifetimes [skip ci] * [comment audit] trim emulator and feature tour narration [skip ci] * [comment audit] clarify floating workspace and GitHub cache ownership [skip ci] * [comment audit] trim provider controls and Linear filter explanations [skip ci] * [comment audit] clarify pairing and chat resume lifetimes [skip ci] * [comment audit] clarify chat delivery and transcript lifetimes [skip ci] * [comment audit] clarify session options and host-owned status [skip ci] * [comment audit] clarify draft durability and live-status precedence [skip ci] * [comment audit] Trim chat send and model ownership explanations [skip ci] * [comment audit] Shorten transcript ownership and send recovery comments [skip ci] * [comment audit] Simplify native chat composer and transcript hook comments [skip ci] * [comment audit] Shorten native chat scroll and structured session explanations [skip ci] * [comment audit] Trim workspace picker and onboarding commentary [skip ci] * [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci] * [comment audit] Trim sidebar and session history explanations [skip ci] * [comment audit] Condense session resume and review checks comments [skip ci] * [comment audit] Shorten checks state and file explorer refresh comments [skip ci] * [comment audit] Trim plugin bridge and source-control action rationale [skip ci] * [comment audit] Trim source-control listing and review rationale [skip ci] * [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci] * [comment audit] Condense Settings account and appearance rationale [skip ci] * [comment audit] Trim pairing and repository Settings explanations [skip ci] * [comment audit] Condense Settings search SSH and terminal safeguards [skip ci] * [comment audit] Shorten Settings voice and provider ownership comments [skip ci] * [comment audit] Condense Settings draft persistence and host-state rationale [skip ci] * [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci] * [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci] * [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci] * [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci] * [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci] * [comment audit] Prune focus-read baseline after comment cleanup * [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci] * [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci] * [comment audit] Clarify viewport geometry and skill update safeguards [skip ci] * [comment audit] Tighten skill inventory, usage and host status comments [skip ci] * [comment audit] Clarify usage ownership and resource kill safeguards [skip ci] * [comment audit] Tighten tab selection, rename and search contracts [skip ci] * [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci] * [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci] * [comment audit] Tighten provider loading and terminal recovery comments [skip ci] * [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci] * [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci] * [comment audit] Tighten PTY startup and replay explanations [skip ci] * [comment audit] Clarify PTY identity and delivery ownership [skip ci] * [comment audit] Shorten remote reconnect and input replay rationale [skip ci] * [comment audit] Condense terminal parking clipboard and file link comments [skip ci] * [comment audit] Clarify IME ownership and trim trace history [skip ci] * [comment audit] Shorten pane recovery and parked watcher explanations [skip ci] * [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci] * [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci] * [comment audit] Trim renderer lifecycle and menu rationale [skip ci] * [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci] * [comment audit] Clarify renderer hook evidence and source ownership [skip ci] * [comment audit] Tighten IPC ownership and reconnect explanations [skip ci] * [comment audit] Trim renderer focus, paste and attention comments [skip ci] * [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci] * [comment audit] Trim agent launch, resume and ownership commentary [skip ci] * [comment audit] Shorten account, browser and editor comments [skip ci] * [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci] * [comment audit] Condense launch recovery and editor tokenization comments [skip ci] * [comment audit] Condense workspace selection and terminal pane rationale [skip ci] * [comment audit] Condense terminal fitting and rendering recovery comments [skip ci] * [comment audit] Condense terminal input and host routing explanations [skip ci] * [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci] * [comment audit] Condense structured launch and terminal link explanations [skip ci] * [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci] * [comment audit] Condense session persistence and workspace activation rationale [skip ci] * [comment audit] Condense workspace routing and browser ownership rationale [skip ci] * [comment audit] Condense mirror recovery and layout authority rationale [skip ci] * [comment audit] Condense runtime transport and file safety rationale [skip ci] * [comment audit] Condense runtime graph and structured session rationale [skip ci] * [comment audit] Condense browser creation and remote tab recovery rationale [skip ci] * [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci] * [comment audit] Condense frame, startup and provider cache rationale [skip ci] * [comment audit] Condense host twin and agent lifecycle rationale [skip ci] * [comment audit] Condense browser authority and editor cleanup rationale [skip ci] * [comment audit] Condense editor buffer identity and review cache rationale [skip ci] * [comment audit] Condense provider lifetime and repository ownership rationale [skip ci] * [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci] * [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci] * [comment audit] Condense cleanup ownership and listing admission rationale [skip ci] * [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci] * [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci] * [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci] * [comment audit] Condense cloud rollout and assignment safety rationale [skip ci] * [comment audit] Condense relay session and database recovery rationale [skip ci] * [comment audit] Condense relay placement and protocol compatibility rationale [skip ci] * [comment audit] Condense build and packaging constraints [skip ci] * [comment audit] Condense CI gates and validation isolation rationale [skip ci] * [comment audit] Condense catalog identity and browser validation rationale [skip ci] * [comment audit] Condense mobile browser and frame-budget rationale [skip ci] * [comment audit] Shorten mobile route and browser test explanations [skip ci] * [comment audit] Condense editor and session seam test rationale [skip ci] * [comment audit] Trim session and terminal closure history [skip ci] * [comment audit] Shorten terminal browser and census explanations [skip ci] * [comment audit] Preserve root-only E2E diagnostic baseline * [comment audit] Condense mobile seam and HTML preview test rationale [skip ci] * [comment audit] Condense preview instrumentation and render harness rationale [skip ci] * [comment audit] Condense bundle contracts and native-build constraints [skip ci] * [comment audit] Shorten native packaging and CI routing rationale [skip ci] * [comment audit] Condense release tooling and benchmark constraints [skip ci] * [comment audit] Condense packaging and platform gate rationale [skip ci] * [comment audit] Shorten mobile route and history rationale [skip ci] * [comment audit] Condense mobile browser and component rationale [skip ci] * [comment audit] Shorten drawer and workspace-form rationale [skip ci] * [comment audit] Trim mobile review and editor-document explanations [skip ci] * [comment audit] Condense editor lifecycle and Markdown explanations [skip ci] * [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci] * [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci] * [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci] * [comment audit] Condense bridge stream, native request and audio rationale [skip ci] * [comment audit] Reduce bridge schema and page-client commentary [skip ci] * [comment audit] Tighten mobile page bridge contracts [skip ci] * [comment audit] Clarify mobile cache and session constraints [skip ci] * [comment audit] Condense mobile route and lifecycle rationale [skip ci] * [comment audit] Tighten mobile shell hook timing rationale [skip ci] * [comment audit] Clarify route handoff and notification compatibility [skip ci] * [comment audit] Clarify push registration and dictation cleanup constraints [skip ci] * [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci] * [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci] * [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci] * [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci] * [comment audit] Tighten stream replay and queued session ownership rationale [skip ci] * [comment audit] Condense mobile composer and reply-schema constraints [skip ci] * [comment audit] Clarify mobile transcript recovery and session ownership [skip ci] * [comment audit] Clarify mobile queue and source-control compatibility [skip ci] * [comment audit] Condense storage and task launch constraints [skip ci] * [comment audit] Clarify task schemas and terminal document ownership [skip ci] * [comment audit] Tighten terminal input and viewport rationale [skip ci] * [comment audit] Shorten terminal and RPC recorder contracts [skip ci] * [comment audit] Condense recorder engine and connection contracts [skip ci] * [comment audit] Shorten host credential and relay recovery comments [skip ci] * [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci] * [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci] * [comment audit] Condense catalog and relay entry contracts [skip ci] * [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci] * [comment audit] Condense WSL relay and E2E regression rationale [skip ci] * [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci] * [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci] * [comment audit] Shorten layout oracle and regression explanations [skip ci] * [comment audit] Condense paired recovery and ownership regression contracts [skip ci] * [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci] * [comment audit] Condense terminal rendering and IME regression rationale [skip ci] * [comment audit] Shorten terminal recovery and benchmark contracts [skip ci] * [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci] * [comment audit] Condense workflow routing and release rationale [skip ci] * [comment audit] Condense mobile security and installer contracts [skip ci] * [comment audit] Shorten native rationale and stylesheet workarounds [skip ci] * [comment audit] Shorten Windows visibility probe rationale [skip ci] * [comment audit] Condense embedded release and signing rationale [skip ci] * [comment audit] Retain current main test behavior in final reconciliation [skip ci] * [comment audit] Complete final comment review across current source inventory * [comment audit] Handle consolidated audit reports before final CI * [comment audit] Preserve credential storage census detail [skip ci] * [comment audit] Scale broad changed E2E selections with verified timing shards * [comment audit] Align E2E assertions with browser close and settled session metadata * [comment audit] Match existing E2Es to current resource and windowing contracts * [comment audit] Observe the shared server outage notice in reconnect coverage * [comment audit] Make existing renderer probes measure settled tab and PTY work * [comment audit] Align palette E2E selector with task search * [comment audit] Refresh remaining palette search selectors * [comment audit] Keep selector correction within selected audit specs * [comment audit] Await emoji fixture settings inside renderer evaluation * [comment audit] Cancel fixture fit retries between terminal tests --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2312 lines
93 KiB
TypeScript
2312 lines
93 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import {
|
|
ASSIGNMENT_LIMITS,
|
|
CONTROL_CONTINUITY_LIMITS,
|
|
RELAY_CLOSE_CODE,
|
|
RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS,
|
|
RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME,
|
|
RELAY_PROTOCOL_LIMITS
|
|
} from '@orca-cloud/relay-contract'
|
|
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
|
import type WebSocket from 'ws'
|
|
import type { RelayAssignmentStore } from './assignment-store.js'
|
|
import type { RelayConfig } from './config.js'
|
|
import { RelayStoreError, type RelayCredentialStore } from './credential-store.js'
|
|
import { CONTROL_RENEWAL_BATCH_INTERVAL_MS } from './control-renewal-batch.js'
|
|
import {
|
|
type ControlRenewalOutcome,
|
|
type ControlRenewalRequest
|
|
} from './control-renewal-statement.js'
|
|
import {
|
|
HostSessionRegistry,
|
|
IDLE_REHOME_MIN_CONTROL_AGE_MS,
|
|
type HostSession
|
|
} from './host-session-registry.js'
|
|
import { relayHostLogDigest } from './relay-host-log-digest.js'
|
|
import type { RelayRuntimeObserver } from './relay-observability.js'
|
|
import {
|
|
REGIONAL_REHOME_TRUST_PROBE_ATTEMPT_ID,
|
|
REGIONAL_REHOME_TRUST_PROBE_HOST_ID,
|
|
REGIONAL_REHOME_TRUST_PROBE_USER_ID
|
|
} from './regional-rehome-trust-probe.js'
|
|
import type { RelayTokenClaims } from './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()
|
|
})
|
|
|
|
it('logs control closes with a host digest and counts them, never the raw id', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { activate, observer } = createRegistry(activateControl)
|
|
const socket = new FakeSocket()
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
try {
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
socket.emit('error', new RangeError('Max payload size exceeded'))
|
|
socket.close(1006, 'network reset')
|
|
|
|
expect(observer.recordControlClose).toHaveBeenCalledWith(1006)
|
|
const line = warn.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.find((entry) => entry.includes('control closed'))
|
|
expect(line).toContain(`host=${relayHostLogDigest(identity.relayHostId)}`)
|
|
expect(line).toContain('code=1006')
|
|
expect(line).toContain('Max payload size exceeded')
|
|
expect(line).not.toContain(identity.relayHostId)
|
|
} finally {
|
|
warn.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('contains a dependency failure to one socket instead of the process', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
// The same rejection shape as a pg-pool connect timeout; unguarded, it
|
|
// became an unhandled rejection that crashed whole production cells.
|
|
const verifyRelayToken = vi.fn(async (): Promise<RelayTokenClaims | null> => {
|
|
throw new Error('Connection terminated due to connection timeout')
|
|
})
|
|
const { activate } = createRegistry(activateControl, {}, verifyRelayToken)
|
|
const socket = new FakeSocket()
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
try {
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 7)
|
|
socket.emit(
|
|
'message',
|
|
Buffer.from(JSON.stringify({ type: 'auth-refresh', relayJwt: 'refreshed' })),
|
|
false
|
|
)
|
|
await vi.waitFor(() =>
|
|
expect(socket.close).toHaveBeenCalledWith(
|
|
RELAY_CLOSE_CODE.LIMIT_EXCEEDED,
|
|
'relay temporarily unavailable'
|
|
)
|
|
)
|
|
expect(warn).toHaveBeenCalledWith(
|
|
'[orca-relay] auth refresh failed: Connection terminated due to connection timeout'
|
|
)
|
|
} finally {
|
|
warn.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('attributes a control close to its client build without trusting the version string', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { activate } = createRegistry(activateControl)
|
|
const socket = new FakeSocket()
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
try {
|
|
// A client controls this string, so it must be bounded and stripped like any close reason.
|
|
await activate(
|
|
socket as unknown as WebSocket,
|
|
identity,
|
|
null,
|
|
1,
|
|
false,
|
|
1,
|
|
`1.4.173\n${'x'.repeat(200)}`
|
|
)
|
|
socket.close(4408, 'replaced by a newer generation')
|
|
|
|
const line = warn.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.find((entry) => entry.includes('control closed'))
|
|
expect(line).toContain('app="1.4.173')
|
|
// The raw newline is escaped by JSON.stringify either way; only its escaped
|
|
// form proves the strip ran, so assert on that.
|
|
expect(line).not.toContain('\\n')
|
|
expect(line).toMatch(/app="[^"]{1,80}"/)
|
|
} finally {
|
|
warn.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('reports the work a generation replacement destroyed, not the drained aftermath', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate } = createRegistry(activateControl, {
|
|
failReservation: vi.fn().mockResolvedValue(undefined)
|
|
})
|
|
const firstSocket = new FakeSocket()
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
try {
|
|
await activate(firstSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const session = registry.get({
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId
|
|
})!
|
|
// Teardown drains both maps before closing the socket, so a close handler that
|
|
// reads them live always reports zero regardless of what was actually killed.
|
|
session.activeSplices.set('conn-a', () => session.activeSplices.delete('conn-a'))
|
|
session.activeSplices.set('conn-b', () => session.activeSplices.delete('conn-b'))
|
|
const clientSocket = new FakeSocket()
|
|
session.pendingConns.set('conn-c', {
|
|
connId: 'conn-c',
|
|
connTicket: 'ticket',
|
|
reservation: { userId: identity.sub, relayHostId: identity.relayHostId },
|
|
client: clientSocket as unknown as WebSocket,
|
|
attachTimer: setTimeout(() => {}, 60_000),
|
|
credentialActivityId: null
|
|
} as unknown as Parameters<typeof session.pendingConns.set>[1])
|
|
|
|
const secondSocket = new FakeSocket()
|
|
await activate(secondSocket as unknown as WebSocket, identity, session, 2, false, 1)
|
|
|
|
const line = warn.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.find((entry) => entry.includes('replaced by a newer generation'))
|
|
expect(line).toContain('splices=2')
|
|
expect(line).toContain('pending=1')
|
|
} finally {
|
|
warn.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('keeps the first drain snapshot when a drain is retried', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate } = createRegistry(activateControl)
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
// Captured before draining, because teardown removes the session from the map.
|
|
const session = registry.get({
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId
|
|
})!
|
|
session.activeSplices.set('conn-a', () => session.activeSplices.delete('conn-a'))
|
|
|
|
// Drain retries/signals must re-arm, not stack teardown; a paced 800-host cell otherwise leaks 800 timers holding exit open each retry.
|
|
registry.drain(0)
|
|
const scheduled = vi.getTimerCount()
|
|
registry.drain(0)
|
|
// Compare against the count before the retry rather than an absolute, since the
|
|
// session's heartbeat interval is also pending.
|
|
expect(vi.getTimerCount()).toBe(scheduled)
|
|
vi.advanceTimersByTime(1)
|
|
|
|
// Check state: synchronous FakeSocket close logs before second teardown, so log-only assertions miss corruption.
|
|
expect(session.closingCounts).toEqual({ splices: 1, pending: 0 })
|
|
})
|
|
|
|
it('drains only the incarnation-bound host and makes replay idempotent', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate } = createRegistry(
|
|
activateControl,
|
|
{},
|
|
vi.fn(async () => identity)
|
|
)
|
|
const firstSocket = new FakeSocket()
|
|
const secondSocket = new FakeSocket()
|
|
const secondIdentity = {
|
|
...identity,
|
|
sub: 'user-2',
|
|
relayHostId: 'ponmlkjihgfedcba'
|
|
}
|
|
await activate(firstSocket as unknown as WebSocket, identity, null, 1, false, 7)
|
|
await activate(secondSocket as unknown as WebSocket, secondIdentity, null, 1, false, 3)
|
|
const trustProbe = {
|
|
attemptId: REGIONAL_REHOME_TRUST_PROBE_ATTEMPT_ID,
|
|
userId: REGIONAL_REHOME_TRUST_PROBE_USER_ID,
|
|
relayHostId: REGIONAL_REHOME_TRUST_PROBE_HOST_ID,
|
|
sourceAssignmentEpoch: 1,
|
|
graceMs: 0
|
|
}
|
|
expect(registry.get(trustProbe)).toBeNull()
|
|
expect(registry.drainHost(trustProbe)).toBe('host-not-connected')
|
|
expect(registry.drainHost(trustProbe)).toBe('host-not-connected')
|
|
expect(firstSocket.send).not.toHaveBeenCalledWith(expect.stringContaining('"drain"'))
|
|
expect(secondSocket.send).not.toHaveBeenCalledWith(expect.stringContaining('"drain"'))
|
|
const request = {
|
|
attemptId: '11111111-1111-4111-8111-111111111111',
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId,
|
|
sourceAssignmentEpoch: 7,
|
|
graceMs: 30_000
|
|
}
|
|
|
|
expect(registry.drainHost(request)).toBe('accepted')
|
|
expect(firstSocket.send).toHaveBeenCalledWith(
|
|
JSON.stringify({ type: 'drain', graceMs: 30_000, recovery: 'resolve-director' })
|
|
)
|
|
expect(secondSocket.send).not.toHaveBeenCalledWith(expect.stringContaining('"drain"'))
|
|
firstSocket.emit(
|
|
'message',
|
|
Buffer.from(JSON.stringify({ type: 'auth-refresh', relayJwt: 'refreshed' })),
|
|
false
|
|
)
|
|
await vi.waitFor(() => expect(registry.get(request)?.state).toBe('drain-only'))
|
|
const timers = vi.getTimerCount()
|
|
expect(registry.drainHost(request)).toBe('already-accepted')
|
|
expect(vi.getTimerCount()).toBe(timers)
|
|
expect(() =>
|
|
registry.drainHost({
|
|
...request,
|
|
attemptId: '22222222-2222-4222-8222-222222222222'
|
|
})
|
|
).toThrow('regional_rehome_attempt_conflict')
|
|
expect(() => registry.drainHost({ ...request, sourceAssignmentEpoch: 8 })).toThrow(
|
|
'regional_rehome_assignment_epoch_mismatch'
|
|
)
|
|
|
|
const rebound = new FakeSocket()
|
|
await activate(rebound as unknown as WebSocket, identity, registry.get(request), 1, true, 7)
|
|
expect(registry.get(request)?.state).toBe('drain-only')
|
|
expect(rebound.send).toHaveBeenCalledWith(expect.stringContaining('"type":"drain"'))
|
|
|
|
await vi.advanceTimersByTimeAsync(30_000)
|
|
expect(registry.get(request)).toBeNull()
|
|
expect(
|
|
registry.get({ userId: secondIdentity.sub, relayHostId: secondIdentity.relayHostId })
|
|
).not.toBeNull()
|
|
expect(secondSocket.close).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refreshes the logged client build when a control rebinds', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate } = createRegistry(activateControl)
|
|
const firstSocket = new FakeSocket()
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
try {
|
|
await activate(firstSocket as unknown as WebSocket, identity, null, 1, false, 1, '1.4.100')
|
|
const session = registry.get({
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId
|
|
})!
|
|
const rebindSocket = new FakeSocket()
|
|
await activate(rebindSocket as unknown as WebSocket, identity, session, 1, true, 1, '1.4.200')
|
|
|
|
// The rebind closes the predecessor. That line is a churn line, so it must carry the
|
|
// build that socket ran, not the successor's — the refresh above lands before it closes.
|
|
const rebound = warn.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.find((entry) => entry.includes('control rebound'))
|
|
expect(rebound).toContain('app="1.4.100"')
|
|
|
|
rebindSocket.close(1006, 'network reset')
|
|
const line = warn.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.find((entry) => entry.includes('code=1006'))
|
|
expect(line).toContain('app="1.4.200"')
|
|
} finally {
|
|
warn.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('keeps every live control socket indexed when first activations overlap', async () => {
|
|
const firstControl = deferred<string>()
|
|
const secondControl = deferred<string>()
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockReturnValueOnce(firstControl.promise)
|
|
.mockReturnValueOnce(secondControl.promise)
|
|
const { registry, activate } = createRegistry(activateControl)
|
|
const firstSocket = new FakeSocket()
|
|
const secondSocket = new FakeSocket()
|
|
|
|
const first = activate(firstSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const second = activate(secondSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
secondControl.resolve('control:production-gce-c3:1')
|
|
await Promise.resolve()
|
|
firstControl.resolve('control:production-gce-c3:1')
|
|
await Promise.all([first, second])
|
|
|
|
const liveSockets = [firstSocket, secondSocket].filter(
|
|
(socket) => socket.readyState === socket.OPEN
|
|
)
|
|
const session = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })
|
|
expect(liveSockets).toHaveLength(1)
|
|
expect(session?.socket).toBe(liveSockets[0])
|
|
})
|
|
|
|
it('does not publish a control that closes during activation', async () => {
|
|
const blocked = deferred<string>()
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockReturnValueOnce(blocked.promise)
|
|
const { registry, activate, releaseActivity } = createRegistry(activateControl)
|
|
const socket = new FakeSocket()
|
|
|
|
const activation = activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(activateControl).toHaveBeenCalledOnce()
|
|
socket.close()
|
|
blocked.resolve('control:production-gce-c3:1')
|
|
await activation
|
|
|
|
expect(registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })).toBeNull()
|
|
expect(releaseActivity).toHaveBeenCalledWith(
|
|
{ userId: identity.sub, relayHostId: identity.relayHostId },
|
|
'control:production-gce-c3:1'
|
|
)
|
|
})
|
|
|
|
it('does not rebind a control that closes during activation', 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 originalSocket = new FakeSocket()
|
|
await activate(originalSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const original = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })
|
|
expect(original).not.toBeNull()
|
|
|
|
const rebindSocket = new FakeSocket()
|
|
const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(activateControl).toHaveBeenCalledTimes(2)
|
|
rebindSocket.close()
|
|
blocked.resolve('control:production-gce-c3:1')
|
|
await rebinding
|
|
|
|
const session = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })
|
|
expect(session).toBe(original)
|
|
expect(session?.socket).toBe(originalSocket)
|
|
expect(session?.state).toBe('active')
|
|
expect(releaseActivity).toHaveBeenCalledWith(
|
|
{ userId: identity.sub, relayHostId: identity.relayHostId },
|
|
'control:production-gce-c3:1'
|
|
)
|
|
})
|
|
|
|
// 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)
|
|
expect(activateControl).toHaveBeenCalledTimes(2)
|
|
// 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')
|
|
})
|
|
|
|
// Two hellos proven against the same empty map could take the same generation; an activation at
|
|
// a generation this host already had here is refused, so a late leave cannot share it.
|
|
it('refuses a fresh activation at a generation this host already had here', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:2')
|
|
const { registry, activate } = createRegistry(activateControl)
|
|
const key = { userId: identity.sub, relayHostId: identity.relayHostId }
|
|
const first = new FakeSocket()
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface.
|
|
await activate(first as unknown as WebSocket, identity, null, 2, false, 1)
|
|
first.close(1006, 'network reset')
|
|
await vi.advanceTimersByTimeAsync(CONTROL_CONTINUITY_LIMITS.orphanGraceMs)
|
|
expect(registry.get(key)).toBeNull()
|
|
const reused = new FakeSocket()
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface.
|
|
await activate(reused as unknown as WebSocket, identity, null, 2, false, 1)
|
|
expect(reused.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded')
|
|
expect(registry.get(key)).toBeNull()
|
|
})
|
|
|
|
it('skips a closed queued control so its live retry avoids abandoned database work', async () => {
|
|
const stalled = deferred<string>()
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockReturnValueOnce(stalled.promise)
|
|
.mockImplementation(async () => {
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 4_000))
|
|
return 'control:production-gce-c3:1'
|
|
})
|
|
const { registry, activate, releaseActivity } = createRegistry(activateControl)
|
|
const firstSocket = new FakeSocket()
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface.
|
|
const first = activate(firstSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(activateControl).toHaveBeenCalledOnce()
|
|
const abandonedSocket = new FakeSocket()
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface.
|
|
const abandoned = activate(abandonedSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const liveSocket = new FakeSocket()
|
|
const startedAt = Date.now()
|
|
let liveCompletedAt: number | undefined
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface.
|
|
const live = activate(liveSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
.then(() => { liveCompletedAt = Date.now() })
|
|
firstSocket.close()
|
|
abandonedSocket.close()
|
|
stalled.resolve('control:production-gce-c3:1')
|
|
await vi.advanceTimersByTimeAsync(8_000)
|
|
await Promise.all([first, abandoned, live])
|
|
|
|
console.log(JSON.stringify({
|
|
scenario: 'closed queued control before a live retry',
|
|
activationCalls: activateControl.mock.calls.length,
|
|
activityReleases: releaseActivity.mock.calls.length,
|
|
liveReadyMs: liveCompletedAt === undefined ? null : liveCompletedAt - startedAt
|
|
}))
|
|
expect(activateControl).toHaveBeenCalledTimes(2)
|
|
expect(releaseActivity).toHaveBeenCalledOnce()
|
|
expect(liveCompletedAt! - startedAt).toBe(4_000)
|
|
expect(registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })?.socket)
|
|
.toBe(liveSocket)
|
|
})
|
|
|
|
it('rejects client lookup when the indexed control socket is not open', async () => {
|
|
const reservation = {
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId,
|
|
credentialKind: 'resume',
|
|
relayDeviceId: 'device-1',
|
|
leaseExpiresAt: Date.now() + 60_000
|
|
}
|
|
const store = {
|
|
resolveResume: vi.fn().mockResolvedValue({ userId: identity.sub }),
|
|
reserveCredential: vi.fn().mockResolvedValue(reservation),
|
|
failReservation: vi.fn().mockResolvedValue(undefined)
|
|
}
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValueOnce('control:production-gce-c3:1')
|
|
const { registry, activate } = createRegistry(activateControl, store)
|
|
const controlSocket = new FakeSocket()
|
|
await activate(controlSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const session = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })
|
|
expect(session?.state).toBe('active')
|
|
// A dead socket that never delivered its close event: state stays active,
|
|
// so only the readyState guard can protect the lookup.
|
|
controlSocket.readyState = controlSocket.CLOSED
|
|
|
|
const client = new FakeSocket()
|
|
await registry.acceptClient(client as unknown as WebSocket, identity.relayHostId, 'credential')
|
|
|
|
expect(store.failReservation).toHaveBeenCalledWith(reservation)
|
|
expect(client.send).toHaveBeenCalledWith(
|
|
JSON.stringify({ type: 'relay-hello', ok: false, code: RELAY_CLOSE_CODE.HOST_OFFLINE })
|
|
)
|
|
expect(session?.pendingConns.size).toBe(0)
|
|
})
|
|
|
|
it('fails a control waiting behind a stalled activation without breaking serialization', async () => {
|
|
const stalled = deferred<string>()
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockReturnValueOnce(stalled.promise)
|
|
const { registry, activate } = createRegistry(activateControl)
|
|
const stalledSocket = new FakeSocket()
|
|
const first = activate(stalledSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const waitingSocket = new FakeSocket()
|
|
const second = activate(waitingSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
|
|
// Let the first activation reach the store (clearing its own queue timer)
|
|
// before the waiting control's deadline elapses.
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
vi.advanceTimersByTime(30_000)
|
|
expect(waitingSocket.close).toHaveBeenCalledWith(
|
|
RELAY_CLOSE_CODE.LIMIT_EXCEEDED,
|
|
'control activation queue stalled'
|
|
)
|
|
// The waiting control never reached the store; serialization held.
|
|
expect(activateControl).toHaveBeenCalledOnce()
|
|
|
|
stalled.resolve('control:production-gce-c3:1')
|
|
await Promise.all([first, second])
|
|
const session = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })
|
|
expect(session?.socket).toBe(stalledSocket)
|
|
expect(session?.state).toBe('active')
|
|
})
|
|
|
|
it('rejects an activation that returns after drain begins', async () => {
|
|
const blocked = deferred<string>()
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValueOnce('control:production-gce-c3:1')
|
|
.mockReturnValueOnce(blocked.promise)
|
|
const { registry, activate, renewControlActivities, releaseActivity } =
|
|
createRegistry(activateControl)
|
|
const originalSocket = new FakeSocket()
|
|
await activate(originalSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const original = registry.get({
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId
|
|
})
|
|
expect(original).not.toBeNull()
|
|
|
|
const replacementSocket = new FakeSocket()
|
|
const replacement = activate(
|
|
replacementSocket as unknown as WebSocket,
|
|
identity,
|
|
original,
|
|
2,
|
|
false,
|
|
1
|
|
)
|
|
registry.drain(100)
|
|
blocked.resolve('control:production-gce-c3:2')
|
|
await replacement
|
|
vi.advanceTimersByTime(100)
|
|
vi.advanceTimersByTime(15_000)
|
|
|
|
expect(replacementSocket.close).toHaveBeenCalledWith(
|
|
RELAY_CLOSE_CODE.DRAINING,
|
|
'relay draining'
|
|
)
|
|
expect(registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })).toBeNull()
|
|
expect(renewControlActivities).not.toHaveBeenCalled()
|
|
expect(releaseActivity).toHaveBeenCalledWith(
|
|
{ userId: identity.sub, relayHostId: identity.relayHostId },
|
|
'control:production-gce-c3:2'
|
|
)
|
|
})
|
|
|
|
it('keeps a replacement mapped after stale orphan cleanup', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValueOnce('control:production-gce-c3:1')
|
|
.mockResolvedValueOnce('control:production-gce-c3:2')
|
|
const { registry, activate } = createRegistry(activateControl)
|
|
const originalSocket = new FakeSocket()
|
|
await activate(originalSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const original = registry.get({
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId
|
|
})
|
|
expect(original).not.toBeNull()
|
|
originalSocket.close()
|
|
|
|
const replacementSocket = new FakeSocket()
|
|
await activate(replacementSocket as unknown as WebSocket, identity, original, 2, false, 1)
|
|
const replacement = registry.get({
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId
|
|
})
|
|
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs)
|
|
|
|
expect(replacement).not.toBeNull()
|
|
expect(registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })).toBe(
|
|
replacement
|
|
)
|
|
expect(registry.runtimeCounts().controls).toBe(1)
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
it('stops the heartbeat of an actively replaced generation', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValueOnce('control:production-gce-c3:1')
|
|
.mockResolvedValueOnce('control:production-gce-c3:2')
|
|
const { registry, activate, renewControlActivities } = createRegistry(activateControl)
|
|
const originalSocket = new FakeSocket()
|
|
await activate(originalSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const original = registry.get({
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId
|
|
})
|
|
expect(original).not.toBeNull()
|
|
|
|
await activate(new FakeSocket() as unknown as WebSocket, identity, original, 2, false, 1)
|
|
await vi.advanceTimersByTimeAsync(15_000)
|
|
await closeRenewalWindow()
|
|
|
|
expect(renewControlActivities).toHaveBeenCalledOnce()
|
|
expect(renewControlActivities).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
identity: { userId: identity.sub, relayHostId: identity.relayHostId },
|
|
activityId: 'control:production-gce-c3:2',
|
|
cellId: 'production-gce-c3'
|
|
})
|
|
])
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
it('ignores missing-activity recovery denial after an authority transition', async () => {
|
|
const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1'))
|
|
const socket = new FakeSocket()
|
|
await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
|
|
h.renewControlActivities.mockResolvedValueOnce(['control_activity_not_found'])
|
|
let reject!: (error: Error) => void
|
|
h.acquireActivity.mockReturnValueOnce(
|
|
new Promise<void>((_, fail) => {
|
|
reject = fail
|
|
})
|
|
)
|
|
await vi.advanceTimersByTimeAsync(15_000)
|
|
await closeRenewalWindow()
|
|
h.registry.drainHost({
|
|
attemptId: 'attempt',
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId,
|
|
sourceAssignmentEpoch: 1,
|
|
graceMs: 60_000
|
|
})
|
|
reject(new Error('activity_cell_not_authoritative'))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(socket.close).not.toHaveBeenCalled()
|
|
expect(session.state).toBe('drain-only')
|
|
})
|
|
|
|
it('keeps 15s pings while halving steady-state control renewals', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate, renewControlActivities } = createRegistry(activateControl)
|
|
const socket = new FakeSocket()
|
|
const activatedAt = Date.now()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
|
|
for (let interval = 0; interval < 4; interval++) {
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
socket.emit('message', Buffer.from(JSON.stringify({ type: 'pong' })), false)
|
|
await closeRenewalWindow()
|
|
}
|
|
|
|
const pings = socket.send.mock.calls.filter((call) => String(call[0]).includes('"ping"'))
|
|
expect(pings).toHaveLength(4)
|
|
expect(renewControlActivities).toHaveBeenCalledTimes(2)
|
|
const firstExpiry = Number(renewControlActivities.mock.calls[0]![0][0]!.expiresAt)
|
|
const secondExpiry = Number(renewControlActivities.mock.calls[1]![0][0]!.expiresAt)
|
|
expect(firstExpiry).toBe(
|
|
activatedAt +
|
|
RELAY_PROTOCOL_LIMITS.controlPingIntervalMs +
|
|
ASSIGNMENT_LIMITS.activityLeaseMs +
|
|
RELAY_PROTOCOL_LIMITS.controlPingIntervalMs
|
|
)
|
|
expect(secondExpiry - firstExpiry).toBe(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs * 2)
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
it('retries a failed control renewal on the next ping', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate, renewControlActivities } = createRegistry(activateControl)
|
|
renewControlActivities.mockRejectedValueOnce(new Error('pool timeout'))
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
const socket = new FakeSocket()
|
|
try {
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
await closeRenewalWindow()
|
|
expect(renewControlActivities).toHaveBeenCalledOnce()
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
await closeRenewalWindow()
|
|
expect(renewControlActivities).toHaveBeenCalledTimes(2)
|
|
} finally {
|
|
warn.mockRestore()
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
|
|
it('retries past a stalled control renewal without waiting for it', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate, renewControlActivities } = createRegistry(activateControl)
|
|
const stalled = deferred<ControlRenewalOutcome[]>()
|
|
renewControlActivities.mockReturnValueOnce(stalled.promise)
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs * 2)
|
|
await closeRenewalWindow()
|
|
|
|
expect(renewControlActivities).toHaveBeenCalledTimes(2)
|
|
stalled.resolve(['renewed'])
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
it('ignores a superseded renewal resolving after a fresher success', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate, renewControlActivities } = createRegistry(activateControl)
|
|
const stalled = deferred<ControlRenewalOutcome[]>()
|
|
renewControlActivities.mockReturnValueOnce(stalled.promise)
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs * 2)
|
|
await closeRenewalWindow()
|
|
expect(renewControlActivities).toHaveBeenCalledTimes(2)
|
|
stalled.resolve(['renewed'])
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
await closeRenewalWindow()
|
|
|
|
expect(renewControlActivities).toHaveBeenCalledTimes(2)
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
await closeRenewalWindow()
|
|
|
|
expect(renewControlActivities).toHaveBeenCalledTimes(3)
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
it('re-acquires a missing control activity lease', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate, acquireActivity, renewControlActivities } =
|
|
createRegistry(activateControl)
|
|
renewControlActivities.mockResolvedValueOnce(['control_activity_not_found'])
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
await closeRenewalWindow()
|
|
|
|
expect(acquireActivity).toHaveBeenCalledWith(
|
|
{ userId: identity.sub, relayHostId: identity.relayHostId },
|
|
{
|
|
activityId: 'control:production-gce-c3:1',
|
|
kind: 'control',
|
|
cellId: config.cellId
|
|
}
|
|
)
|
|
expect(socket.close).not.toHaveBeenCalled()
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
it('closes a control whose activity lease moved to another cell', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate, acquireActivity, renewControlActivities } =
|
|
createRegistry(activateControl)
|
|
renewControlActivities.mockResolvedValueOnce(['control_activity_moved'])
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
await closeRenewalWindow()
|
|
|
|
expect(acquireActivity).not.toHaveBeenCalled()
|
|
expect(socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, 'control activity moved')
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
it('closes when a missing control activity cannot be re-acquired on this cell', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate, acquireActivity, renewControlActivities } =
|
|
createRegistry(activateControl)
|
|
renewControlActivities.mockResolvedValueOnce(['control_activity_not_found'])
|
|
acquireActivity.mockRejectedValueOnce(new Error('activity_cell_not_authoritative'))
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
await closeRenewalWindow()
|
|
|
|
expect(socket.close).toHaveBeenCalledWith(
|
|
RELAY_CLOSE_CODE.DRAINING,
|
|
'control migration completed'
|
|
)
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
it('closes a control when renewal finds its cell is no longer authoritative', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate, renewControlActivities } = createRegistry(activateControl)
|
|
renewControlActivities.mockResolvedValueOnce(['activity_cell_not_authoritative'])
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
await closeRenewalWindow()
|
|
|
|
expect(socket.close).toHaveBeenCalledWith(
|
|
RELAY_CLOSE_CODE.DRAINING,
|
|
'control migration completed'
|
|
)
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
it('continues renewing throughout the drain grace period', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate, renewControlActivities } = createRegistry(activateControl)
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
registry.drain(60_000)
|
|
|
|
for (let interval = 0; interval < 3; interval++) {
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
socket.emit('message', Buffer.from(JSON.stringify({ type: 'pong' })), false)
|
|
await closeRenewalWindow()
|
|
}
|
|
|
|
expect(renewControlActivities).toHaveBeenCalledTimes(2)
|
|
expect(socket.readyState).toBe(socket.OPEN)
|
|
vi.advanceTimersByTime(15_000)
|
|
})
|
|
})
|
|
|
|
describe('control renewal cadence across a rebind', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('keeps the halved cadence after a rebind lands under a stalled renewal', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate, renewControlActivities } = createRegistry(activateControl)
|
|
const ping = RELAY_PROTOCOL_LIMITS.controlPingIntervalMs
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
|
|
const beat = async (target: FakeSocket): Promise<void> => {
|
|
await vi.advanceTimersByTimeAsync(ping)
|
|
target.emit('message', Buffer.from(JSON.stringify({ type: 'pong' })), false)
|
|
await closeRenewalWindow()
|
|
}
|
|
|
|
// Age the session so its attempt counter is well above zero.
|
|
for (let tick = 0; tick < 5; tick++) await beat(socket)
|
|
expect(renewControlActivities).toHaveBeenCalledTimes(3)
|
|
|
|
// The next renewal stalls and is still in flight when the control rebinds.
|
|
const stalled = deferred<ControlRenewalOutcome[]>()
|
|
renewControlActivities.mockReturnValueOnce(stalled.promise)
|
|
for (let tick = 0; tick < 2; tick++) await beat(socket)
|
|
expect(renewControlActivities).toHaveBeenCalledTimes(4)
|
|
|
|
const session = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
|
|
const rebindSocket = new FakeSocket()
|
|
await activate(rebindSocket as unknown as WebSocket, identity, session, 1, true, 1)
|
|
stalled.resolve(['renewed'])
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
|
|
const before = renewControlActivities.mock.calls.length
|
|
for (let tick = 0; tick < 4; tick++) await beat(rebindSocket)
|
|
expect(renewControlActivities.mock.calls.length - before).toBe(2)
|
|
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
})
|
|
|
|
describe('control renewals shared by one batch', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('renews two due hosts in one call and leaves a stale one alone', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValueOnce('control:production-gce-c3:1')
|
|
.mockResolvedValueOnce('control:production-gce-c3:1')
|
|
const { registry, activate, renewControlActivities } = createRegistry(activateControl)
|
|
const other = { ...identity, sub: 'user-2', relayHostId: 'ponmlkjihgfedcba' }
|
|
const staleSocket = new FakeSocket()
|
|
const liveSocket = new FakeSocket()
|
|
await activate(staleSocket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
await activate(liveSocket as unknown as WebSocket, other, null, 1, false, 1)
|
|
const stale = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
|
|
const live = registry.get({ userId: other.sub, relayHostId: other.relayHostId })!
|
|
|
|
// Both come due inside the same window, and one socket goes away while the
|
|
// statement is still in PostgreSQL.
|
|
let release!: () => void
|
|
renewControlActivities.mockImplementationOnce(
|
|
async (rows: readonly ControlRenewalRequest[]) => {
|
|
staleSocket.close()
|
|
await new Promise<void>((resolve) => (release = resolve))
|
|
return rows.map((): ControlRenewalOutcome => 'renewed')
|
|
}
|
|
)
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
const staleDueAt = stale.activityRenewalDueAt
|
|
await closeRenewalWindow()
|
|
release()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
|
|
expect(renewControlActivities).toHaveBeenCalledOnce()
|
|
expect(
|
|
renewControlActivities.mock.calls[0]![0].map(
|
|
(row: ControlRenewalRequest) => row.identity.relayHostId
|
|
)
|
|
).toEqual([identity.relayHostId, other.relayHostId])
|
|
expect(live.activityRenewalCompletedAttempt).toBe(1)
|
|
expect(stale.activityRenewalCompletedAttempt).toBe(0)
|
|
expect(stale.activityRenewalDueAt).toBe(staleDueAt)
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
})
|
|
|
|
describe('control lease recovery after the session is gone', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('does not re-acquire a lease for a session a newer generation already replaced', async () => {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValueOnce('control:production-gce-c3:1')
|
|
.mockResolvedValueOnce('control:production-gce-c3:2')
|
|
const { registry, activate, acquireActivity, renewControlActivities } =
|
|
createRegistry(activateControl)
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
try {
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const session = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
|
|
|
|
// The renewal is still in flight when a newer generation takes over.
|
|
let finishRenewal!: (outcomes: ControlRenewalOutcome[]) => void
|
|
renewControlActivities.mockReturnValueOnce(
|
|
new Promise<ControlRenewalOutcome[]>((resolve) => (finishRenewal = resolve))
|
|
)
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
await closeRenewalWindow()
|
|
expect(renewControlActivities).toHaveBeenCalledOnce()
|
|
|
|
const newer = new FakeSocket()
|
|
await activate(newer as unknown as WebSocket, identity, session, 2, false, 1)
|
|
|
|
// Its release already removed the lease, so the renewal reports it missing.
|
|
finishRenewal(['control_activity_not_found'])
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
|
|
expect(acquireActivity).not.toHaveBeenCalled()
|
|
} finally {
|
|
warn.mockRestore()
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('host hello ack pending connections', () => {
|
|
const DETAILS = new Set([RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS])
|
|
const LEGACY_ENTRY = { connId: 'conn-1', connTicket: 'T'.repeat(43) }
|
|
const DETAILED_ENTRY = { ...LEGACY_ENTRY, kind: 'invite', relayDeviceId: 'device-1' }
|
|
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
function newRegistry(): ReturnType<typeof createRegistry> {
|
|
return createRegistry(
|
|
vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
)
|
|
}
|
|
|
|
function addPendingConnection(session: HostSession): void {
|
|
session.pendingConns.set('conn-1', {
|
|
...LEGACY_ENTRY,
|
|
reservation: {
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId,
|
|
credentialKind: 'invite',
|
|
relayDeviceId: 'device-1'
|
|
},
|
|
client: new FakeSocket() as unknown as WebSocket,
|
|
attachTimer: setTimeout(() => {}, 60_000),
|
|
credentialActivityId: null
|
|
} as unknown as Parameters<typeof session.pendingConns.set>[1])
|
|
}
|
|
|
|
function sentAck(socket: FakeSocket): Record<string, unknown> {
|
|
const acks = socket.send.mock.calls
|
|
.map((call) => JSON.parse(String(call[0])) as Record<string, unknown>)
|
|
.filter((message) => message.type === 'host-hello-ack')
|
|
return acks.at(-1)!
|
|
}
|
|
|
|
function sessionOf(registry: HostSessionRegistry): HostSession {
|
|
return registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
|
|
}
|
|
|
|
async function ackFor(capabilities?: ReadonlySet<string>): Promise<Record<string, unknown>> {
|
|
const { registry, activate } = newRegistry()
|
|
const socket = new FakeSocket()
|
|
registry.acceptControl(
|
|
socket as unknown as WebSocket,
|
|
identity,
|
|
undefined,
|
|
capabilities ?? new Set()
|
|
)
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const session = sessionOf(registry)
|
|
addPendingConnection(session)
|
|
socket.send.mockClear()
|
|
;(registry as unknown as { sendHelloAck(session: HostSession): void }).sendHelloAck(session)
|
|
return sentAck(socket)
|
|
}
|
|
|
|
async function ackAfterRebind(
|
|
first: ReadonlySet<string>,
|
|
successor: ReadonlySet<string>
|
|
): Promise<{ opening: Record<string, unknown>; rebound: Record<string, unknown> }> {
|
|
const { registry, activate } = newRegistry()
|
|
const opening = new FakeSocket()
|
|
registry.acceptControl(opening as unknown as WebSocket, identity, undefined, first)
|
|
await activate(opening as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const session = sessionOf(registry)
|
|
addPendingConnection(session)
|
|
opening.send.mockClear()
|
|
;(registry as unknown as { sendHelloAck(session: HostSession): void }).sendHelloAck(session)
|
|
|
|
const rebound = new FakeSocket()
|
|
registry.acceptControl(rebound as unknown as WebSocket, identity, undefined, successor)
|
|
await activate(rebound as unknown as WebSocket, identity, session, 1, true, 1)
|
|
return { opening: sentAck(opening), rebound: sentAck(rebound) }
|
|
}
|
|
|
|
it('states the pending kind and device to a host that advertised it can read them', async () => {
|
|
const ack = await ackFor(DETAILS)
|
|
|
|
expect(ack.pendingConns).toEqual([DETAILED_ENTRY])
|
|
})
|
|
|
|
it('restates only the identifiers to a host that never advertised the capability', async () => {
|
|
// A shipped host parses these entries strictly, so an unannounced key fails
|
|
// the whole ack parse and kills a control that was working.
|
|
const ack = await ackFor()
|
|
|
|
expect(ack.pendingConns).toEqual([LEGACY_ENTRY])
|
|
})
|
|
|
|
it('downgrades the restated entry when the successor control drops the capability', async () => {
|
|
// The capability belongs to the socket, not the session: a rebind can land a
|
|
// control whose decoder is older than the one that opened the session.
|
|
const { opening, rebound } = await ackAfterRebind(DETAILS, new Set())
|
|
|
|
expect(opening.pendingConns).toEqual([DETAILED_ENTRY])
|
|
expect(rebound.pendingConns).toEqual([LEGACY_ENTRY])
|
|
})
|
|
|
|
it('upgrades the restated entry when the successor control adds the capability', async () => {
|
|
const { opening, rebound } = await ackAfterRebind(new Set(), DETAILS)
|
|
|
|
expect(opening.pendingConns).toEqual([LEGACY_ENTRY])
|
|
expect(rebound.pendingConns).toEqual([DETAILED_ENTRY])
|
|
})
|
|
})
|
|
|
|
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(
|
|
store: Partial<RelayCredentialStore> = {},
|
|
controlAgeMs = IDLE_REHOME_MIN_CONTROL_AGE_MS
|
|
) {
|
|
const h = createRegistry(vi.fn().mockResolvedValue('control:1'), store)
|
|
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() + controlAgeMs)
|
|
return { ...h, socket, session: h.registry.get(request)! }
|
|
}
|
|
it.each([
|
|
{ controlAgeMs: 0, outcome: 'busy' },
|
|
{ controlAgeMs: IDLE_REHOME_MIN_CONTROL_AGE_MS - 1, outcome: 'busy' },
|
|
{ controlAgeMs: IDLE_REHOME_MIN_CONTROL_AGE_MS, outcome: 'committed' },
|
|
{ controlAgeMs: 60 * 60 * 1000, outcome: 'committed' }
|
|
])('answers $outcome for a control socket $controlAgeMs ms old', async (row) => {
|
|
const { controlAgeMs, outcome } = row
|
|
const h = await source({}, controlAgeMs)
|
|
const commit = vi.fn().mockResolvedValue({ outcome: 'committed' })
|
|
expect(
|
|
await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed'))
|
|
).toEqual({ outcome })
|
|
expect(commit).toHaveBeenCalledTimes(outcome === 'busy' ? 0 : 1)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(h.socket.close).toHaveBeenCalledTimes(outcome === 'busy' ? 0 : 1)
|
|
})
|
|
it('restarts the age when the host rebinds its control socket', async () => {
|
|
const h = await source()
|
|
const rebound = new FakeSocket()
|
|
h.registry.acceptControl(
|
|
rebound as unknown as WebSocket,
|
|
identity,
|
|
undefined,
|
|
new Set([RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME])
|
|
)
|
|
rebound.removeAllListeners('message')
|
|
await h.activate(rebound as unknown as WebSocket, identity, h.session, 1, true, 1)
|
|
const commit = vi.fn().mockResolvedValue({ outcome: 'committed' })
|
|
expect(
|
|
await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed'))
|
|
).toEqual({ outcome: 'busy' })
|
|
expect(commit).not.toHaveBeenCalled()
|
|
})
|
|
it('closes a superseded socket whose refresh meets the cutover, not its successor', async () => {
|
|
const h = await source()
|
|
const successor = new FakeSocket()
|
|
h.registry.acceptControl(
|
|
successor as unknown as WebSocket,
|
|
identity,
|
|
undefined,
|
|
new Set([RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME])
|
|
)
|
|
successor.removeAllListeners('message')
|
|
await h.activate(successor as unknown as WebSocket, identity, h.session, 1, true, 1)
|
|
vi.setSystemTime(Date.now() + IDLE_REHOME_MIN_CONTROL_AGE_MS)
|
|
const commit = deferred<{ outcome: 'committed' }>()
|
|
const cutover = h.registry.idleRehome(
|
|
request,
|
|
vi.fn(() => commit.promise),
|
|
vi.fn().mockResolvedValue('not-committed')
|
|
)
|
|
h.socket.emit('message', Buffer.from(JSON.stringify({ type: 'auth-refresh', relayJwt: 'jwt' })), false)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(h.socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, 'resolve-director')
|
|
expect(successor.close).not.toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, 'resolve-director')
|
|
commit.resolve({ outcome: 'committed' })
|
|
await cutover
|
|
})
|
|
it('keeps either established client busy until both actually leave', async () => {
|
|
const h = await source()
|
|
h.session.activeConnIds.add('phone')
|
|
h.session.activeConnIds.add('ipad')
|
|
const commit = vi.fn().mockResolvedValue({ outcome: 'committed' })
|
|
h.session.activeConnIds.delete('ipad')
|
|
expect(
|
|
await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed'))
|
|
).toEqual({ outcome: 'busy' })
|
|
expect(commit).not.toHaveBeenCalled()
|
|
h.session.activeConnIds.delete('phone')
|
|
expect(
|
|
await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed'))
|
|
).toEqual({ outcome: 'committed' })
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(h.socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, expect.any(String))
|
|
expect(h.releaseActivity).toHaveBeenCalled()
|
|
})
|
|
it.each([
|
|
{ userId: 'other-user' },
|
|
{ sourceAssignmentEpoch: 2 },
|
|
{ sourceGeneration: 2 },
|
|
{ sourceCellIncarnation: 'other-incarnation' },
|
|
{ targetCellId: 'other-target' }
|
|
])('rejects a reused operation ID with changed authority %j', async (change) => {
|
|
const h = await source()
|
|
const result = deferred<{ outcome: 'deferred' }>()
|
|
const commit = vi.fn().mockReturnValue(result.promise)
|
|
const reconcile = vi.fn().mockResolvedValue('not-committed')
|
|
const moving = h.registry.idleRehome(request, commit, reconcile)
|
|
const conflicting = h.registry.idleRehome({ ...request, ...change }, commit, reconcile)
|
|
result.resolve({ outcome: 'deferred' })
|
|
expect(await conflicting).toEqual({ outcome: 'stale' })
|
|
expect(await moving).toEqual({ outcome: 'deferred' })
|
|
expect(commit).toHaveBeenCalledOnce()
|
|
expect(h.socket.close).not.toHaveBeenCalled()
|
|
})
|
|
it('accounts for accepts before credential identity resolves', async () => {
|
|
const lookup = deferred<null>()
|
|
const h = await source({
|
|
resolveResume: vi.fn().mockReturnValue(lookup.promise),
|
|
resolveInviteForMove: vi.fn().mockResolvedValue(null)
|
|
})
|
|
const client = new FakeSocket()
|
|
const accept = h.registry.acceptClient(
|
|
client as unknown as WebSocket,
|
|
identity.relayHostId,
|
|
'credential'
|
|
)
|
|
expect(
|
|
await h.registry.idleRehome(request, vi.fn(), vi.fn().mockResolvedValue('not-committed'))
|
|
).toEqual({ outcome: 'busy' })
|
|
lookup.resolve(null)
|
|
await accept
|
|
expect(h.socket.close).not.toHaveBeenCalled()
|
|
})
|
|
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' })
|
|
})
|
|
it.each(['ambiguous', 'deferred'])(
|
|
'keeps %s outcomes fenced until locked reconciliation succeeds',
|
|
async (claim) => {
|
|
const h = await source()
|
|
const reconcile = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('database unavailable'))
|
|
.mockRejectedValueOnce(new Error('database unavailable'))
|
|
.mockResolvedValue('not-committed')
|
|
const moving = h.registry.idleRehome(
|
|
request,
|
|
claim === 'ambiguous'
|
|
? vi.fn().mockRejectedValue(new Error('lost commit reply'))
|
|
: vi.fn().mockResolvedValue({ outcome: 'deferred' }),
|
|
reconcile
|
|
)
|
|
await vi.advanceTimersByTimeAsync(50)
|
|
expect(
|
|
await h.registry.idleRehome(
|
|
{ ...request, attemptId: 'other' },
|
|
vi.fn(),
|
|
vi.fn().mockResolvedValue('not-committed')
|
|
)
|
|
).toEqual({ outcome: 'busy' })
|
|
expect(h.socket.close).not.toHaveBeenCalled()
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
expect(await moving).toEqual({ outcome: 'deferred' })
|
|
expect(reconcile).toHaveBeenCalledTimes(3)
|
|
expect(h.socket.close).not.toHaveBeenCalled()
|
|
}
|
|
)
|
|
it('owns accepted control mutations before the handler first awaits', async () => {
|
|
const mutation = deferred<RelayTokenClaims | null>()
|
|
const h = await source()
|
|
;(h.registry as unknown as { verifyRelayToken: unknown }).verifyRelayToken = vi
|
|
.fn()
|
|
.mockReturnValue(mutation.promise)
|
|
h.socket.emit(
|
|
'message',
|
|
Buffer.from(JSON.stringify({ type: 'auth-refresh', relayJwt: 'token' })),
|
|
false
|
|
)
|
|
const commit = vi.fn().mockResolvedValue({ outcome: 'deferred' })
|
|
expect(
|
|
await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed'))
|
|
).toEqual({ outcome: 'busy' })
|
|
mutation.resolve(identity)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(
|
|
await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('not-committed'))
|
|
).toEqual({ outcome: 'deferred' })
|
|
})
|
|
it('owns queued replacement activation before its first persistence await', async () => {
|
|
const h = await source()
|
|
const activation = deferred<string>()
|
|
const assignments = (h.registry as unknown as { assignments: { activateControl: unknown } })
|
|
.assignments
|
|
assignments.activateControl = vi.fn().mockReturnValue(activation.promise)
|
|
const replacement = new FakeSocket()
|
|
const activating = h.activate(
|
|
replacement as unknown as WebSocket,
|
|
identity,
|
|
h.session,
|
|
2,
|
|
false,
|
|
1
|
|
)
|
|
expect(
|
|
await h.registry.idleRehome(request, vi.fn(), vi.fn().mockResolvedValue('not-committed'))
|
|
).toEqual({ outcome: 'busy' })
|
|
activation.resolve('control:2')
|
|
await activating
|
|
})
|
|
it('retires changed authority even when the claim definitively deferred', async () => {
|
|
const h = await source()
|
|
expect(
|
|
await h.registry.idleRehome(
|
|
request,
|
|
vi.fn().mockResolvedValue({ outcome: 'deferred' }),
|
|
vi.fn().mockResolvedValue('stale')
|
|
)
|
|
).toEqual({ outcome: 'stale' })
|
|
expect(h.session.state).toBe('closed')
|
|
expect(h.releaseActivity).toHaveBeenCalled()
|
|
})
|
|
it('holds attach ownership through basis failure reservation cleanup', async () => {
|
|
const basis = deferred<void>()
|
|
const cleanup = deferred<void>()
|
|
const h = await source({
|
|
recordConnectionBasis: vi.fn().mockImplementation(async () => {
|
|
await basis.promise
|
|
throw new Error('basis failed')
|
|
}),
|
|
failReservation: vi.fn().mockReturnValue(cleanup.promise)
|
|
})
|
|
const client = new FakeSocket()
|
|
h.session.pendingConns.set('conn', {
|
|
connId: 'conn',
|
|
connTicket: 'ticket',
|
|
client: client as unknown as WebSocket,
|
|
reservation: {
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId,
|
|
credentialKind: 'invite',
|
|
relayDeviceId: 'device-1',
|
|
tokenHash: 'invite-hash',
|
|
reservationId: 'reservation-1',
|
|
leaseExpiresAt: Date.now() + 1000
|
|
},
|
|
attachTimer: setTimeout(() => {}, 1000),
|
|
credentialActivityId: null,
|
|
phonePath: 'db',
|
|
timing: {
|
|
startedAt: Date.now(),
|
|
connOpenAt: Date.now(),
|
|
stageMs: { assignment: 0, credential: 0, activity: 0 }
|
|
}
|
|
})
|
|
const attached = h.registry.acceptHostData(
|
|
new FakeSocket() as unknown as WebSocket,
|
|
'conn',
|
|
'ticket',
|
|
1
|
|
)
|
|
const commit = vi.fn().mockResolvedValue({ outcome: 'deferred' })
|
|
expect(await h.registry.idleRehome(request, commit, vi.fn())).toEqual({ outcome: 'busy' })
|
|
basis.resolve()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(h.session.activeConnIds.size).toBe(0)
|
|
expect(await h.registry.idleRehome(request, commit, vi.fn())).toEqual({ outcome: 'busy' })
|
|
expect(commit).not.toHaveBeenCalled()
|
|
cleanup.resolve()
|
|
await attached
|
|
})
|
|
it('rejects an attach mid-cutover before its ticket is ever examined', async () => {
|
|
const h = await source({ failReservation: vi.fn().mockResolvedValue(undefined) })
|
|
const result = deferred<{ outcome: 'deferred' }>()
|
|
// The cutover must already be in flight: an idle host is what it claims.
|
|
const moving = h.registry.idleRehome(request, () => result.promise, vi.fn())
|
|
const client = new FakeSocket()
|
|
h.session.pendingConns.set('conn', {
|
|
connId: 'conn',
|
|
connTicket: 'ticket',
|
|
client: client as unknown as WebSocket,
|
|
reservation: {
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId,
|
|
credentialKind: 'invite',
|
|
leaseExpiresAt: Date.now() + 1000
|
|
},
|
|
attachTimer: setTimeout(() => {}, 1000),
|
|
credentialActivityId: null
|
|
} as never)
|
|
const host = new FakeSocket()
|
|
// The ticket below is the live one: only the cutover fence may reject it.
|
|
expect(
|
|
await h.registry.acceptHostData(host as unknown as WebSocket, 'conn', 'ticket', 1)
|
|
).toBe(false)
|
|
expect(host.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String))
|
|
expect(h.observer.recordAuth).not.toHaveBeenCalled()
|
|
expect(h.session.pendingConns.has('conn')).toBe(true)
|
|
expect(h.session.activeConnIds.size).toBe(0)
|
|
result.resolve({ outcome: 'deferred' })
|
|
await moving
|
|
})
|
|
it('holds no attach ownership when no session owns the connection', async () => {
|
|
const h = await source()
|
|
const host = new FakeSocket()
|
|
expect(
|
|
await h.registry.acceptHostData(host as unknown as WebSocket, 'stranger', 'ticket', 1)
|
|
).toBe(false)
|
|
expect(h.observer.recordAuth).toHaveBeenCalledWith(false)
|
|
expect(host.close).toHaveBeenCalledWith(
|
|
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
|
expect.any(String)
|
|
)
|
|
// A leaked idle-work hold from the unowned attach would report `busy` here.
|
|
expect(
|
|
await h.registry.idleRehome(
|
|
request,
|
|
vi.fn().mockResolvedValue({ outcome: 'committed' }),
|
|
vi.fn()
|
|
)
|
|
).toEqual({ outcome: 'committed' })
|
|
})
|
|
it('returns the durable operation outcome after source retirement', async () => {
|
|
const h = await source()
|
|
const commit = vi.fn().mockResolvedValue({ outcome: 'committed' })
|
|
await h.registry.idleRehome(request, commit, vi.fn())
|
|
expect(
|
|
await h.registry.idleRehome(request, commit, vi.fn().mockResolvedValue('committed'))
|
|
).toEqual({ outcome: 'committed' })
|
|
expect(commit).toHaveBeenCalledOnce()
|
|
})
|
|
it('does not reopen a source overtaken by emergency drain', async () => {
|
|
const h = await source()
|
|
const result = deferred<{ outcome: 'deferred' }>()
|
|
const moving = h.registry.idleRehome(
|
|
request,
|
|
() => result.promise,
|
|
vi.fn().mockResolvedValue('not-committed')
|
|
)
|
|
h.registry.drain(0)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
result.resolve({ outcome: 'deferred' })
|
|
await moving
|
|
expect(h.session.state).toBe('closed')
|
|
expect(h.registry.get(request)).toBeNull()
|
|
})
|
|
})
|
|
|
|
// The host data leg's owner lookup is the registry's only whole-inventory scan on
|
|
// an attach. These count what that scan touches, not how long it takes.
|
|
describe('host data attach owner lookup', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
const SESSION_COUNT = 1000
|
|
const CONN_ID = 'conn-owned'
|
|
const OWNER_INDEX = { first: 0, middle: 499, last: 999 } as const
|
|
type Placement = keyof typeof OWNER_INDEX | 'absent'
|
|
type LookupCounts = { visits: number; membership: number }
|
|
|
|
function bindOwn<K, V>(map: Map<K, V>, property: string | symbol): unknown {
|
|
const value: unknown = Reflect.get(map, property, map)
|
|
return typeof value === 'function' ? value.bind(map) : value
|
|
}
|
|
|
|
// One visit per session the scan pulls off the map iterator; answers unchanged.
|
|
function countingValues<K, V>(map: Map<K, V>, counts: LookupCounts): Map<K, V> {
|
|
return new Proxy(map, {
|
|
get(target, property) {
|
|
if (property !== 'values') return bindOwn(target, property)
|
|
return function* (): Generator<V> {
|
|
for (const value of target.values()) {
|
|
counts.visits += 1
|
|
yield value
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// One membership check per `pendingConns.has`; answers unchanged.
|
|
function countingHas<K, V>(map: Map<K, V>, counts: LookupCounts): Map<K, V> {
|
|
return new Proxy(map, {
|
|
get(target, property) {
|
|
if (property !== 'has') return bindOwn(target, property)
|
|
return (key: K) => {
|
|
counts.membership += 1
|
|
return target.has(key)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
function pendingConn(
|
|
client: FakeSocket,
|
|
connTicket: string
|
|
): NonNullable<ReturnType<HostSession['pendingConns']['get']>> {
|
|
return {
|
|
connId: CONN_ID,
|
|
connTicket,
|
|
client: client as unknown as WebSocket,
|
|
reservation: {
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId,
|
|
credentialKind: 'invite',
|
|
relayDeviceId: 'device-1',
|
|
tokenHash: 'invite-hash',
|
|
reservationId: 'reservation-1',
|
|
leaseExpiresAt: Date.now() + 1000
|
|
},
|
|
attachTimer: setTimeout(() => {}, 1000),
|
|
credentialActivityId: null,
|
|
phonePath: 'db',
|
|
timing: {
|
|
startedAt: Date.now(),
|
|
connOpenAt: Date.now(),
|
|
stageMs: { assignment: 0, credential: 0, activity: 0 }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every decoy holds a pending conn of its own, so each membership check the
|
|
// scan makes is real work rather than a lookup in an empty map.
|
|
function decoySession(index: number, counts: LookupCounts): HostSession {
|
|
const pendingConns = new Map<string, unknown>([[`conn-decoy-${index}`, { connId: 'decoy' }]])
|
|
return {
|
|
relayHostId: `decoy-host-${index}`,
|
|
generation: 1,
|
|
state: 'active',
|
|
activeConnIds: new Set<string>(),
|
|
pendingConns: countingHas(pendingConns, counts)
|
|
} as unknown as HostSession
|
|
}
|
|
|
|
async function attachRegistry(placement: Placement, store: Partial<RelayCredentialStore> = {}) {
|
|
const h = createRegistry(vi.fn().mockResolvedValue('control:1'), {
|
|
failReservation: vi.fn().mockResolvedValue(undefined),
|
|
recordConnectionBasis: vi.fn().mockResolvedValue(undefined),
|
|
deactivateBasis: vi.fn().mockResolvedValue(undefined),
|
|
...store
|
|
})
|
|
const control = new FakeSocket()
|
|
await h.activate(control as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const internals = h.registry as unknown as { sessions: Map<string, HostSession> }
|
|
const [ownerKey, owner] = [...internals.sessions.entries()][0]!
|
|
const counts: LookupCounts = { visits: 0, membership: 0 }
|
|
const client = new FakeSocket()
|
|
if (placement !== 'absent') owner.pendingConns.set(CONN_ID, pendingConn(client, 'ticket'))
|
|
owner.pendingConns = countingHas(owner.pendingConns, counts)
|
|
const ordered: HostSession[] = []
|
|
const sessions = new Map<string, HostSession>()
|
|
const ownerIndex = placement === 'absent' ? 0 : OWNER_INDEX[placement]
|
|
for (let index = 0; index < SESSION_COUNT; index += 1) {
|
|
const session = index === ownerIndex ? owner : decoySession(index, counts)
|
|
ordered.push(session)
|
|
sessions.set(index === ownerIndex ? ownerKey : `decoy-${index}`, session)
|
|
}
|
|
internals.sessions = countingValues(sessions, counts)
|
|
return { ...h, owner, ordered, counts, client, control, sessions: internals.sessions }
|
|
}
|
|
|
|
it.each([
|
|
{
|
|
placement: 'first',
|
|
after: { visits: 1, membership: 1 }
|
|
},
|
|
{
|
|
placement: 'middle',
|
|
after: { visits: 500, membership: 500 }
|
|
},
|
|
{
|
|
placement: 'last',
|
|
after: { visits: 1000, membership: 1000 }
|
|
},
|
|
{
|
|
placement: 'absent',
|
|
after: { visits: 1000, membership: 1000 }
|
|
}
|
|
] as const)(
|
|
'visits the inventory once, not twice, for a $placement owner',
|
|
async ({ placement, after }) => {
|
|
const h = await attachRegistry(placement)
|
|
expect(h.sessions.size).toBe(SESSION_COUNT)
|
|
h.counts.visits = 0
|
|
h.counts.membership = 0
|
|
const host = new FakeSocket()
|
|
// An unusable ticket stops the attach immediately after the lookup, so the
|
|
// counts below belong to the lookup alone.
|
|
expect(
|
|
await h.registry.acceptHostData(host as unknown as WebSocket, CONN_ID, 'wrong', 1)
|
|
).toBe(false)
|
|
expect(h.observer.recordAuth).toHaveBeenCalledExactlyOnceWith(false)
|
|
expect(host.close).toHaveBeenCalledWith(
|
|
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
|
'invalid host data ticket'
|
|
)
|
|
expect({ ...h.counts }).toEqual(after)
|
|
}
|
|
)
|
|
|
|
it.each([
|
|
{ reason: 'ticket', ticket: 'wrong', generation: 1, state: 'active' },
|
|
{ reason: 'generation', ticket: 'ticket', generation: 2, state: 'active' },
|
|
{ reason: 'state', ticket: 'ticket', generation: 1, state: 'orphaned' }
|
|
] as const)('fails an attach whose $reason does not match the owner', async (input) => {
|
|
const h = await attachRegistry('middle')
|
|
h.owner.state = input.state
|
|
const host = new FakeSocket()
|
|
expect(
|
|
await h.registry.acceptHostData(
|
|
host as unknown as WebSocket,
|
|
CONN_ID,
|
|
input.ticket,
|
|
input.generation
|
|
)
|
|
).toBe(false)
|
|
expect(h.observer.recordAuth).toHaveBeenCalledExactlyOnceWith(false)
|
|
expect(host.close).toHaveBeenCalledWith(
|
|
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
|
|
'invalid host data ticket'
|
|
)
|
|
expect(h.owner.pendingConns.has(CONN_ID)).toBe(true)
|
|
expect(h.owner.activeConnIds.size).toBe(0)
|
|
})
|
|
|
|
it('rejects on the earlier duplicate owner rather than the later live one', async () => {
|
|
const h = await attachRegistry('middle')
|
|
h.ordered[0]!.pendingConns.set(CONN_ID, pendingConn(new FakeSocket(), 'stale-ticket') as never)
|
|
h.counts.visits = 0
|
|
h.counts.membership = 0
|
|
const host = new FakeSocket()
|
|
expect(
|
|
await h.registry.acceptHostData(host as unknown as WebSocket, CONN_ID, 'ticket', 1)
|
|
).toBe(false)
|
|
expect({ ...h.counts }).toEqual({ visits: 1, membership: 1 })
|
|
expect(h.owner.pendingConns.has(CONN_ID)).toBe(true)
|
|
})
|
|
|
|
it('consumes the earlier duplicate owner on basis failure and leaves the later one untouched', async () => {
|
|
const basis = vi.fn().mockRejectedValue(new Error('basis failed'))
|
|
const h = await attachRegistry('first', { recordConnectionBasis: basis })
|
|
const duplicate = h.ordered[3]!
|
|
duplicate.pendingConns.set(CONN_ID, pendingConn(new FakeSocket(), 'ticket') as never)
|
|
h.counts.visits = 0
|
|
h.counts.membership = 0
|
|
const host = new FakeSocket()
|
|
expect(
|
|
await h.registry.acceptHostData(host as unknown as WebSocket, CONN_ID, 'ticket', 1)
|
|
).toBe(false)
|
|
expect({ ...h.counts }).toEqual({ visits: 1, membership: 1 })
|
|
expect(h.observer.recordAuth).toHaveBeenCalledWith(true)
|
|
expect(basis).toHaveBeenCalledOnce()
|
|
// The first owner's entry was consumed; the later duplicate never was.
|
|
expect(h.owner.pendingConns.has(CONN_ID)).toBe(false)
|
|
expect(duplicate.pendingConns.has(CONN_ID)).toBe(true)
|
|
expect(h.owner.activeConnIds.size).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('paced drain', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
async function connectHosts(count: number): Promise<{
|
|
registry: HostSessionRegistry
|
|
sockets: FakeSocket[]
|
|
}> {
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate } = createRegistry(activateControl)
|
|
const sockets: FakeSocket[] = []
|
|
for (let index = 0; index < count; index += 1) {
|
|
const socket = new FakeSocket()
|
|
sockets.push(socket)
|
|
await activate(
|
|
socket as unknown as WebSocket,
|
|
{ ...identity, sub: `user-${index}` },
|
|
null,
|
|
1,
|
|
false,
|
|
1
|
|
)
|
|
socket.send.mockClear()
|
|
}
|
|
return { registry, sockets }
|
|
}
|
|
|
|
function drainsSent(sockets: FakeSocket[]): number {
|
|
return sockets.filter((socket) =>
|
|
socket.send.mock.calls.some(([payload]) => String(payload).includes('"type":"drain"'))
|
|
).length
|
|
}
|
|
|
|
it('sends every drain at once when no window is given', async () => {
|
|
const { registry, sockets } = await connectHosts(4)
|
|
registry.drain(0)
|
|
// A zero-grace drain is told by its close, once the host's release has landed.
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(drainsSent(sockets)).toBe(4)
|
|
})
|
|
|
|
// Windows here stay under the 75s control-silence watchdog, which would otherwise close
|
|
// a test socket that never heartbeats before its paced send is due.
|
|
it('spreads the sends evenly across the window', async () => {
|
|
const { registry, sockets } = await connectHosts(5)
|
|
registry.drain(0, { paceWindowMs: 40_000 })
|
|
// Each host is told by its zero-grace close, which a timer nested in the send runs 1 ms on.
|
|
const advance = async (ms: number): Promise<void> => {
|
|
await vi.advanceTimersByTimeAsync(ms)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
}
|
|
// The first host is told at once; the last lands on the window's closing edge.
|
|
await advance(0)
|
|
expect(drainsSent(sockets)).toBe(1)
|
|
await advance(10_000)
|
|
expect(drainsSent(sockets)).toBe(2)
|
|
await advance(20_000)
|
|
expect(drainsSent(sockets)).toBe(4)
|
|
await advance(10_000)
|
|
expect(drainsSent(sockets)).toBe(5)
|
|
})
|
|
|
|
it('refuses new host controls before the first paced send lands', async () => {
|
|
const { registry, sockets } = await connectHosts(3)
|
|
registry.drain(0, { paceWindowMs: 40_000 })
|
|
expect(registry.isDraining()).toBe(true)
|
|
// The fleet drain refuses newly arriving host controls immediately.
|
|
const socket = new FakeSocket()
|
|
registry.acceptControl(socket as unknown as WebSocket, { ...identity, sub: 'user-late' })
|
|
expect(socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, 'relay draining')
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(drainsSent(sockets)).toBe(1)
|
|
})
|
|
|
|
it('gives each host its own grace after its own send, not after the call', async () => {
|
|
const { registry, sockets } = await connectHosts(2)
|
|
registry.drain(10_000, { paceWindowMs: 40_000 })
|
|
await vi.advanceTimersByTimeAsync(10_000)
|
|
expect(sockets[0]!.readyState).toBe(sockets[0]!.CLOSED)
|
|
expect(sockets[1]!.readyState).toBe(sockets[1]!.OPEN)
|
|
// Its own send at 40s plus its own 10s grace, not 10s from the drain call.
|
|
await vi.advanceTimersByTimeAsync(39_999)
|
|
expect(sockets[1]!.readyState).toBe(sockets[1]!.OPEN)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(sockets[1]!.readyState).toBe(sockets[1]!.CLOSED)
|
|
})
|
|
|
|
it('leaves no timer behind once an emergency drain cuts a window short', async () => {
|
|
const { registry } = await connectHosts(4)
|
|
registry.drain(0, { paceWindowMs: 40_000 })
|
|
registry.drain(0)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
// Every session is closed, so anything still pending is an orphan of the cut window.
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('keeps the first teardown snapshot when a regional drain fires before the fleet one', async () => {
|
|
const { registry, sockets } = await connectHosts(1)
|
|
const session = registry.get({ userId: 'user-0', relayHostId: identity.relayHostId })!
|
|
session.activeSplices.set('conn-a', () => session.activeSplices.delete('conn-a'))
|
|
registry.drainHost({
|
|
attemptId: 'attempt',
|
|
userId: 'user-0',
|
|
relayHostId: identity.relayHostId,
|
|
sourceAssignmentEpoch: 1,
|
|
graceMs: 0
|
|
})
|
|
registry.drain(10)
|
|
await vi.advanceTimersByTimeAsync(11)
|
|
expect(session.closingCounts).toEqual({ splices: 1, pending: 0 })
|
|
expect(sockets[0]!.readyState).toBe(sockets[0]!.CLOSED)
|
|
})
|
|
|
|
it('lets an emergency drain supersede the sends still queued by a paced one', async () => {
|
|
const { registry, sockets } = await connectHosts(4)
|
|
registry.drain(0, { paceWindowMs: 40_000 })
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(drainsSent(sockets)).toBe(1)
|
|
registry.drain(0)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(drainsSent(sockets)).toBe(4)
|
|
const sendsAfterEmergency = sockets.map((socket) => socket.send.mock.calls.length)
|
|
await vi.advanceTimersByTimeAsync(40_000)
|
|
expect(sockets.map((socket) => socket.send.mock.calls.length)).toEqual(sendsAfterEmergency)
|
|
})
|
|
})
|
|
|
|
describe('controls closed after host proof but before wiring', () => {
|
|
let warn: MockInstance<typeof console.warn>
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
})
|
|
afterEach(() => {
|
|
warn.mockRestore()
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
const refusedLines = (): string[] =>
|
|
warn.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.filter((line) => line.includes('control refused after proof'))
|
|
|
|
function expectRefusal(
|
|
observer: { recordPostProofClose: ReturnType<typeof vi.fn> },
|
|
code: number | null,
|
|
reason: string,
|
|
cause?: string
|
|
): void {
|
|
expect(observer.recordPostProofClose).toHaveBeenCalledWith(reason)
|
|
const [line] = refusedLines()
|
|
expect(line).toContain(`host=${relayHostLogDigest(identity.relayHostId)} gen=1 app="1.4.173"`)
|
|
expect(line).toContain(`code=${code ?? 'none'} reason=${JSON.stringify(reason)}`)
|
|
if (cause) expect(line).toContain(`cause=${cause}`)
|
|
expect(line).not.toContain(identity.relayHostId)
|
|
expect(line).not.toContain(identity.sub)
|
|
}
|
|
|
|
it('logs an idle cutover refusal', async () => {
|
|
const h = createRegistry(vi.fn())
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: beginIdleWork only checks the host key's presence.
|
|
const idle = h.registry as unknown as { idleAttempts: Map<string, unknown> }
|
|
idle.idleAttempts.set(identity.relayHostId, {})
|
|
const socket = new FakeSocket()
|
|
await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
expect(socket.close).toHaveBeenCalledWith(
|
|
RELAY_CLOSE_CODE.WRONG_CELL,
|
|
'idle cutover in progress'
|
|
)
|
|
expectRefusal(h.observer, RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress')
|
|
})
|
|
|
|
it('logs a stalled activation queue once', async () => {
|
|
const stalled = deferred<string>()
|
|
const h = createRegistry(
|
|
vi.fn<RelayAssignmentStore['activateControl']>().mockReturnValueOnce(stalled.promise)
|
|
)
|
|
const first = h.activate(new FakeSocket() as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const waiting = new FakeSocket()
|
|
const second = h.activate(waiting as unknown as WebSocket, identity, null, 1, false, 1)
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
vi.advanceTimersByTime(30_000)
|
|
expectRefusal(h.observer, RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'control activation queue stalled')
|
|
stalled.resolve('control:production-gce-c3:1')
|
|
await Promise.all([first, second])
|
|
expect(h.observer.recordPostProofClose).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('logs a superseded activation', async () => {
|
|
const h = createRegistry(vi.fn())
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only compared by identity against the empty session map.
|
|
const stale = {} as HostSession
|
|
await h.activate(new FakeSocket() as unknown as WebSocket, identity, stale, 1, false, 1)
|
|
expectRefusal(h.observer, RELAY_CLOSE_CODE.PEER_DROPPED, 'control activation superseded')
|
|
})
|
|
|
|
it.each([
|
|
{ error: new Error('wrong_assignment'), cause: 'wrong_assignment' },
|
|
{ error: Object.assign(new Error('timeout'), { code: '57014' }), cause: 'database_transient' },
|
|
// Assembled at runtime so the secret scanner does not read the fixture as a real URL.
|
|
{
|
|
error: new Error(`connect ${['postgres', '//user:secret@db/relay'].join(':')}`),
|
|
cause: 'error'
|
|
}
|
|
])('logs a failed host-proof assignment check as $cause', async ({ error, cause }) => {
|
|
const h = createRegistry(
|
|
vi.fn<RelayAssignmentStore['activateControl']>().mockRejectedValueOnce(error)
|
|
)
|
|
await h.activate(new FakeSocket() as unknown as WebSocket, identity, null, 1, false, 1)
|
|
expectRefusal(
|
|
h.observer,
|
|
RELAY_CLOSE_CODE.WRONG_CELL,
|
|
'assignment changed during host proof',
|
|
cause
|
|
)
|
|
expect(refusedLines()[0]).not.toContain('secret')
|
|
})
|
|
|
|
it('logs a drain that lands during activation', async () => {
|
|
const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1'))
|
|
h.registry.drain(100)
|
|
await h.activate(new FakeSocket() as unknown as WebSocket, identity, null, 1, false, 1)
|
|
expectRefusal(h.observer, RELAY_CLOSE_CODE.DRAINING, 'relay draining')
|
|
})
|
|
|
|
it('logs a host that hangs up during activation, with no cell close code', async () => {
|
|
const blocked = deferred<string>()
|
|
const h = createRegistry(
|
|
vi.fn<RelayAssignmentStore['activateControl']>().mockReturnValueOnce(blocked.promise)
|
|
)
|
|
const socket = new FakeSocket()
|
|
const activation = h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
socket.close()
|
|
blocked.resolve('control:production-gce-c3:1')
|
|
await activation
|
|
expectRefusal(h.observer, null, 'host closed during activation')
|
|
})
|
|
|
|
it('logs a host that hangs up while queued, with no cell close code', async () => {
|
|
const stalled = deferred<string>()
|
|
const h = createRegistry(
|
|
vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockReturnValueOnce(stalled.promise)
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
)
|
|
const first = h.activate(new FakeSocket() as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const queued = new FakeSocket()
|
|
const second = h.activate(queued as unknown as WebSocket, identity, null, 1, false, 1)
|
|
queued.close()
|
|
stalled.resolve('control:production-gce-c3:1')
|
|
await Promise.all([first, second])
|
|
expectRefusal(h.observer, null, 'host closed while queued')
|
|
})
|
|
|
|
it('logs the hang-up, not a stall, when the queued host left before the deadline', async () => {
|
|
const stalled = deferred<string>()
|
|
const h = createRegistry(
|
|
vi.fn<RelayAssignmentStore['activateControl']>().mockReturnValueOnce(stalled.promise)
|
|
)
|
|
const first = h.activate(new FakeSocket() as unknown as WebSocket, identity, null, 1, false, 1)
|
|
const queued = new FakeSocket()
|
|
const second = h.activate(queued as unknown as WebSocket, identity, null, 1, false, 1)
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
queued.readyState = queued.CLOSED
|
|
vi.advanceTimersByTime(30_000)
|
|
expect(queued.close).toHaveBeenCalledWith(
|
|
RELAY_CLOSE_CODE.LIMIT_EXCEEDED,
|
|
'control activation queue stalled'
|
|
)
|
|
expectRefusal(h.observer, null, 'host closed while queued')
|
|
stalled.resolve('control:production-gce-c3:1')
|
|
await Promise.all([first, second])
|
|
})
|
|
|
|
it('does not log a control that was wired', async () => {
|
|
const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1'))
|
|
await h.activate(new FakeSocket() as unknown as WebSocket, identity, null, 1, false, 1)
|
|
expect(h.observer.recordPostProofClose).not.toHaveBeenCalled()
|
|
expect(refusedLines()).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('control error codes', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
async function revokeFailure(error: Error): Promise<{ sent: string[]; warned: string[] }> {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
const activateControl = vi
|
|
.fn<RelayAssignmentStore['activateControl']>()
|
|
.mockResolvedValue('control:production-gce-c3:1')
|
|
const { registry, activate } = createRegistry(activateControl, {
|
|
revoke: vi.fn().mockRejectedValue(error)
|
|
})
|
|
const socket = new FakeSocket()
|
|
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
|
socket.emit(
|
|
'message',
|
|
Buffer.from(JSON.stringify({ type: 'device-revoke', reqId: 'revoke-1', relayDeviceId: 'device-1' })),
|
|
false
|
|
)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
const sent = socket.send.mock.calls.map((call) => String(call[0]))
|
|
const warned = warn.mock.calls.map((call) => String(call[0]))
|
|
warn.mockRestore()
|
|
registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
return { sent, warned }
|
|
}
|
|
|
|
function controlErrors(sent: string[]): unknown[] {
|
|
return sent
|
|
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
.filter((message) => message.type === 'control-error')
|
|
}
|
|
|
|
it('never sends a database error message, which can carry the connection URL', async () => {
|
|
// Assembled at runtime so secret scanners do not flag a fixture URL.
|
|
const url = ['postgres', '://relay:', 'fake-pw', '@db.internal:5432/relay'].join('')
|
|
const error = Object.assign(new Error(`connect to ${url} failed: Connection terminated`), {
|
|
code: '08006'
|
|
})
|
|
const { sent, warned } = await revokeFailure(error)
|
|
|
|
expect(controlErrors(sent)).toEqual([
|
|
{ type: 'control-error', reqId: 'revoke-1', code: 'database_transient' }
|
|
])
|
|
expect(sent.join('\n')).not.toContain('fake-pw')
|
|
expect(warned).toContain('[orca-relay] control device-revoke failed: Error: redacted code=08006')
|
|
expect(warned.join('\n')).not.toContain('fake-pw')
|
|
})
|
|
|
|
it('maps any other unexpected message to a fixed code', async () => {
|
|
const { sent } = await revokeFailure(new Error('Unexpected token < in JSON'))
|
|
|
|
expect(controlErrors(sent)).toEqual([
|
|
{ type: 'control-error', reqId: 'revoke-1', code: 'control_operation_failed' }
|
|
])
|
|
})
|
|
|
|
it.each([
|
|
['a store code', new RelayStoreError('reject-revoked'), 'reject-revoked'],
|
|
['an invariant slug', new Error('authorization_expired'), 'authorization_expired']
|
|
])('keeps %s on the wire', async (_kind, error, code) => {
|
|
const { sent, warned } = await revokeFailure(error)
|
|
|
|
expect(controlErrors(sent)).toEqual([{ type: 'control-error', reqId: 'revoke-1', code }])
|
|
expect(warned.join('\n')).not.toContain('control device-revoke failed')
|
|
})
|
|
})
|