mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +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>
845 lines
32 KiB
TypeScript
845 lines
32 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import { RELAY_CLOSE_CODE, RELAY_PROTOCOL_LIMITS } from '@orca-cloud/relay-contract'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type WebSocket from 'ws'
|
|
import type { RelayAssignmentStore } from './assignment-store.js'
|
|
import type { RelayConfig } from './config.js'
|
|
import type { CredentialReservation, RelayCredentialStore } from './credential-store.js'
|
|
import {
|
|
CONTROL_LEASE_JITTER_MS,
|
|
CONTROL_LEASE_MS,
|
|
HostSessionRegistry
|
|
} from './host-session-registry.js'
|
|
import type { RelayRuntimeObserver } from './relay-observability.js'
|
|
import type { RelayTokenClaims } from './relay-token-verifier.js'
|
|
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
|
|
|
|
// 2026-09-04: phone dial timed out during serialized SQL; late accept leaked a 10s lease and failed bind with host_data_reservation_already_bound.
|
|
|
|
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')
|
|
})
|
|
}
|
|
|
|
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>((next) => (resolve = next))
|
|
return { promise, resolve }
|
|
}
|
|
|
|
const reservation: CredentialReservation = {
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId,
|
|
credentialKind: 'resume',
|
|
relayDeviceId: 'device-1',
|
|
tokenHash: 'hash',
|
|
reservationId: 'reservation-1',
|
|
leaseExpiresAt: Date.now() + 60_000,
|
|
acceptedCredentialVersion: 2,
|
|
acceptedAs: 'current'
|
|
}
|
|
|
|
function harness(options: { random?: () => number; now?: () => number } = {}) {
|
|
const acquireActivity = vi.fn().mockResolvedValue(undefined)
|
|
const releaseActivity = vi.fn().mockResolvedValue(true)
|
|
const assignments = {
|
|
activateControl: vi.fn().mockResolvedValue('control:production-gce-c3:1'),
|
|
markMigrationTargetRegistered: vi.fn().mockResolvedValue(undefined),
|
|
resolve: vi.fn().mockResolvedValue({ cellId: config.cellId }),
|
|
acquireActivity,
|
|
renewControlActivity: vi.fn().mockResolvedValue(undefined),
|
|
releaseActivity
|
|
} as unknown as RelayAssignmentStore
|
|
const store = {
|
|
resolveResume: vi.fn().mockResolvedValue({ userId: identity.sub }),
|
|
reserveCredential: vi.fn().mockResolvedValue(reservation),
|
|
failReservation: vi.fn().mockResolvedValue(undefined),
|
|
recordConnectionBasis: vi.fn().mockResolvedValue(undefined),
|
|
deactivateBasis: vi.fn().mockResolvedValue(undefined)
|
|
}
|
|
const observer = {
|
|
recordAuth: vi.fn(),
|
|
recordForwardedBytes: vi.fn(),
|
|
recordHttp: vi.fn(),
|
|
recordReconnect: vi.fn(),
|
|
recordSql: vi.fn(),
|
|
recordClientAcceptAbandoned: vi.fn(),
|
|
recordClientAcceptCompleted: vi.fn(),
|
|
recordControlRtt: vi.fn(),
|
|
recordPhoneRejection: vi.fn(),
|
|
recordPhoneAttachDeadline: vi.fn()
|
|
} satisfies RelayRuntimeObserver
|
|
const registry = new HostSessionRegistry(
|
|
config,
|
|
vi.fn(),
|
|
store as unknown as RelayCredentialStore,
|
|
assignments,
|
|
new ProcessQueuedByteBudget(),
|
|
observer,
|
|
options.now,
|
|
options.random
|
|
)
|
|
const activate = (
|
|
registry as unknown as {
|
|
activate: (
|
|
socket: WebSocket,
|
|
identity: RelayTokenClaims,
|
|
existing: null,
|
|
generation: number,
|
|
rebind: boolean,
|
|
assignmentEpoch: number,
|
|
appVersion: string
|
|
) => Promise<void>
|
|
}
|
|
).activate.bind(registry)
|
|
return { registry, store, assignments, acquireActivity, releaseActivity, observer, activate }
|
|
}
|
|
|
|
async function activeHost(h: ReturnType<typeof harness>): Promise<FakeSocket> {
|
|
const control = new FakeSocket()
|
|
await h.activate(control as unknown as WebSocket, identity, null, 1, false, 1, '1.4.197')
|
|
return control
|
|
}
|
|
|
|
describe('client accept abandoned mid-DB-phase', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('does not admit new source work after a drain crosses activity acquisition', async () => {
|
|
const h = harness()
|
|
const control = await activeHost(h)
|
|
const slow = deferred<void>()
|
|
h.acquireActivity.mockReturnValueOnce(slow.promise)
|
|
const client = new FakeSocket()
|
|
const capacity = { bind: vi.fn(), release: vi.fn() }
|
|
const accepting = h.registry.acceptClient(
|
|
client as unknown as WebSocket,
|
|
identity.relayHostId,
|
|
'credential',
|
|
capacity
|
|
)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
h.registry.drainHost({
|
|
attemptId: 'attempt',
|
|
userId: identity.sub,
|
|
relayHostId: identity.relayHostId,
|
|
sourceAssignmentEpoch: 1,
|
|
graceMs: 60_000
|
|
})
|
|
slow.resolve()
|
|
await accepting
|
|
expect(control.send).not.toHaveBeenCalledWith(expect.stringContaining('conn-open'))
|
|
expect(capacity.bind).not.toHaveBeenCalled()
|
|
expect(client.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String))
|
|
expect(h.releaseActivity).toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not splice an attachment whose generation retired during basis persistence', async () => {
|
|
const h = harness()
|
|
await activeHost(h)
|
|
const client = new FakeSocket()
|
|
await h.registry.acceptClient(
|
|
client as unknown as WebSocket,
|
|
identity.relayHostId,
|
|
'credential'
|
|
)
|
|
const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
|
|
const pending = [...session.pendingConns.values()][0]!
|
|
const slow = deferred<void>()
|
|
h.store.recordConnectionBasis.mockReturnValueOnce(slow.promise)
|
|
const host = new FakeSocket()
|
|
const attaching = h.registry.acceptHostData(
|
|
host as unknown as WebSocket,
|
|
pending.connId,
|
|
pending.connTicket,
|
|
1
|
|
)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
h.registry.drain(0)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
slow.resolve()
|
|
expect(await attaching).toBe(false)
|
|
expect(session.activeSplices.size).toBe(0)
|
|
expect(h.store.deactivateBasis).toHaveBeenCalledWith(pending.connId)
|
|
expect(client.send).not.toHaveBeenCalledWith(expect.stringContaining('\"ok\":true'))
|
|
expect(host.close).toHaveBeenCalled()
|
|
})
|
|
|
|
it('stops after a slow activity acquire when the phone already hung up', async () => {
|
|
const h = harness()
|
|
const control = await activeHost(h)
|
|
const slowAcquire = deferred<void>()
|
|
h.acquireActivity.mockReturnValueOnce(slowAcquire.promise)
|
|
const capacity = { bind: vi.fn(), release: vi.fn() }
|
|
const client = new FakeSocket()
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
try {
|
|
const accepting = h.registry.acceptClient(
|
|
client as unknown as WebSocket,
|
|
identity.relayHostId,
|
|
'credential',
|
|
capacity
|
|
)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(h.acquireActivity).toHaveBeenCalledOnce()
|
|
// The phone's 12s bound fires while the cell still waits on Postgres.
|
|
client.close(1000, 'client bound')
|
|
capacity.release()
|
|
slowAcquire.resolve()
|
|
await accepting
|
|
|
|
// No conn-open reached the desktop; nothing pending; the lease it just took is
|
|
// released instead of leaking to expiry cleanup; bind never throws.
|
|
expect(control.send).not.toHaveBeenCalledWith(expect.stringContaining('conn-open'))
|
|
expect(capacity.bind).not.toHaveBeenCalled()
|
|
const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })
|
|
expect(session?.pendingConns.size).toBe(0)
|
|
expect(h.store.failReservation).toHaveBeenCalledWith(reservation)
|
|
expect(h.releaseActivity).toHaveBeenCalledWith(
|
|
{ userId: identity.sub, relayHostId: identity.relayHostId },
|
|
expect.stringMatching(/^confirmation:/)
|
|
)
|
|
expect(h.observer.recordClientAcceptAbandoned).toHaveBeenCalledWith(
|
|
'activity',
|
|
expect.any(Number)
|
|
)
|
|
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
|
|
entry.includes('orca_relay_client_accept_abandoned')
|
|
)
|
|
expect(line).toBeDefined()
|
|
expect(JSON.parse(line!)).toMatchObject({ stage: 'activity' })
|
|
expect(line).not.toContain(identity.relayHostId)
|
|
} finally {
|
|
warn.mockRestore()
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
|
|
it('stops after a slow credential reservation without acquiring an activity lease', async () => {
|
|
const h = harness()
|
|
await activeHost(h)
|
|
const slowReserve = deferred<CredentialReservation>()
|
|
h.store.reserveCredential.mockReturnValueOnce(slowReserve.promise)
|
|
const client = new FakeSocket()
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
try {
|
|
const accepting = h.registry.acceptClient(
|
|
client as unknown as WebSocket,
|
|
identity.relayHostId,
|
|
'credential'
|
|
)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
client.close(1000, 'client bound')
|
|
slowReserve.resolve(reservation)
|
|
await accepting
|
|
|
|
expect(h.acquireActivity).not.toHaveBeenCalled()
|
|
expect(h.store.failReservation).toHaveBeenCalledWith(reservation)
|
|
expect(h.observer.recordClientAcceptAbandoned).toHaveBeenCalledWith(
|
|
'credential',
|
|
expect.any(Number)
|
|
)
|
|
} finally {
|
|
warn.mockRestore()
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
|
|
it('stops after a slow resume lookup before starting the invite and assignment lookups', async () => {
|
|
const h = harness()
|
|
await activeHost(h)
|
|
const store = h.store as typeof h.store & { resolveInviteForMove: ReturnType<typeof vi.fn> }
|
|
store.resolveInviteForMove = vi.fn().mockResolvedValue(null)
|
|
const slowResume = deferred<null>()
|
|
h.store.resolveResume.mockReturnValueOnce(slowResume.promise)
|
|
const resolveAssignment = (h.assignments as unknown as { resolve: ReturnType<typeof vi.fn> })
|
|
.resolve
|
|
resolveAssignment.mockClear()
|
|
const client = new FakeSocket()
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
try {
|
|
const accepting = h.registry.acceptClient(
|
|
client as unknown as WebSocket,
|
|
identity.relayHostId,
|
|
'credential'
|
|
)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
client.close(1000, 'client bound')
|
|
slowResume.resolve(null)
|
|
await accepting
|
|
|
|
expect(store.resolveInviteForMove).not.toHaveBeenCalled()
|
|
expect(resolveAssignment).not.toHaveBeenCalled()
|
|
expect(h.store.reserveCredential).not.toHaveBeenCalled()
|
|
expect(h.observer.recordClientAcceptAbandoned).toHaveBeenCalledWith(
|
|
'assignment',
|
|
expect.any(Number)
|
|
)
|
|
} finally {
|
|
warn.mockRestore()
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
|
|
it('stops after a slow same-cell assignment resolve, before reserving a credential', async () => {
|
|
const h = harness()
|
|
await activeHost(h)
|
|
const resolveAssignment = (h.assignments as unknown as { resolve: ReturnType<typeof vi.fn> })
|
|
.resolve
|
|
const slowResolve = deferred<{ cellId: string }>()
|
|
resolveAssignment.mockReturnValueOnce(slowResolve.promise)
|
|
const client = new FakeSocket()
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
try {
|
|
const accepting = h.registry.acceptClient(
|
|
client as unknown as WebSocket,
|
|
identity.relayHostId,
|
|
'credential'
|
|
)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
client.close(1000, 'client bound')
|
|
// A correct, same-cell assignment: only the closed socket stops the accept.
|
|
slowResolve.resolve({ cellId: config.cellId })
|
|
await accepting
|
|
|
|
// Proves the accept reached the third guard, not the first.
|
|
expect(resolveAssignment).toHaveBeenCalled()
|
|
expect(h.store.reserveCredential).not.toHaveBeenCalled()
|
|
expect(h.observer.recordClientAcceptAbandoned).toHaveBeenCalledWith(
|
|
'assignment',
|
|
expect.any(Number)
|
|
)
|
|
} finally {
|
|
warn.mockRestore()
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
|
|
it('logs and counts an attach deadline with what the cell knew of the control', async () => {
|
|
let now = 1_700_000_000_000
|
|
const h = harness({ now: () => now })
|
|
await activeHost(h)
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
try {
|
|
const client = new FakeSocket()
|
|
await h.registry.acceptClient(
|
|
client as unknown as WebSocket,
|
|
identity.relayHostId,
|
|
'credential'
|
|
)
|
|
now += RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs
|
|
vi.advanceTimersByTime(RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs)
|
|
// The phone-only reason; relay-hello itself is unchanged for strict old parsers.
|
|
expect(client.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.HOST_OFFLINE, 'attach-timeout')
|
|
expect(client.send).toHaveBeenCalledWith(
|
|
JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 })
|
|
)
|
|
expect(h.observer.recordPhoneAttachDeadline).toHaveBeenCalledOnce()
|
|
expect(h.observer.recordPhoneRejection).toHaveBeenCalledWith(RELAY_CLOSE_CODE.HOST_OFFLINE)
|
|
const lines = warn.mock.calls
|
|
.map(([line]) => String(line))
|
|
.filter((line) => line.includes('orca_relay_attach_deadline_offline'))
|
|
expect(lines.map((line) => JSON.parse(line))).toEqual([
|
|
{
|
|
event: 'orca_relay_attach_deadline_offline',
|
|
relayHostIdDigest: expect.any(String),
|
|
credentialKind: 'resume',
|
|
msSinceLastPong: RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs,
|
|
pingOutstanding: false,
|
|
controlAgeMs: RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs,
|
|
controlRttMs: null,
|
|
pong: 'fresh',
|
|
cellId: config.cellId
|
|
}
|
|
])
|
|
expect(lines[0]).not.toContain(identity.relayHostId)
|
|
} finally {
|
|
warn.mockRestore()
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
|
|
it('still opens the connection when the phone is holding on', async () => {
|
|
const h = harness()
|
|
const control = await activeHost(h)
|
|
const capacity = { bind: vi.fn(), release: vi.fn() }
|
|
const client = new FakeSocket()
|
|
await h.registry.acceptClient(
|
|
client as unknown as WebSocket,
|
|
identity.relayHostId,
|
|
'credential',
|
|
capacity
|
|
)
|
|
expect(control.send).toHaveBeenCalledWith(expect.stringContaining('"type":"conn-open"'))
|
|
expect(capacity.bind).toHaveBeenCalledOnce()
|
|
expect(h.observer.recordClientAcceptAbandoned).not.toHaveBeenCalled()
|
|
expect(client.close).not.toHaveBeenCalled()
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
})
|
|
|
|
describe('successful client accept timing', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('times every serialized stage plus the attach window once relay-hello lands', async () => {
|
|
let now = 1_700_000_000_000
|
|
const h = harness({ now: () => now })
|
|
const control = await activeHost(h)
|
|
h.store.resolveResume.mockImplementationOnce(async () => {
|
|
now += 5
|
|
return { userId: identity.sub }
|
|
})
|
|
h.store.reserveCredential.mockImplementationOnce(async () => {
|
|
now += 7
|
|
return reservation
|
|
})
|
|
h.acquireActivity.mockImplementationOnce(async () => {
|
|
now += 11
|
|
})
|
|
h.store.recordConnectionBasis.mockImplementationOnce(async () => {
|
|
now += 3
|
|
})
|
|
const client = new FakeSocket()
|
|
const hostData = new FakeSocket()
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
try {
|
|
await h.registry.acceptClient(client as unknown as WebSocket, identity.relayHostId, 'cred')
|
|
const connOpen = JSON.parse(
|
|
String(control.send.mock.calls.find((call) => String(call[0]).includes('conn-open'))![0])
|
|
) as { connId: string; connTicket: string }
|
|
// The desktop's data leg is the attach window this is meant to expose.
|
|
now += 23
|
|
const accepted = await h.registry.acceptHostData(
|
|
hostData as unknown as WebSocket,
|
|
connOpen.connId,
|
|
connOpen.connTicket,
|
|
1
|
|
)
|
|
|
|
expect(accepted).toBe(true)
|
|
expect(h.observer.recordClientAcceptCompleted).toHaveBeenCalledWith({
|
|
totalMs: 49,
|
|
stageMs: { assignment: 5, credential: 7, activity: 11, attach: 23, basis: 3 },
|
|
phonePath: 'db'
|
|
})
|
|
const line = log.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.find((entry) => entry.includes('orca_relay_client_accept_completed'))
|
|
expect(line).toBeDefined()
|
|
const event = JSON.parse(line!) as {
|
|
role: string
|
|
cellId: string
|
|
region: string
|
|
credentialKind: string
|
|
stageMs: Record<string, number>
|
|
totalMs: number
|
|
relayHostIdDigest: string
|
|
}
|
|
expect(event.credentialKind).toBe('resume')
|
|
expect(event).toMatchObject({ assignmentEpoch: 1, controlGeneration: 1, drainMode: 'none' })
|
|
// Joins the line back to the emitting process, like the runtime metrics event.
|
|
expect(event).toMatchObject({ role: 'cell', cellId: config.cellId, region: 'us-central1' })
|
|
expect(Object.keys(event.stageMs).sort()).toEqual([
|
|
'activity',
|
|
'assignment',
|
|
'attach',
|
|
'basis',
|
|
'credential'
|
|
])
|
|
for (const stage of Object.values(event.stageMs)) expect(stage).toBeGreaterThanOrEqual(0)
|
|
// The stages tile the accept end to end: every millisecond is attributed.
|
|
const summed = Object.values(event.stageMs).reduce((total, stage) => total + stage, 0)
|
|
expect(summed).toBe(event.totalMs)
|
|
expect(event.relayHostIdDigest).toMatch(/^[0-9a-f]{12}$/)
|
|
expect(line).not.toContain(identity.relayHostId)
|
|
} finally {
|
|
log.mockRestore()
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
})
|
|
|
|
// Fires one heartbeat and returns the `t` of the ping it sent, which is the only
|
|
// echo the registry will time.
|
|
async function advanceToPing(control: FakeSocket, clock: { now: number }): Promise<number> {
|
|
clock.now += RELAY_PROTOCOL_LIMITS.controlPingIntervalMs
|
|
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
|
|
const ping = control.send.mock.calls
|
|
.filter((call) => String(call[0]).includes('"type":"ping"'))
|
|
.at(-1)!
|
|
return (JSON.parse(String(ping[0])) as { t: number }).t
|
|
}
|
|
|
|
// The attach resolves its owning session once and hands it to the unfenced leg;
|
|
// these hold the session it must be and the order the client hears about it.
|
|
describe('host data attach ownership', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
const bystander = { ...identity, sub: 'user-2', relayHostId: 'qponmlkjihgfedcb' }
|
|
|
|
async function pendingAttach(h: ReturnType<typeof harness>) {
|
|
const client = new FakeSocket()
|
|
await h.registry.acceptClient(
|
|
client as unknown as WebSocket,
|
|
identity.relayHostId,
|
|
'credential'
|
|
)
|
|
const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
|
|
return { client, session, pending: [...session.pendingConns.values()][0]! }
|
|
}
|
|
|
|
it('attaches the session that owns the connection, not the first one registered', async () => {
|
|
const h = harness()
|
|
const idle = new FakeSocket()
|
|
await h.activate(idle as unknown as WebSocket, bystander, null, 1, false, 1, '1.4.197')
|
|
await activeHost(h)
|
|
const { client, session, pending } = await pendingAttach(h)
|
|
const idleSession = h.registry.get({
|
|
userId: bystander.sub,
|
|
relayHostId: bystander.relayHostId
|
|
})!
|
|
const host = new FakeSocket()
|
|
expect(
|
|
await h.registry.acceptHostData(
|
|
host as unknown as WebSocket,
|
|
pending.connId,
|
|
pending.connTicket,
|
|
1
|
|
)
|
|
).toBe(true)
|
|
expect(client.send).toHaveBeenCalledWith(expect.stringContaining('"type":"relay-hello"'))
|
|
expect(session.activeSplices.has(pending.connId)).toBe(true)
|
|
expect(idleSession.activeSplices.size).toBe(0)
|
|
expect(idleSession.activeConnIds.size).toBe(0)
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
it('acknowledges the client only after the connection basis is persisted', async () => {
|
|
const h = harness()
|
|
await activeHost(h)
|
|
const { client, session, pending } = await pendingAttach(h)
|
|
const basis = deferred<void>()
|
|
h.store.recordConnectionBasis.mockReturnValueOnce(basis.promise)
|
|
const host = new FakeSocket()
|
|
const attaching = h.registry.acceptHostData(
|
|
host as unknown as WebSocket,
|
|
pending.connId,
|
|
pending.connTicket,
|
|
1
|
|
)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(h.store.recordConnectionBasis).toHaveBeenCalledOnce()
|
|
expect(client.send).not.toHaveBeenCalledWith(expect.stringContaining('relay-hello'))
|
|
basis.resolve()
|
|
expect(await attaching).toBe(true)
|
|
expect(client.send).toHaveBeenCalledWith(expect.stringContaining('"type":"relay-hello"'))
|
|
expect(session.activeSplices.has(pending.connId)).toBe(true)
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
})
|
|
|
|
describe('control round-trip sampling', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('logs a host once at the fourth sample and not again within the hour', async () => {
|
|
const clock = { now: 1_700_000_000_000 }
|
|
const h = harness({ now: () => clock.now })
|
|
const control = await activeHost(h)
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
const rttLines = (): string[] =>
|
|
log.mock.calls
|
|
.map((call) => String(call[0]))
|
|
.filter((entry) => entry.includes('orca_relay_host_control_rtt'))
|
|
// One heartbeat, then the desktop's echo of that ping's own `t` 40 ms later.
|
|
const roundTrip = async (): Promise<void> => {
|
|
const pingAt = await advanceToPing(control, clock)
|
|
clock.now += 40
|
|
control.emit('message', JSON.stringify({ type: 'pong', t: pingAt }), false)
|
|
}
|
|
try {
|
|
for (let round = 0; round < 3; round++) await roundTrip()
|
|
expect(h.observer.recordControlRtt).toHaveBeenCalledTimes(3)
|
|
expect(rttLines()).toHaveLength(0)
|
|
|
|
await roundTrip()
|
|
expect(h.observer.recordControlRtt).toHaveBeenLastCalledWith(40)
|
|
expect(rttLines()).toHaveLength(1)
|
|
expect(JSON.parse(rttLines()[0]!)).toMatchObject({
|
|
event: 'orca_relay_host_control_rtt',
|
|
role: 'cell',
|
|
cellId: config.cellId,
|
|
region: 'us-central1',
|
|
rttMsMedian: 40,
|
|
assignmentEpoch: 1,
|
|
controlGeneration: 1,
|
|
drainMode: 'none',
|
|
sampleCount: 4
|
|
})
|
|
expect(rttLines()[0]).not.toContain(identity.relayHostId)
|
|
|
|
// Later samples keep feeding the fleet metric, but stay silent for an hour.
|
|
for (let round = 0; round < 8; round++) await roundTrip()
|
|
expect(h.observer.recordControlRtt).toHaveBeenCalledTimes(12)
|
|
expect(rttLines()).toHaveLength(1)
|
|
|
|
const elapsedStart = clock.now
|
|
while (clock.now - elapsedStart < 60 * 60 * 1000) await roundTrip()
|
|
expect(rttLines()).toHaveLength(2)
|
|
} finally {
|
|
log.mockRestore()
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
|
|
it('ignores a pong that answers no outstanding ping', async () => {
|
|
const clock = { now: 1_700_000_000_000 }
|
|
const h = harness({ now: () => clock.now })
|
|
const control = await activeHost(h)
|
|
try {
|
|
// Nothing has been pinged yet, so even a plausible echo is not a round trip.
|
|
control.emit('message', JSON.stringify({ type: 'pong' }), false)
|
|
control.emit('message', JSON.stringify({ type: 'pong', t: 'later' }), false)
|
|
control.emit('message', JSON.stringify({ type: 'pong', t: clock.now }), false)
|
|
control.emit('message', JSON.stringify({ type: 'pong', t: clock.now - 10 }), false)
|
|
expect(h.observer.recordControlRtt).not.toHaveBeenCalled()
|
|
|
|
const pingAt = await advanceToPing(control, clock)
|
|
// A guessed timestamp is not the outstanding ping's `t`, so it is dropped.
|
|
control.emit('message', JSON.stringify({ type: 'pong', t: pingAt - 1 }), false)
|
|
control.emit('message', JSON.stringify({ type: 'pong', t: pingAt + 1 }), false)
|
|
expect(h.observer.recordControlRtt).not.toHaveBeenCalled()
|
|
|
|
clock.now += 10
|
|
control.emit('message', JSON.stringify({ type: 'pong', t: pingAt }), false)
|
|
expect(h.observer.recordControlRtt).toHaveBeenCalledWith(10)
|
|
} finally {
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
|
|
it('records one sample per ping however many pongs a host floods', async () => {
|
|
const clock = { now: 1_700_000_000_000 }
|
|
const h = harness({ now: () => clock.now })
|
|
const control = await activeHost(h)
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
try {
|
|
const pingAt = await advanceToPing(control, clock)
|
|
clock.now += 12
|
|
for (let flood = 0; flood < 5_000; flood++) {
|
|
control.emit('message', JSON.stringify({ type: 'pong', t: pingAt }), false)
|
|
control.emit('message', JSON.stringify({ type: 'pong', t: clock.now }), false)
|
|
}
|
|
// One answered ping is one process-wide sample and one per-session sample, so
|
|
// neither the metric window nor the hourly log line can be flooded.
|
|
expect(h.observer.recordControlRtt).toHaveBeenCalledTimes(1)
|
|
expect(h.observer.recordControlRtt).toHaveBeenCalledWith(12)
|
|
expect(
|
|
log.mock.calls.filter((call) => String(call[0]).includes('orca_relay_host_control_rtt'))
|
|
).toHaveLength(0)
|
|
} finally {
|
|
log.mockRestore()
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('control lease jitter', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('grants a lease uniformly around its mean so cohorts drift apart at the same mean rate', async () => {
|
|
const now = 1_700_000_000_000
|
|
const helloAck = (socket: FakeSocket) =>
|
|
JSON.parse(
|
|
String(socket.send.mock.calls.find((call) => String(call[0]).includes('host-hello-ack'))![0])
|
|
) as { leaseExpiresAt: number }
|
|
|
|
const shortest = harness({ now: () => now, random: () => 0 })
|
|
const shortestAck = helloAck(await activeHost(shortest))
|
|
const centered = harness({ now: () => now, random: () => 0.5 })
|
|
const centeredAck = helloAck(await activeHost(centered))
|
|
const longestRoll = 0.999999
|
|
const longest = harness({ now: () => now, random: () => longestRoll })
|
|
const longestAck = helloAck(await activeHost(longest))
|
|
|
|
// Pinned, not bounded: a jitter clamped to one side still satisfies an upper
|
|
// bound, so only the exact top of the band proves it is symmetric.
|
|
const longestOffset = Math.floor((longestRoll * 2 - 1) * CONTROL_LEASE_JITTER_MS)
|
|
expect(shortestAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS - CONTROL_LEASE_JITTER_MS)
|
|
expect(centeredAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS)
|
|
expect(longestAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS + longestOffset)
|
|
shortest.registry.drain(0)
|
|
centered.registry.drain(0)
|
|
longest.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
|
|
it('rebinds re-roll the jitter instead of pinning the cohort phase', async () => {
|
|
const now = 1_700_000_000_000
|
|
let roll = 0
|
|
const h = harness({ now: () => now, random: () => roll })
|
|
const first = await activeHost(h)
|
|
const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
|
|
const firstLease = session.leaseExpiresAt
|
|
roll = 0.75
|
|
const rebind = new FakeSocket()
|
|
await (
|
|
h.registry as unknown as {
|
|
activate: (...args: unknown[]) => Promise<void>
|
|
}
|
|
).activate(rebind as unknown as WebSocket, identity, session, 1, true, 1, '1.4.197')
|
|
expect(session.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS + CONTROL_LEASE_JITTER_MS / 2)
|
|
expect(session.leaseExpiresAt).not.toBe(firstLease)
|
|
expect(first.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.PEER_DROPPED, 'control rebound')
|
|
h.registry.drain(0)
|
|
vi.advanceTimersByTime(0)
|
|
})
|
|
})
|
|
|
|
describe('paced drain and the phones of a host not yet told', () => {
|
|
beforeEach(() => vi.useFakeTimers())
|
|
afterEach(() => {
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
const laterHostId = 'qrstuvwxyz012345'
|
|
const laterIdentity = { ...identity, sub: 'user-2', relayHostId: laterHostId }
|
|
|
|
async function twoHostCell(): Promise<{
|
|
h: ReturnType<typeof harness>
|
|
told: FakeSocket
|
|
untold: FakeSocket
|
|
}> {
|
|
const h = harness()
|
|
const told = await activeHost(h)
|
|
const untold = new FakeSocket()
|
|
await h.activate(untold as unknown as WebSocket, laterIdentity, null, 1, false, 1, '1.4.197')
|
|
// Both hosts now dial in, so the credential mocks have to answer for either.
|
|
h.store.resolveResume.mockImplementation(async (hostId: string) => ({
|
|
userId: hostId === laterHostId ? laterIdentity.sub : identity.sub
|
|
}))
|
|
h.store.reserveCredential.mockImplementation(async (hostId: string) => ({
|
|
...reservation,
|
|
userId: hostId === laterHostId ? laterIdentity.sub : identity.sub,
|
|
relayHostId: hostId
|
|
}))
|
|
return { h, told, untold }
|
|
}
|
|
|
|
async function dial(h: ReturnType<typeof harness>, hostId: string): Promise<FakeSocket> {
|
|
const client = new FakeSocket()
|
|
await h.registry.acceptClient(client as unknown as WebSocket, hostId, 'credential')
|
|
return client
|
|
}
|
|
|
|
it('serves a host whose drain has not been sent and refuses one whose has', async () => {
|
|
const { h, told, untold } = await twoHostCell()
|
|
h.registry.drain(0, { paceWindowMs: 40_000 })
|
|
|
|
const refused = await dial(h, identity.relayHostId)
|
|
expect(refused.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, expect.any(String))
|
|
expect(told.send).not.toHaveBeenCalledWith(expect.stringContaining('conn-open'))
|
|
|
|
const served = await dial(h, laterHostId)
|
|
expect(served.close).not.toHaveBeenCalled()
|
|
expect(untold.send).toHaveBeenCalledWith(expect.stringContaining('conn-open'))
|
|
})
|
|
|
|
it('refuses that host\'s phones as soon as its own drain is sent', async () => {
|
|
const { h, untold } = await twoHostCell()
|
|
h.registry.drain(0, { paceWindowMs: 40_000 })
|
|
await vi.advanceTimersByTimeAsync(40_000)
|
|
// Told by its zero-grace close, a timer nested 1 ms after the send.
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(untold.send).toHaveBeenCalledWith(expect.stringContaining('"type":"drain"'))
|
|
|
|
const refused = await dial(h, laterHostId)
|
|
expect(refused.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, expect.any(String))
|
|
})
|
|
|
|
it('keeps an unpaced drain refusing every phone at once', async () => {
|
|
const { h } = await twoHostCell()
|
|
h.registry.drain(0)
|
|
for (const hostId of [identity.relayHostId, laterHostId]) {
|
|
const refused = await dial(h, hostId)
|
|
expect(refused.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, expect.any(String))
|
|
}
|
|
})
|
|
})
|