test: strengthen transport recovery and benchmark assertions (#33547)

* test: strengthen transport recovery and benchmark assertions

* test: use preferred array type in reconnect fixture

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-25 07:22:28 -07:00
committed by GitHub
co-authored by OrcaWin
parent d1590a7280
commit e13c47bea9
21 changed files with 310 additions and 106 deletions
+16 -1
View File
@@ -1,3 +1,4 @@
import assert from 'node:assert/strict'
import { performance } from 'node:perf_hooks'
import { platform, release } from 'node:os'
import {
@@ -72,6 +73,20 @@ function median(values: readonly number[]): number {
}
function benchmarkCase(entry: BenchmarkCase): BenchmarkResult {
const payload = prepareJsonRpcPayload(entry.message)
assert.equal(
HEADER_LENGTH + payload.byteLength,
encodeLegacyJsonRpcFrame(entry.message, 0, 0).length
)
for (let target = 0; target < entry.targets; target++) {
const id = target + 37
const ack = 19
assert.deepEqual(
encodePreparedJsonRpcFrame(payload, id, ack),
encodeLegacyJsonRpcFrame(entry.message, id, ack),
`${entry.name}: prepared frame differs for target ${target}`
)
}
const legacy = (iteration: number): void => runLegacy(entry.message, entry.targets, iteration)
const prepared = (iteration: number): void => runPrepared(entry.message, entry.targets, iteration)
for (let warmup = 0; warmup < 3; warmup++) {
@@ -160,7 +175,7 @@ const lines = [
),
`checksum=${checksum}`,
'Limitations: in-process framing only; excludes dispatcher scheduling, sink/network I/O, and tail-GC.',
'Legacy is the exact pre-change framing composition retained in this script, not a separate checkout.',
'Legacy models a separate size estimate and JSON serialization per target using the current frame encoder.',
'The two-target rows model dispatcher fan-out; watcher batches and file streams are commonly single-target.'
]
process.stdout.write(`${lines.join('\n')}\n`)
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// Benchmark four per-keystroke/store-write projections. Scenarios 1/3/4 compare production with pre-change baseline after checking equivalence.
// Benchmark four projection workloads. Scenarios 1/3/4 compare current production with local baselines after checking equivalence.
// Scenario 2 models both loops because its React hook cannot load without a renderer; it does not time production.
import { spawnSync } from 'node:child_process'
import { transformSync } from 'esbuild'
@@ -133,7 +133,7 @@ compare({
label: 'workspace board filter (per keystroke burst)',
scale: `${WORKTREES} worktrees x ${KEYSTROKES} keystrokes`,
drives: 'production',
// Omitting `documents` is the pre-change shape: the index is rebuilt inside every match.
// Omitting `documents` rebuilds the index inside every match.
before: () => matchAll(undefined),
// The hook memoizes the index on [worktrees, repoMap]; only the match reruns per keystroke.
after: () => matchAll(buildWorkspaceBoardPaletteDocuments({ worktrees: boardWorktrees, repoMap }))
@@ -156,9 +156,17 @@ const groupTabById = new Map(groupTabs.map((item) => [item.id, item]))
function tabProjections(findOpenFile, findGroupTab) {
const editorItems = groupTabs
.filter((item) => item.contentType === 'editor')
.map((item) => findOpenFile(item.entityId))
.filter((file) => file !== undefined)
const order = tabOrder.map((itemId) => findGroupTab(itemId)?.entityId ?? itemId)
.map((item) => {
const file = findOpenFile(item.entityId)
return file ? { ...file, tabId: item.id } : null
})
.filter((file) => file !== null)
const order = tabOrder.map((itemId) => {
const item = findGroupTab(itemId)
return item && (item.contentType === 'terminal' || item.contentType === 'browser')
? item.entityId
: itemId
})
return [editorItems, order]
}
@@ -193,7 +201,7 @@ const changedEntries = Array.from({ length: CHANGED_FILES }, (_, index) => ({
path: `src/area-${index % 20}/module-${index % 60}/nested/deep/part-${index % 7}/file-${index}.ts`
}))
// Pre-change `buildSourceControlTree`: identical except each ancestor path is re-joined.
// Local tree comparator re-joins ancestor paths; shares current normalization and sorting.
function buildSourceControlTreeBefore(area, entries) {
const makeDirectory = (dirPath, name, depth) => ({
type: 'directory',
@@ -291,7 +299,7 @@ const boundaryArgs = {
repoHeaderBucketByRepoId: new Map(headerRepoIds.map((id) => [id, 'ungrouped']))
}
// Pre-change `getRepoHeaderSectionEndByRepoId`: a findIndex and an indexOf per header row.
// Local boundary comparator uses findIndex and indexOf per header row.
function getRepoHeaderSectionEndByRepoIdBefore(args) {
const rowStarts = []
let offset = 0
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { performance } from 'node:perf_hooks'
import { extractIconHref } from '../../src/main/repo-icon-source-href.ts'
// Original production expressions, preserved for the before/after measurement.
// Regex comparator retained for the before/after measurement.
const html =
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i
const object =
@@ -34,6 +34,16 @@ function measurePair(source) {
}
}
for (const [source, expected] of [
['<link rel="icon" href="/favicon.ico?version=1">', '/favicon.ico'],
["<link href='/shortcut.svg' rel='shortcut icon'>", '/shortcut.svg'],
['{ rel: "icon", href: "/object.png?version=1" }', '/object.png'],
["{ href: '/shortcut-object.svg', rel: 'shortcut icon' }", '/shortcut-object.svg']
]) {
assert.equal(original(source), expected)
assert.equal(extractIconHref(source), expected)
}
const results = []
for (const size of [8192, 16384, 32768]) {
for (const variant of ['no icon', 'rel without href', 'unterminated link starts']) {
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// Measures the complete ProseMirror doc traversal used by the two doc-link plugins.
// Measures doc-link scans over plain paragraph fixtures built from markdown source lines.
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
@@ -99,7 +99,10 @@ function createFixture(doc, nonce) {
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
const middle = sorted.length / 2
return Number.isInteger(middle)
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[Math.floor(middle)]
}
function measureCorpus(docs) {
@@ -174,7 +177,7 @@ const large = docs.filter((doc) => doc.size > 3000)
const biggest = docs.reduce((a, b) => (b.size > a.size ? b : a))
const pad = (value, width) => String(value).padStart(width)
console.log('Doc-link ProseMirror traversal, per editor transaction. Lower is better.')
console.log('Doc-link ProseMirror scan over plain paragraph fixtures. Lower is better.')
console.log(
`docs=${docs.length} (>3KB: ${large.length}) iterations=${ITERATIONS} (interleaved median)`
)
@@ -183,7 +186,7 @@ console.log(
)
for (const [label, set] of [
['all repo markdown', docs],
['repo markdown sample', docs],
['docs over 3 KB', large],
[`biggest (${biggest.file.split('/').pop()})`, [biggest]]
]) {
@@ -198,5 +201,5 @@ for (const [label, set] of [
)
}
console.log(
'\nAuto-conversion pays this once per keystroke; preview decorations pay it once\nper keystroke and once per caret move.'
'\nEach round scans one document from the selected corpus. Fixtures omit Markdown parsing,\ncode marks/blocks, link conversion, resolution and decorations; this is not full plugin timing.'
)
@@ -90,11 +90,11 @@ function queries(): string[] {
const run = (index: number, length: number): string =>
Array.from({ length }, (_unused, step) => QUERIES[(index + step) % QUERIES.length]).join(' ')
return [
// Two terms, unquoted: not literal, so straight to OR.
// Unquoted pairs use the current phrase/AND/OR retrieval ladder.
...QUERIES,
// Quoted pair reaches phrase: fourteen-word corpus contains every pair.
// Quoted pairs sample phrase matching in the fourteen-word corpus.
...QUERIES.map((query) => `"${query}"`),
// Quoted eight-term run is absent from corpus, forcing phrase miss then AND.
// Longer quoted runs sample fallback routes; reports group by the observed route.
...QUERIES.map((_query, index) => `"${run(index, 4)}"`)
]
}
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// Benchmark production capTerminalScrollbackSessionBuffer/remapPaneKeys against equivalent pre-change baselines on every-write CPU paths.
// Benchmark production scrollback capping and pane-key remapping against scan/rebuild baselines.
// Snapshot disk rewrite is measured separately (#18764).
import { spawnSync } from 'node:child_process'
import { performance } from 'node:perf_hooks'
@@ -65,7 +65,8 @@ const { isTerminalLeafId, makePaneKey, parsePaneKey } = await import(
function median(samples) {
const sorted = [...samples].sort((left, right) => left - right)
return sorted[Math.floor(sorted.length / 2)]
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
}
function timeRounds(run) {
@@ -87,7 +88,7 @@ function report(label, baselineMs, currentMs, extra = '') {
return speedup
}
// Verbatim pre-change capTerminalScrollbackSessionBuffer; measureUtf8ByteLength itself is unchanged.
// Scan UTF-8 bytes before clamping; compare with the production budget probe.
function baselineCapScrollbackBuffer(buffer) {
if (
buffer.length <= TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT &&
@@ -149,7 +150,7 @@ for (let index = 0; index < PANE_KEYS; index += 1) {
leaves.set(leafId, leafId)
}
// Verbatim pre-change remapPaneKeys: parses every key, then rebuilds the object regardless.
// Parse every key and rebuild the object; compare steady-state reuse.
function baselineRemapPaneKeys(values, remap) {
if (!values || Object.keys(values).length === 0) {
return { values, changed: false }
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// Measure full-chunk scheme scans against bounded 512-byte carry suffix on each PTY chunk, including early-out without /pull/.
// Arms mirror production; re-read source constants at startup so changed cap/schemes fail rather than benchmarking stale assumptions.
// Model full-chunk and bounded carry scans, including an includes() early-out without /pull/.
// Source constants are checked; the mirrored algorithms still require manual review for drift.
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
@@ -61,7 +61,7 @@ function endsWithHttpSchemePrefixFragment(value) {
return ''
}
// Pre-fix implementation, kept verbatim for comparison.
// Full-chunk comparator for the bounded-suffix model.
function carryBefore(value) {
const schemeIndex = Math.max(...HTTP_SCHEME_PREFIXES.map((prefix) => value.lastIndexOf(prefix)))
if (schemeIndex !== -1) {
@@ -181,10 +181,10 @@ for (const bytes of SIZES) {
}
const pad = (value, width) => String(value).padStart(width)
console.log('PR-link carry scan, per PTY chunk. Speedup = before / after (>1 is faster).')
console.log('Modeled PR-link carry scan, per chunk. Ratio = full-chunk / bounded (>1 is faster).')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
console.log(
`${pad('chunk', 9)} ${pad('carry only', 12)} ${pad('detector path', 15)} ${pad('fragment tail', 15)}`
`${pad('chunk', 9)} ${pad('carry only', 12)} ${pad('modeled path', 15)} ${pad('fragment tail', 15)}`
)
for (const row of rows) {
console.log(
@@ -193,7 +193,7 @@ for (const row of rows) {
}
console.log(
'\ncarry only = the scan this change bounds, in isolation.\n' +
'detector path = includes() + carry, i.e. what the PTY hot path actually saves.\n' +
'fragment tail = chunk ending mid-scheme, where the new code pays an extra\n' +
' bounded probe. ~1x means the fallback costs nothing material.'
'modeled path = includes() + carry; excludes normalization, parsing and callbacks.\n' +
'fragment tail = chunk ending mid-scheme, including the fallback probe.\n' +
'Ratios describe these local models and fixtures; the full detector is not executed.'
)
@@ -22,6 +22,9 @@ class FakeSession implements RpcClient {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
listenerCount(): number {
return this.listeners.size
}
setState(state: ConnectionState): void {
this.state = state
for (const listener of this.listeners) {
@@ -64,6 +67,8 @@ describe('waitForAuthenticated', () => {
const outcome = await settle(waiting)
expect(outcome.error).toBeInstanceOf(ReplacementAuthenticationTimeoutError)
expect(outcome.error?.message).toBe('replacement session authentication timed out')
expect(session.listenerCount()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
// Allow lock-contended cell assignment past the opening deadline through per-stage budgets.
@@ -83,6 +88,8 @@ describe('waitForAuthenticated', () => {
await vi.advanceTimersByTimeAsync(20_000)
session.setState('connected')
await expect(waiting).resolves.toBeUndefined()
expect(session.listenerCount()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
it('bounds a cell that took the dial and never answers, naming the stage', async () => {
@@ -96,22 +103,35 @@ describe('waitForAuthenticated', () => {
await vi.advanceTimersByTimeAsync(1)
const outcome = await settle(waiting)
expect(outcome.error).toBeInstanceOf(ReplacementAuthenticationTimeoutError)
expect((outcome.error as ReplacementAuthenticationTimeoutError).stage).toBe('awaiting-hello')
if (!(outcome.error instanceof ReplacementAuthenticationTimeoutError)) {
throw new Error('Expected a stage timeout')
}
expect(outcome.error.stage).toBe('awaiting-hello')
expect(outcome.error?.message).toBe(
'replacement session authentication timed out (awaiting-hello, 30s)'
)
expect(session.listenerCount()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
it('keeps the caller bound while the socket never opens', async () => {
const session = new FakeRelaySession()
const waiting = waitForAuthenticated(session, 12_000)
waiting.catch(() => {})
await vi.advanceTimersByTimeAsync(12_000)
await vi.advanceTimersByTimeAsync(11_999)
expect((await settle(waiting)).status).toBe('pending')
await vi.advanceTimersByTimeAsync(1)
const outcome = await settle(waiting)
expect((outcome.error as ReplacementAuthenticationTimeoutError).stage).toBe('opening')
expect(outcome.error).toBeInstanceOf(ReplacementAuthenticationTimeoutError)
if (!(outcome.error instanceof ReplacementAuthenticationTimeoutError)) {
throw new Error('Expected an opening timeout')
}
expect(outcome.error.stage).toBe('opening')
expect(outcome.error?.message).toBe(
'replacement session authentication timed out (opening, 12s)'
)
expect(session.listenerCount()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
})
it('ignores stage advances after the wait has settled', async () => {
@@ -119,6 +139,7 @@ describe('waitForAuthenticated', () => {
const waiting = waitForAuthenticated(session, 12_000)
session.setState('disconnected')
await expect(waiting).rejects.toThrow('replacement session disconnected')
expect(session.listenerCount()).toBe(0)
session.dialStage.advance('awaiting-hello')
expect(vi.getTimerCount()).toBe(0)
})
@@ -86,6 +86,7 @@ describe('mobile rpc-client connect-wait replay', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSockets.length = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client.
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
@@ -115,6 +116,7 @@ describe('mobile rpc-client connect-wait replay', () => {
await vi.advanceTimersByTimeAsync(500)
mockSockets[1]!.open()
await vi.advanceTimersByTimeAsync(0)
expect(client.getState()).toBe('connected')
expect(terminalSendFrames(mockSockets[1]!)).toEqual([])
} finally {
client.close()
@@ -141,6 +143,22 @@ describe('mobile rpc-client connect-wait replay', () => {
mockSockets[1]!.open()
await vi.advanceTimersByTimeAsync(0)
expect(terminalSendFrames(mockSockets[1]!)).toHaveLength(1)
const frame: unknown = JSON.parse(
terminalSendFrames(mockSockets[1]!)[0]!.replace(/^encrypted:/, '')
)
expect(frame).toEqual({
id: expect.any(String),
deviceToken: 'token',
method: 'terminal.send',
params: { terminal: 't1', text: 'YZZY' }
})
if (!frame || typeof frame !== 'object' || !('id' in frame) || typeof frame.id !== 'string') {
throw new Error('Expected the written request ID')
}
mockSockets[1]!.receive(
`encrypted:${JSON.stringify({ id: frame.id, ok: true, result: { sent: true } })}`
)
await expect(request).resolves.toMatchObject({ ok: true, result: { sent: true } })
} finally {
client.close()
await request.catch(() => undefined)
@@ -70,11 +70,20 @@ class MockWebSocket {
const mockSockets: MockWebSocket[] = []
const originalWebSocket = globalThis.WebSocket
function readRequest(payload: string): { id: string; method: string } | null {
const value: unknown = JSON.parse(payload.replace(/^encrypted:/, ''))
return value &&
typeof value === 'object' &&
'id' in value &&
typeof value.id === 'string' &&
'method' in value &&
typeof value.method === 'string'
? { id: value.id, method: value.method }
: null
}
function hasSentRequest(socket: MockWebSocket, method: string): boolean {
return socket.sent.some(
(payload) =>
(JSON.parse(payload.replace(/^encrypted:/, '')) as { method: string }).method === method
)
return socket.sent.some((payload) => readRequest(payload)?.method === method)
}
function connectAuthenticated(): { client: ReturnType<typeof connect>; socket: MockWebSocket } {
@@ -91,6 +100,7 @@ describe('mobile rpc-client delivery ambiguity marking', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSockets.length = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client.
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
@@ -206,12 +216,11 @@ describe('mobile rpc-client delivery ambiguity marking', () => {
)
const authProbe = client.sendRequest('status.get')
await Promise.resolve()
const probe = socket.sent
.map(
(payload) =>
JSON.parse(payload.replace(/^encrypted:/, '')) as { id: string; method: string }
)
.find((request) => request.method === 'status.get')!
expect(hasSentRequest(socket, 'terminal.send')).toBe(true)
const probe = socket.sent.map(readRequest).find((request) => request?.method === 'status.get')
if (!probe) {
throw new Error('Expected the written status probe')
}
socket.receive(
`encrypted:${JSON.stringify({ id: probe.id, ok: false, error: { code: 'unauthorized', message: 'Unauthorized' } })}`
@@ -72,6 +72,7 @@ async function nextSocket(): Promise<MockWebSocket> {
for (let elapsed = 0; sockets.length === count && elapsed < 60_000; elapsed += 100) {
await vi.advanceTimersByTimeAsync(100)
}
expect(sockets).toHaveLength(count + 1)
return sockets.at(-1)!
}
@@ -81,7 +82,16 @@ function firstFrame(socket: MockWebSocket): unknown {
function requestIdOf(plaintext: string | null): string {
const value: unknown = JSON.parse(plaintext ?? 'null')
return value && typeof value === 'object' && 'id' in value ? String(value.id) : ''
expect(value).toEqual({
id: expect.any(String),
deviceToken: 'device-token',
method: 'status.get',
params: {}
})
if (!value || typeof value !== 'object' || !('id' in value) || typeof value.id !== 'string') {
throw new Error('Expected the sealed request ID')
}
return value.id
}
// Plays the current host's direct v2 handshake with real crypto.
@@ -119,6 +129,7 @@ describe('direct connections use E2EE v2 framing', () => {
sockets.length = 0
storage.clear()
resetE2EEV2HostLatchForTests()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client.
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
@@ -1,8 +1,7 @@
// Exercise real sockets, timers and NaCl against half-open mobile/Tailscale paths (#5049).
// Exercise legacy framing with real sockets, timers and NaCl against half-open mobile/Tailscale paths (#5049).
// ORCA_MOBILE_LIVE_REPRO=1 enables the short case; ORCA_MOBILE_LIVE_REPRO_FULL=1 adds the roughly eight-minute recovery loop.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { randomBytes } from 'node:crypto'
import type { AddressInfo } from 'node:net'
import nacl from 'tweetnacl'
import { WebSocketServer, type WebSocket as ServerSocket } from 'ws'
import { connect, type RpcClient } from './rpc-client'
@@ -12,6 +11,8 @@ vi.mock('expo-crypto', () => ({
getRandomBytes: (n: number) => new Uint8Array(randomBytes(n))
}))
vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy'))
const RUN_LIVE =
process.env.ORCA_MOBILE_LIVE_REPRO === '1' || !!process.env.ORCA_MOBILE_LIVE_REPRO_FULL
const RUN_FULL = process.env.ORCA_MOBILE_LIVE_REPRO_FULL === '1'
@@ -58,7 +59,16 @@ function startServer(port = 0): Promise<WebSocketServer> {
}
const msg = typeof data === 'string' ? data : data.toString('utf-8')
if (!sharedKey) {
const hello = JSON.parse(msg) as { publicKeyB64: string }
const hello: unknown = JSON.parse(msg)
if (
!hello ||
typeof hello !== 'object' ||
!('publicKeyB64' in hello) ||
typeof hello.publicKeyB64 !== 'string'
) {
ws.terminate()
return
}
const clientKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64'))
sharedKey = nacl.box.before(clientKey, serverKeyPair.secretKey)
ws.send(JSON.stringify({ type: 'e2ee_ready' }))
@@ -68,14 +78,25 @@ function startServer(port = 0): Promise<WebSocketServer> {
if (!plaintext) {
return
}
const request = JSON.parse(plaintext) as { id?: string; type?: string; deviceToken?: string }
const request: unknown = JSON.parse(plaintext)
if (!request || typeof request !== 'object') {
return
}
if (!authenticated) {
if (request.type === 'e2ee_auth' && request.deviceToken === AUTH_TOKEN) {
if (
'type' in request &&
request.type === 'e2ee_auth' &&
'deviceToken' in request &&
request.deviceToken === AUTH_TOKEN
) {
authenticated = true
ws.send(e2eeEncrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey))
}
return
}
if (!('id' in request) || typeof request.id !== 'string') {
return
}
ws.send(
e2eeEncrypt(JSON.stringify({ id: request.id, ok: true, result: { up: true } }), sharedKey)
)
@@ -85,7 +106,11 @@ function startServer(port = 0): Promise<WebSocketServer> {
}
function serverPort(wss: WebSocketServer): number {
return (wss.address() as AddressInfo).port
const address = wss.address()
if (!address || typeof address === 'string') {
throw new Error('Expected an assigned loopback port')
}
return address.port
}
function stopServer(wss: WebSocketServer): Promise<void> {
@@ -155,7 +180,7 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => {
)
it.runIf(RUN_FULL)(
'repro: parked retry loop stays stuck until the foreground nudge',
'foreground nudge accelerates recovery while the trickle retry is pending',
{ timeout: 600_000 },
async () => {
wss = await startServer()
@@ -172,7 +197,7 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => {
expect(c.getState()).toBe('reconnecting')
wss = await startServer(port)
// Keep the server healthy long enough to distinguish automatic recovery from a loop waiting for foreground.
// The next 90-second trickle retry is still pending during this observation.
await sleep(70_000)
expect(c.getState()).not.toBe('connected')
@@ -43,6 +43,7 @@ afterEach(() => {
describe('mobile rpc-client connection logs', () => {
it('never exposes endpoint query credentials', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client.
globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket
const logs: ConnectionLogEntry[] = []
const endpoint = 'wss://desktop.example:7443/runtime?token=super-secret&route=private'
@@ -60,6 +61,7 @@ describe('mobile rpc-client connection logs', () => {
})
it('does not expose URL credentials in malformed legacy endpoints', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client.
globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket
const logs: ConnectionLogEntry[] = []
@@ -68,7 +68,23 @@ const originalWebSocket = globalThis.WebSocket
function sentRequests(socket: RuntimeEventTestSocket, method: string): SentRequest[] {
return socket.sent
.map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRequest)
.flatMap((payload) => {
const value: unknown = JSON.parse(payload.replace(/^encrypted:/, ''))
return value &&
typeof value === 'object' &&
'id' in value &&
typeof value.id === 'string' &&
'method' in value &&
typeof value.method === 'string'
? [
{
id: value.id,
method: value.method,
params: 'params' in value ? value.params : undefined
}
]
: []
})
.filter((request) => request.method === method)
}
@@ -101,6 +117,7 @@ describe('runtime client-event stream disposal', () => {
beforeEach(() => {
vi.useFakeTimers()
sockets.length = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the socket constructor, states, handlers, send and close consumed by the client.
globalThis.WebSocket = RuntimeEventTestSocket as unknown as typeof WebSocket
})
@@ -23,7 +23,7 @@ function createRegistry(initialState: ConnectionState = 'connected') {
if (state !== 'connected') {
return false
}
sent.push(request as SentRequest)
sent.push(request)
return true
}
})
@@ -72,6 +72,9 @@ describe('RpcClientStreamRegistry', () => {
registry.markForReplay()
registry.replayAfterAuthentication()
registry.handleBinary(terminalOutput(7, 'stale'))
registry.handleResponse(streamingResponse(first.id, { type: 'subscribed', streamId: 8 }))
registry.handleBinary(terminalOutput(8, 'after'))
registry.handleBinary(terminalOutput(7, 'still-stale'))
expect(sent[1]).toMatchObject({
id: first.id,
@@ -80,7 +83,9 @@ describe('RpcClientStreamRegistry', () => {
})
expect(events).toEqual([
{ type: 'subscribed', streamId: 7 },
{ type: 'data', streamId: 7, chunk: 'before' }
{ type: 'data', streamId: 7, chunk: 'before' },
{ type: 'subscribed', streamId: 8 },
{ type: 'data', streamId: 8, chunk: 'after' }
])
})
@@ -383,8 +388,11 @@ describe('RpcClientStreamRegistry', () => {
it('ends one transcript stream on dispose and leaves a sibling on the same socket (U-03)', () => {
const { registry, sent } = createRegistry()
const disposeFirst = registry.subscribe('agentSession.subscribe', { sessionId: 's1' }, () => {})
registry.subscribe('agentSession.subscribe', { sessionId: 's2' }, () => {})
const [first] = sent
const siblingEvents: unknown[] = []
registry.subscribe('agentSession.subscribe', { sessionId: 's2' }, (event) =>
siblingEvents.push(event)
)
const [first, sibling] = sent
disposeFirst()
expect(sent.at(-1)).toMatchObject({
@@ -392,6 +400,9 @@ describe('RpcClientStreamRegistry', () => {
params: { sessionId: 's1', subscriptionId: first!.id }
})
expect(sent.filter((request) => request.method === 'agentSession.unsubscribe')).toHaveLength(1)
registry.handleResponse(streamingResponse(sibling!.id, { type: 'chunk', text: 'still active' }))
expect(siblingEvents).toEqual([{ type: 'chunk', text: 'still active' }])
expect(registry.size()).toBe(1)
})
it('names the terminal request it sent when unsubscribing, and keeps the slot for older hosts', () => {
@@ -80,7 +80,7 @@ function lastSocket(): WedgedWebSocket {
function openConnectionLogs(spy: MockInstance<typeof console.log>): Record<string, unknown>[] {
return spy.mock.calls
.filter((call) => call[0] === '[net] openConnection')
.map((call) => call[1] as Record<string, unknown>)
.map((call) => logFields(call[1]) ?? {})
}
describe('synthesized close diagnostics', () => {
@@ -90,6 +90,7 @@ describe('synthesized close diagnostics', () => {
vi.useFakeTimers()
recordSocketDeadlineAppState('active')
sockets.length = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client.
globalThis.WebSocket = WedgedWebSocket as unknown as typeof WebSocket
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
})
@@ -245,18 +246,18 @@ describe('synthesized close diagnostics', () => {
// Deliver the old close during the replacement socket’s lifetime.
await vi.advanceTimersByTimeAsync(200)
wedged.deliverClose(1006)
const staleCloseLog = logSpy.mock.calls.findLast(
(call) => call[0] === '[net] ws.onclose'
)?.[1] as Record<string, unknown> | undefined
const staleCloseLog = logFields(
logSpy.mock.calls.findLast((call) => call[0] === '[net] ws.onclose')?.[1]
)
expect(staleCloseLog?.constructToCloseMs).toBe(5_700)
expect(staleCloseLog?.aliveMs).toBeNull()
expect(staleCloseLog?.inboundIdleMs).toBe(5_600)
await vi.advanceTimersByTimeAsync(300)
lastSocket().deliverClose(1006)
const closeLog = logSpy.mock.calls.findLast((call) => call[0] === '[net] ws.onclose')?.[1] as
| Record<string, unknown>
| undefined
const closeLog = logFields(
logSpy.mock.calls.findLast((call) => call[0] === '[net] ws.onclose')?.[1]
)
expect(closeLog?.constructToCloseMs).toBe(500)
expect(closeLog?.aliveMs).toBe(500)
expect(closeLog?.inboundIdleMs).toBe(500)
@@ -334,3 +335,9 @@ describe('synthesized close diagnostics', () => {
client.close()
})
})
function logFields(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object'
? Object.fromEntries(Object.entries(value))
: undefined
}
@@ -70,25 +70,26 @@ function authenticate(socket: MockWebSocket): void {
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
}
function sentRequest(socket: MockWebSocket, method: string): { id: string } {
for (const payload of socket.sent) {
const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as {
id: string
method: string
}
if (decoded.method === method) {
return { id: decoded.id }
}
}
throw new Error(`Request not sent: ${method}`)
function sentRequests(socket: MockWebSocket, method: string): { id: string }[] {
return socket.sent.flatMap((payload) => {
const value: unknown = JSON.parse(payload.replace(/^encrypted:/, ''))
return value &&
typeof value === 'object' &&
'method' in value &&
value.method === method &&
'id' in value &&
typeof value.id === 'string'
? [{ id: value.id }]
: []
})
}
function sentRequests(socket: MockWebSocket, method: string): Array<{ id: string }> {
return socket.sent
.map(
(payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as { id: string; method: string }
)
.filter((request) => request.method === method)
function sentRequest(socket: MockWebSocket, method: string): { id: string } {
const request = sentRequests(socket, method)[0]
if (!request) {
throw new Error(`Request not sent: ${method}`)
}
return request
}
function encryptedStreamingReady(id: string, streamId: number): string {
@@ -121,6 +122,7 @@ describe('rpc-client terminal reconnect streams', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSockets.length = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the socket constructor, states, handlers, send and close consumed by the client.
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
@@ -148,9 +150,13 @@ describe('rpc-client terminal reconnect streams', () => {
await vi.advanceTimersByTimeAsync(500)
const second = mockSockets.at(-1)!
expect(mockSockets).toHaveLength(2)
expect(second).not.toBe(first)
authenticate(second)
expect(client.getState()).toBe('connected')
expect(sentRequests(second, 'terminal.subscribe')).toHaveLength(1)
const resumedSubscribe = sentRequest(second, 'terminal.subscribe')
expect(resumedSubscribe.id).toBe(initialSubscribe.id)
second.receive(encryptedStreamingReady(resumedSubscribe.id, 77))
second.receive(encodeTerminalOutput(77, 'after-reconnect'))
second.receive(encodeTerminalOutput(76, 'stale-before-reconnect'))
@@ -80,6 +80,7 @@ describe('unauthorized close-code mapping (silent 4001)', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSockets.length = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client.
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
@@ -96,6 +97,7 @@ describe('unauthorized close-code mapping (silent 4001)', () => {
if (i > 0) {
await vi.advanceTimersByTimeAsync(1_000)
}
expect(mockSockets).toHaveLength(i + 1)
const socket = lastSocket()
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
@@ -122,6 +124,8 @@ describe('unauthorized close-code mapping (silent 4001)', () => {
await vi.advanceTimersByTimeAsync(500)
const next = lastSocket()
expect(next).not.toBe(first)
expect(mockSockets).toHaveLength(2)
next.open()
next.receive(JSON.stringify({ type: 'e2ee_ready' }))
next.receive('encrypted:{"type":"e2ee_authenticated"}')
@@ -138,6 +142,7 @@ describe('unauthorized close-code mapping (silent 4001)', () => {
if (i > 0) {
await vi.advanceTimersByTimeAsync(1_000)
}
expect(mockSockets).toHaveLength(i + 1)
const socket = lastSocket()
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
@@ -157,8 +162,10 @@ describe('unauthorized close-code mapping (silent 4001)', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
for (let i = 0; i < 5; i++) {
const previousCount = mockSockets.length
if (i > 0) {
await vi.advanceTimersByTimeAsync(60_000)
expect(mockSockets.length).toBeGreaterThan(previousCount)
}
const socket = lastSocket()
socket.open()
@@ -73,6 +73,7 @@ const originalWebSocket = globalThis.WebSocket
beforeEach(() => {
vi.useFakeTimers()
sockets.length = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the socket constructor, states, handlers, send and close consumed by the client.
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
@@ -126,7 +127,7 @@ describe('physical session liveness', () => {
})
})
await Promise.resolve()
await vi.advanceTimersByTimeAsync(8_000)
await vi.advanceTimersByTimeAsync(24_000)
expect(socket.close).not.toHaveBeenCalled()
expect(client.getState()).toBe('connected')
@@ -179,11 +180,27 @@ describe('physical session liveness', () => {
client.notifyForeground()
const request = socket.sent
.map((payload) => payload.replace(/^encrypted:/, ''))
.map((payload) => JSON.parse(payload) as { id?: string; method?: string })
.findLast((payload) => payload.method === 'status.get')
.flatMap((payload) => {
const value: unknown = JSON.parse(payload)
return value &&
typeof value === 'object' &&
'method' in value &&
value.method === 'status.get' &&
'id' in value &&
typeof value.id === 'string'
? [{ id: value.id }]
: []
})
.at(-1)
expect(socket.sent.filter((payload) => payload.includes('status.get'))).toHaveLength(
index + 1
)
if (!request) {
throw new Error('Expected a written liveness probe')
}
socket.onmessage?.({
data: `encrypted:${JSON.stringify({
id: request?.id,
id: request.id,
ok: false,
error: { code: 'unauthorized', message: 'Unauthorized' }
})}`
@@ -114,41 +114,54 @@ describe('RpcSessionLivenessWatchdog', () => {
let now = 0
let callback: (() => void) | undefined
const terminate = vi.fn()
const sendProbe = vi.fn(() => true)
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: null,
probeTimeoutMs: 4_000,
missedProbeLimit: 2,
sendProbe: () => true,
sendProbe,
terminate,
now: () => now,
setTimer: (next) => {
callback = next
return 1 as unknown as ReturnType<typeof setTimeout>
return setTimeout(() => {}, 0)
},
clearTimer: () => {}
})
const identity = {}
watchdog.start(identity)
watchdog.probeNow(identity)
expect(sendProbe).toHaveBeenCalledOnce()
if (!callback) {
throw new Error('Expected a scheduled probe deadline')
}
now = 8_000
callback?.()
callback()
expect(sendProbe).toHaveBeenCalledTimes(2)
now += 4_000
callback?.()
callback()
expect(sendProbe).toHaveBeenCalledTimes(3)
expect(terminate).not.toHaveBeenCalled()
now += 4_000
callback()
expect(terminate).toHaveBeenCalledOnce()
expect(terminate).toHaveBeenCalledWith(identity)
})
it('invalidates late callbacks after identity replacement', () => {
const callbacks: (() => void)[] = []
const terminate = vi.fn()
const sendProbe = vi.fn(() => true)
let now = 0
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'direct',
sendProbe: () => true,
sendProbe,
terminate,
now: () => 0,
now: () => now,
setTimer: (callback) => {
callbacks.push(callback)
return callbacks.length as unknown as ReturnType<typeof setTimeout>
return setTimeout(() => {}, 0)
},
clearTimer: () => {}
})
@@ -156,8 +169,14 @@ describe('RpcSessionLivenessWatchdog', () => {
const replacement = {}
watchdog.start(first)
watchdog.probeNow(first)
expect(callbacks).toHaveLength(2)
const staleCallbacks = [...callbacks]
watchdog.start(replacement)
callbacks.forEach((callback) => callback())
expect(callbacks).toHaveLength(3)
now = 20_000
staleCallbacks.forEach((callback) => callback())
expect(sendProbe).toHaveBeenCalledOnce()
expect(callbacks).toHaveLength(3)
expect(terminate).not.toHaveBeenCalled()
})
@@ -151,6 +151,17 @@ describe('buildHeadlessTerminalSplitLayout (headless split persistence)', () =>
{ leafId: 'leaf-c', ptyId: 'pty-c', splitFromLeafId: 'leaf-a', direction: 'vertical' }
)
expect(next.root).toEqual({
type: 'split',
direction: 'horizontal',
first: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-c' }
},
second: { type: 'leaf', leafId: 'leaf-b' }
})
expect(countTerminalLayoutLeaves(next.root)).toBe(3)
expect(next.ptyIdsByLeafId).toEqual({
'leaf-a': 'pty-a',
@@ -158,18 +169,4 @@ describe('buildHeadlessTerminalSplitLayout (headless split persistence)', () =>
'leaf-c': 'pty-c'
})
})
it('does not collapse — the persisted layout keeps both leaves (regression guard)', () => {
// Rebuild from the persisted split must retain multiple leaves.
const afterSplit = buildHeadlessTerminalSplitLayout(
{
root: { type: 'leaf', leafId: 'leaf-a' },
activeLeafId: 'leaf-a',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-a': 'pty-a' }
},
{ leafId: 'leaf-b', ptyId: 'pty-b', splitFromLeafId: 'leaf-a', direction: 'vertical' }
)
expect(countTerminalLayoutLeaves(afterSplit.root)).toBeGreaterThan(1)
})
})