diff --git a/images/chromium-headful/client/src/neko/base.ts b/images/chromium-headful/client/src/neko/base.ts index 37b16dea..0b81c810 100644 --- a/images/chromium-headful/client/src/neko/base.ts +++ b/images/chromium-headful/client/src/neko/base.ts @@ -17,9 +17,18 @@ import { // only "timeout", so each stage fails on its own bound with its own reason. A // bound expiring is terminal rather than retried: a second attempt against the // same peer costs the viewer time an embedder can spend on a new session. +// +// The bounds are sequential, so they are also a budget: an embedder that gives +// up on a viewer before the last one expires never sees the reason. The defaults +// below are an interim ceiling on that budget rather than a value tuned per +// region; the live-view proxy can supply its own, in milliseconds, through +// window.KERNEL_LIVE_VIEW_CONNECT_TIMEOUTS_MS. export type ConnectStage = 'transport' | 'signaling' | 'media' -export const CONNECT_STAGE_TIMEOUT_MS: Record = { +// The shipped defaults. A region with measured timings for its own network can +// lower these without a browser image release, which is what the injected +// override above is for. +const DEFAULT_CONNECT_STAGE_TIMEOUT_MS: Record = { // Network-bound (socket open + TLS), and the stage the live-view proxy's own // wake path shows up in — it can spend ~12s waking a browser before the socket // opens. Left at the watchdog it replaced rather than the measured p99, so a @@ -28,17 +37,47 @@ export const CONNECT_STAGE_TIMEOUT_MS: Record = { // One server round trip on an already-open socket, plus the offer. Measured // p99 under 200ms, including a cold session. signaling: 3000, - // Local: ICE reaches `checking` as soon as the local description is set, since - // the remote candidates arrive in the offer — measured 1-16ms direct, ~90ms - // relay-only. The bound covers through ICE `connected` rather than stopping at - // `checking`, and the timings above only recorded `checking`: 44 of 130 runs - // have no `connected` timestamp, all 30 ap-southeast runs among them. Nothing - // measured the stretch the bound now waits on, so 30s is an interim ceiling for - // it rather than a calibrated one, to be replaced once per-region timings - // through a successful connect exist. + // Covers the connect through ICE `connected`, not just `checking`: the remote + // candidates arrive in the offer, so `checking` lands in 1-16ms direct and + // ~90ms relay-only. The stretch through `connected` is not measured in every + // region — 44 of 130 probe runs stopped at `checking`, all 30 ap-southeast runs + // among them — so this stays at the interim ceiling rather than a shorter value + // the measured paths would justify. A region that has the timings can lower it. media: 30000, } +declare global { + interface Window { + // Injected by the live-view proxy with the page, in milliseconds per stage. + KERNEL_LIVE_VIEW_CONNECT_TIMEOUTS_MS?: unknown + } +} + +// The live-view proxy serves the bounds with the page rather than compiling them +// into the bundle, so a region can retune a stage without a browser image +// release. A stage the override does not name, or names with a value that is not +// a positive number of milliseconds, keeps its default. +export function resolveConnectStageTimeoutMs(overrides?: unknown): Record { + const bounds = { ...DEFAULT_CONNECT_STAGE_TIMEOUT_MS } + + if (typeof overrides !== 'object' || overrides === null) { + return bounds + } + + for (const stage of Object.keys(bounds) as ConnectStage[]) { + const value = (overrides as Record)[stage] + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + bounds[stage] = value + } + } + + return bounds +} + +export const CONNECT_STAGE_TIMEOUT_MS = resolveConnectStageTimeoutMs( + typeof window === 'undefined' ? undefined : window.KERNEL_LIVE_VIEW_CONNECT_TIMEOUTS_MS, +) + // Sent to the parent frame so an embedder can pick a recovery without parsing // prose. The three stages mean a bound expired; the rest never reached one. export type ConnectFailure = ConnectStage | 'unsupported' | 'peer' | 'server' diff --git a/images/chromium-headful/client/tests/connect-bound.test.ts b/images/chromium-headful/client/tests/connect-bound.test.ts index 6152b8e5..6f294542 100644 --- a/images/chromium-headful/client/tests/connect-bound.test.ts +++ b/images/chromium-headful/client/tests/connect-bound.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { BaseClient, CONNECT_STAGE_TIMEOUT_MS } from '../src/neko/base' +import { BaseClient, CONNECT_STAGE_TIMEOUT_MS, resolveConnectStageTimeoutMs } from '../src/neko/base' const posted: Record[] = [] const timers: { id: number; delay: number; run: () => void }[] = [] @@ -420,4 +420,37 @@ describe('live view connect failures', () => { expect(posted).toHaveLength(1) }) + + // The stages run in sequence, so their bounds are a budget: an embedder that + // gives up before the last one expires never sees the reason for the stage that + // stalled. These are the values the live-view docs quote, and the proxy can + // override any of them per region, so a change here has to move the docs too. + test('the shipped defaults are the bounds the live-view docs quote', () => { + expect(CONNECT_STAGE_TIMEOUT_MS).toEqual({ transport: 15000, signaling: 3000, media: 30000 }) + }) + + // The live-view proxy injects window.KERNEL_LIVE_VIEW_CONNECT_TIMEOUTS_MS + // with the page, so a region can retune a stage without a browser image + // release. + describe('injected connect bounds', () => { + test('a stage the override names is used, and unnamed stages keep their default', () => { + expect(resolveConnectStageTimeoutMs({ media: 9000 })).toEqual({ ...CONNECT_STAGE_TIMEOUT_MS, media: 9000 }) + }) + + test('values that are not a positive number of milliseconds are ignored', () => { + const overrides = { transport: 0, signaling: -1, media: '5000' } + + expect(resolveConnectStageTimeoutMs(overrides)).toEqual(CONNECT_STAGE_TIMEOUT_MS) + }) + + test('a stage that is not a bound is ignored', () => { + expect(resolveConnectStageTimeoutMs({ connect: 9000 })).toEqual(CONNECT_STAGE_TIMEOUT_MS) + }) + + test('no injected override leaves every stage at its default', () => { + expect(resolveConnectStageTimeoutMs()).toEqual(CONNECT_STAGE_TIMEOUT_MS) + expect(resolveConnectStageTimeoutMs(null)).toEqual(CONNECT_STAGE_TIMEOUT_MS) + expect(resolveConnectStageTimeoutMs('30000')).toEqual(CONNECT_STAGE_TIMEOUT_MS) + }) + }) })