From 655c383061ae77aff4d0f2f010a535ceeeed3ffb Mon Sep 17 00:00:00 2001 From: Kartios Date: Tue, 11 Aug 2026 22:02:29 -0700 Subject: [PATCH] test: enforce simulation and render budgets --- BUILD_PLAN.md | 15 +- package.json | 3 +- scripts/performance-budget.mjs | 291 ++++++++++++++++++++++++++++ scripts/performance-budgets.json | 13 ++ server/src/test/ice.test.ts | 6 +- server/src/test/serviceSoak.test.ts | 262 +++++++++++++++++++++++++ src/media/iceValidation.ts | 7 +- src/test/iceConfig.test.ts | 4 + src/test/longDurationSoak.test.ts | 227 ++++++++++++++++++++++ 9 files changed, 822 insertions(+), 6 deletions(-) create mode 100755 scripts/performance-budget.mjs create mode 100644 scripts/performance-budgets.json create mode 100644 server/src/test/serviceSoak.test.ts create mode 100644 src/test/longDurationSoak.test.ts diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index daa4639..b443df6 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -47,8 +47,8 @@ art; every shipped dependency, route, font, and asset has recorded provenance. ## M1 — California roads and passive Model X traffic -Status: **implemented and rendered on desktop and mobile; performance-budget -instrumentation remains**. +Status: **implemented and rendered on desktop and mobile; repeatable California +and office browser performance-budget instrumentation is implemented**. - Coarse California board plus detailed Bay Area and SoCal boards. - Serializable US-101 and I-5/I-580/I-80 route graphs. @@ -137,6 +137,17 @@ validation, and no regression to ambient live ADS-B rendering. ## Performance gates +Run `npm run build && npm run performance`. The harness serves the production +bundle locally, uses system Chrome (real GPU when available, SwiftShader as the +headless fallback), warms each scene, and writes `/tmp/tera-performance-budget.json`. +Budgets live in `scripts/performance-budgets.json`; a threshold, console error, +insufficient sample, or anonymous private endpoint request makes the command +non-zero. Use `--output`, `--budgets`, `--warmup-ms`, `--sample-ms`, or +`--software` for explicit CI/diagnostic runs. The named matrix is California and +Office at 1440×900 desktop and 390×844 mobile viewports. Frame comparisons allow +only 0.1 ms for Chrome's rAF timestamp quantization; the declared and reported +budgets are not raised. + - Named benchmark scenes: p95 frame at or below 16.7 ms desktop and 33.3 ms on the selected supported mobile tier. - Hard budgets per scale for resident cells, triangles, draw calls, dynamic diff --git a/package.json b/package.json index ccd96f9..73fbef1 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "sbom": "npm sbom --package-lock-only --sbom-format=spdx", "test": "node --test \"src/test/*.test.ts\"", "typecheck": "tsc --noEmit", - "preview": "vite preview" + "preview": "vite preview", + "performance": "node scripts/performance-budget.mjs" }, "dependencies": { "satellite.js": "^7.1.0", diff --git a/scripts/performance-budget.mjs b/scripts/performance-budget.mjs new file mode 100755 index 0000000..be66b07 --- /dev/null +++ b/scripts/performance-budget.mjs @@ -0,0 +1,291 @@ +#!/usr/bin/env node +/** Repeatable anonymous WebGL performance gate over a built Tera bundle. */ + +import { chromium } from "playwright"; +import { createServer } from "node:http"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const DIST = join(ROOT, "dist"); +const DEFAULT_BUDGETS = join(ROOT, "scripts", "performance-budgets.json"); +const PRIVATE_PATH = /^\/api\/v1\/(?:media|realtime|presence|offices)(?:\/|$)|^\/assets\/(?:remoteMedia|presenceIndicator|scenePeers)-/; +const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".webmanifest": "application/manifest+json" }; +const VIEWPORTS = { + desktop: { width: 1440, height: 900, deviceScaleFactor: 1 }, + mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, +}; +const SCENES = { + california: { host: "tera.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0 }, + office: { host: "office.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true }, +}; + +const args = process.argv.slice(2); +function option(name, fallback) { + const at = args.indexOf(`--${name}`); + return at < 0 ? fallback : args[at + 1]; +} +function positive(name, fallback) { + const value = Number(option(name, fallback)); + if (!Number.isFinite(value) || value <= 0) throw new Error(`--${name} must be a positive number`); + return value; +} +function sceneBudget(value, label) { + if (!value || typeof value !== "object") throw new Error(`missing budget for ${label}`); + for (const key of ["p95FrameIntervalMs", "maxDrawCalls", "maxTriangles"]) { + if (typeof value[key] !== "number" || !Number.isFinite(value[key]) || value[key] <= 0) { + throw new Error(`${label}.${key} must be a positive number`); + } + } + return value; +} +const outputPath = resolve(option("output", "/tmp/tera-performance-budget.json")); +const budgetPath = resolve(option("budgets", DEFAULT_BUDGETS)); +const warmupMs = positive("warmup-ms", 3_000); +const sampleMs = positive("sample-ms", 8_000); +const readyTimeoutMs = positive("ready-timeout-ms", 180_000); +const softwareOnly = args.includes("--software"); +// Chrome exposes rAF timestamps at 0.1 ms precision, so an ideal 60 Hz cadence +// can quantize to 16.8 ms. This tolerance is measurement resolution, not budget +// headroom; reported values and declared budgets remain unchanged. +const FRAME_COMPARISON_EPSILON_MS = 0.1; + +function percentile(values, fraction) { + if (values.length === 0) return null; + const ordered = [...values].sort((a, b) => a - b); + return ordered[Math.max(0, Math.ceil(ordered.length * fraction) - 1)]; +} +const rounded = (value) => value === null ? null : Math.round(value * 1_000) / 1_000; + +async function serve() { + const requests = []; + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://local.invalid"); + requests.push({ method: req.method ?? "GET", path: url.pathname }); + if (url.pathname === "/api/v1/health") { + res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + res.end(JSON.stringify({ auth: { mode: "jwt", entryUrl: "/login.html" }, sources: { weather: "none", flights: "none", satellites: "none", markers: "none" } })); + return; + } + if (url.pathname === "/api/v1/session") { + res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + res.end(JSON.stringify({ authenticated: false, subject: null, passwordLogin: false, admin: false })); + return; + } + if (url.pathname.startsWith("/api/v1/")) { + res.writeHead(404, { "content-type": "application/json", "cache-control": "no-store" }); + res.end(JSON.stringify({ error: "not_found" })); + return; + } + const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, ""); + for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) { + const target = resolve(DIST, `.${relative}`); + if (!target.startsWith(`${resolve(DIST)}/`)) continue; + try { + const body = await readFile(target); + res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" }); + res.end(body); + return; + } catch { /* SPA fallback */ } + } + res.writeHead(404).end("not found"); + }); + await new Promise((ok, fail) => { server.once("error", fail); server.listen(0, "127.0.0.1", ok); }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("performance server did not expose a TCP port"); + return { server, requests, port: address.port }; +} + +const COMMON_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"]; +async function rendererOf(browser) { + const page = await browser.newPage(); + try { + await page.goto("about:blank"); + return await page.evaluate(() => { + const gl = document.createElement("canvas").getContext("webgl2"); + const extension = gl?.getExtension("WEBGL_debug_renderer_info"); + return extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null; + }); + } finally { await page.close(); } +} +async function launchBrowser(port) { + const resolver = `--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1, MAP office.lumbridgecorp.com 127.0.0.1`; + const attempts = softwareOnly + ? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]] + : [["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]], ["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]]; + let last; + for (const [backend, flags] of attempts) { + try { + const browser = await chromium.launch({ channel: "chrome", args: [...COMMON_ARGS, resolver, ...flags] }); + const renderer = await rendererOf(browser); + const softwareRenderer = renderer !== null && /SwiftShader|llvmpipe/i.test(renderer); + if (renderer && (backend === "swiftshader" || !softwareRenderer)) return { browser, backend, renderer, port }; + await browser.close(); + } catch (error) { last = error; } + } + for (const executablePath of ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]) { + try { + await access(executablePath); + const browser = await chromium.launch({ executablePath, args: [...COMMON_ARGS, resolver, "--use-gl=angle", "--use-angle=swiftshader"] }); + return { browser, backend: "system-chrome-swiftshader", renderer: await rendererOf(browser), port }; + } catch (error) { last = error; } + } + throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`); +} + +function instrumentation() { + // Custom production hostnames mapped to loopback are not secure contexts over + // HTTP, so Chrome withholds randomUUID even though getRandomValues remains. + // Production is HTTPS; this shim only restores that API in the local harness. + if (typeof crypto.randomUUID !== "function") { + crypto.randomUUID = () => { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + }; + } + const state = { frames: [], longTasks: [], drawCalls: [], triangles: [], currentCalls: 0, currentTriangles: 0, last: 0, longTaskSupported: false }; + Object.defineProperty(globalThis, "__teraPerformanceBudget", { value: state }); + const triangleCount = (mode, count) => mode === 4 ? count / 3 : (mode === 5 || mode === 6) ? Math.max(0, count - 2) : 0; + const patch = (prototype, method, countAt, instancesAt = null) => { + if (!prototype || typeof prototype[method] !== "function") return; + const original = prototype[method]; + if (original.__teraPerformancePatched) return; + const wrapped = function (...values) { + state.currentCalls += 1; + const instances = instancesAt === null ? 1 : Number(values[instancesAt]) || 0; + state.currentTriangles += triangleCount(Number(values[0]), Number(values[countAt]) || 0) * instances; + return original.apply(this, values); + }; + Object.defineProperty(wrapped, "__teraPerformancePatched", { value: true }); + prototype[method] = wrapped; + }; + for (const prototype of [globalThis.WebGLRenderingContext?.prototype, globalThis.WebGL2RenderingContext?.prototype]) { + patch(prototype, "drawArrays", 2); + patch(prototype, "drawElements", 1); + patch(prototype, "drawArraysInstanced", 2, 3); + patch(prototype, "drawElementsInstanced", 1, 4); + } + try { + new PerformanceObserver((list) => state.longTasks.push(...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })))).observe({ type: "longtask", buffered: true }); + state.longTaskSupported = true; + } catch { /* Long Tasks API is optional. */ } + requestAnimationFrame(function sample(now) { + if (state.last > 0) { + state.frames.push(now - state.last); + state.drawCalls.push(state.currentCalls); + state.triangles.push(state.currentTriangles); + } + state.last = now; + state.currentCalls = 0; + state.currentTriangles = 0; + requestAnimationFrame(sample); + }); +} + +async function measure(browser, port, sceneName, viewportName, budget, requestLog) { + const viewport = VIEWPORTS[viewportName]; + const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: viewport.deviceScaleFactor, isMobile: viewport.isMobile, hasTouch: viewport.hasTouch }); + const page = await context.newPage(); + const consoleErrors = []; + page.on("pageerror", (error) => consoleErrors.push(String(error))); + page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); + const before = requestLog.length; + await page.addInitScript(instrumentation); + const scene = SCENES[sceneName]; + const url = `http://${scene.host}:${port}/`; + try { + await page.goto(url, { waitUntil: "networkidle", timeout: readyTimeoutMs }); + try { + await page.waitForFunction(scene.ready, null, { timeout: readyTimeoutMs }); + } catch (error) { + const state = await page.evaluate(() => ({ + title: document.title, + bootHidden: document.getElementById("boot")?.hidden ?? null, + bootText: document.getElementById("boot")?.textContent?.trim().slice(0, 240) ?? null, + chapters: document.querySelectorAll("#chapters .chapter").length, + enter: document.getElementById("enter")?.textContent?.trim() ?? null, + })).catch(() => null); + throw new Error(`scene readiness timed out: ${JSON.stringify({ state, consoleErrors, requests: requestLog.slice(before) })}`, { cause: error }); + } + await page.waitForTimeout(warmupMs); + await page.evaluate(() => { + const state = globalThis.__teraPerformanceBudget; + state.frames.length = state.drawCalls.length = state.triangles.length = state.longTasks.length = 0; + state.last = performance.now(); state.currentCalls = 0; state.currentTriangles = 0; + }); + await page.waitForTimeout(sampleMs); + const raw = await page.evaluate(() => { + const state = globalThis.__teraPerformanceBudget; + const canvas = document.querySelector("canvas"); + const gl = canvas?.getContext("webgl2") ?? canvas?.getContext("webgl"); + const extension = gl?.getExtension("WEBGL_debug_renderer_info"); + return { + frames: [...state.frames], drawCalls: [...state.drawCalls], triangles: [...state.triangles], + longTasks: [...state.longTasks], longTaskSupported: state.longTaskSupported, + renderer: extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null, + }; + }); + const relevantRequests = requestLog.slice(before); + const privateRequests = relevantRequests.filter((request) => PRIVATE_PATH.test(request.path)); + const metrics = { + frameSamples: raw.frames.length, + p50FrameIntervalMs: rounded(percentile(raw.frames, 0.5)), + p95FrameIntervalMs: rounded(percentile(raw.frames, 0.95)), + p99FrameIntervalMs: rounded(percentile(raw.frames, 0.99)), + maxFrameIntervalMs: rounded(raw.frames.length ? Math.max(...raw.frames) : null), + p95DrawCalls: rounded(percentile(raw.drawCalls, 0.95)), + maxDrawCalls: raw.drawCalls.length ? Math.max(...raw.drawCalls) : null, + p95Triangles: rounded(percentile(raw.triangles, 0.95)), + maxTriangles: raw.triangles.length ? Math.max(...raw.triangles) : null, + longTasksSupported: raw.longTaskSupported, + longTaskCount: raw.longTasks.length, + longTaskTotalMs: raw.longTasks.reduce((sum, task) => sum + task.duration, 0), + }; + const checks = { + p95FrameIntervalMs: metrics.p95FrameIntervalMs !== null && + Math.round(metrics.p95FrameIntervalMs * 10) <= + Math.round((budget.p95FrameIntervalMs + FRAME_COMPARISON_EPSILON_MS) * 10), + maxDrawCalls: metrics.maxDrawCalls !== null && metrics.maxDrawCalls <= budget.maxDrawCalls, + maxTriangles: metrics.maxTriangles !== null && metrics.maxTriangles <= budget.maxTriangles, + anonymousPrivateRequests: privateRequests.length === 0, + consoleErrors: consoleErrors.length === 0, + enoughFrameSamples: metrics.frameSamples >= Math.max(30, Math.floor(sampleMs / 100)), + }; + return { scene: sceneName, viewport: viewportName, url, viewportPixels: viewport, renderer: raw.renderer, budget, metrics, checks, passed: Object.values(checks).every(Boolean), privateRequests, consoleErrors }; + } finally { await context.close(); } +} + +let server; +let browser; +try { + await access(join(DIST, "index.html")); + const budgets = JSON.parse(await readFile(budgetPath, "utf8")); + const hosted = await serve(); server = hosted.server; + const launched = await launchBrowser(hosted.port); browser = launched.browser; + const results = []; + for (const scene of Object.keys(SCENES)) for (const viewport of Object.keys(VIEWPORTS)) { + process.stderr.write(`performance-budget: ${scene}/${viewport}\n`); + const budget = sceneBudget(budgets?.scenes?.[scene]?.[viewport], `${scene}.${viewport}`); + results.push(await measure(browser, hosted.port, scene, viewport, budget, hosted.requests)); + } + const report = { + schemaVersion: 1, generatedAt: new Date().toISOString(), browserPlugin: "not available; Playwright system Chrome fallback used", + browser: { backend: launched.backend, renderer: launched.renderer }, warmupMs, sampleMs, + frameComparisonEpsilonMs: FRAME_COMPARISON_EPSILON_MS, + budgets: budgetPath, passed: results.every((result) => result.passed), results, + }; + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.stderr.write(`performance-budget: ${report.passed ? "PASS" : "FAIL"} — ${outputPath}\n`); + if (!report.passed) process.exitCode = 1; +} catch (error) { + console.error(`performance-budget: ERROR — ${error instanceof Error ? error.stack : String(error)}`); + process.exitCode = 2; +} finally { + await browser?.close().catch(() => undefined); + if (server) await new Promise((resolveClose) => server.close(resolveClose)); +} diff --git a/scripts/performance-budgets.json b/scripts/performance-budgets.json new file mode 100644 index 0000000..c01ab6e --- /dev/null +++ b/scripts/performance-budgets.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "scenes": { + "california": { + "desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 650, "maxTriangles": 750000 }, + "mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 650, "maxTriangles": 750000 } + }, + "office": { + "desktop": { "p95FrameIntervalMs": 16.7, "maxDrawCalls": 550, "maxTriangles": 550000 }, + "mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 550, "maxTriangles": 550000 } + } + } +} diff --git a/server/src/test/ice.test.ts b/server/src/test/ice.test.ts index 4d25e86..c75ea19 100644 --- a/server/src/test/ice.test.ts +++ b/server/src/test/ice.test.ts @@ -28,7 +28,8 @@ const PROVIDER_CREDENTIAL: ScreenShareCredential = { sessionId: "opaque-session", participantId: "opaque-participant", role: "presenter", grantToken: "opaque-grant-token-value", }; const iceRequest = (credential: ScreenShareCredential = PROVIDER_CREDENTIAL, binding: ScreenShareBinding = BINDING) => ({ - type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", binding, credential, + type: "ice-config-request", protocolVersion: 1, requestId: "ice-request-1", + binding: { ...binding }, credential: { ...credential }, } as const); const request = iceRequest(); const rateKeys = (subject = "auth-subject", trustedIp = "203.0.113.8") => ({ subject, trustedIp }); @@ -221,7 +222,8 @@ describe("authenticated ICE route", () => { const missing = { ...iceRequest(presenter) } as Record; delete missing.credential; assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter"), payload: missing })).statusCode, 400); - assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("thief"), payload: iceRequest(presenter) })).statusCode, 401); + const stolen = await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("thief"), payload: iceRequest(presenter) }); + assert.equal(stolen.statusCode, 401, stolen.body); assert.equal((await app.inject({ method: "POST", url: "/api/v1/media/ice", headers: bearer("presenter"), payload: iceRequest(presenter, SECOND_BINDING) })).statusCode, 401); const joined = await app.inject({ method: "POST", url: "/api/v1/media/join", headers: bearer("viewer"), payload: { diff --git a/server/src/test/serviceSoak.test.ts b/server/src/test/serviceSoak.test.ts new file mode 100644 index 0000000..5ccd4ec --- /dev/null +++ b/server/src/test/serviceSoak.test.ts @@ -0,0 +1,262 @@ +/** + * Fast deterministic lifecycle soak for the in-memory realtime/media services. + * It intentionally uses fake clocks and direct service APIs: no sockets, sleeps, + * filesystem writes, or environment-specific infrastructure. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { ActorPoseSnapshot, ResumeRequest } from "../../../src/realtime/types.ts"; +import type { + ScreenShareBinding, + ScreenShareCredential, + ScreenShareCreateRequest, + ScreenShareJoinRequest, +} from "../../../src/media/signalingTypes.ts"; +import type { IceConfigRequest } from "../../../src/media/iceTypes.ts"; +import type { IceConfig } from "../config.ts"; +import { createIceCredentialProvider, createMediaSignalService } from "../media/index.ts"; +import { createRealtimeService } from "../realtime/index.ts"; + +const MEDIA_SESSIONS = 32; +const VIEWERS_PER_SHARE = 3; +const REALTIME_SESSIONS = 128; + +describe("bounded in-memory service soak", () => { + it("rotates media grants, releases listeners/queues, and drains every session", () => { + let now = 1_000_000; + const service = createMediaSignalService({ + now: () => now, + maximumSessions: MEDIA_SESSIONS, + maximumParticipantsPerSession: VIEWERS_PER_SHARE + 1, + maximumQueuedMessagesPerParticipant: 8, + maximumSignalsPerWindow: 16, + grantTtlMs: 90_000, + }); + const credentials: Array<{ credential: ScreenShareCredential; subject: string }> = []; + const unsubscribedDeliveries: number[] = []; + const presenters: Array<{ + binding: ScreenShareBinding; + subject: string; + credential: ScreenShareCredential<"presenter">; + }> = []; + + for (let sessionIndex = 0; sessionIndex < MEDIA_SESSIONS; sessionIndex += 1) { + const binding = mediaBinding(sessionIndex); + const presenterSubject = `presenter-subject-${sessionIndex}`; + const created = service.join(mediaCreate(binding, sessionIndex), presenterSubject); + assert.ok(created.ok && created.value.type === "screen-share-create-grant"); + if (!created.ok || created.value.type !== "screen-share-create-grant") continue; + let presenterCredential = created.value.grant.credential; + const viewers: Array<{ subject: string; credential: ScreenShareCredential; sequence: number }> = []; + + for (let viewerIndex = 0; viewerIndex < VIEWERS_PER_SHARE; viewerIndex += 1) { + const subject = `viewer-subject-${sessionIndex}-${viewerIndex}`; + const joined = service.join(mediaJoin(binding, sessionIndex, viewerIndex), subject); + assert.ok(joined.ok && joined.value.type === "screen-share-join-grant"); + if (!joined.ok || joined.value.type !== "screen-share-join-grant") continue; + viewers.push({ subject, credential: joined.value.grant.credential, sequence: joined.value.sequence }); + } + assert.equal(viewers.length, VIEWERS_PER_SHARE); + + const presenterResume = service.resume({ + type: "screen-share-resume-request", protocolVersion: 1, + requestId: `presenter-resume-${sessionIndex}`, sequence: 2, timestampMs: now, + binding, credential: presenterCredential, lastReceivedSequence: created.value.sequence, + }, presenterSubject); + assert.equal(presenterResume.ok, true); + if (!presenterResume.ok) continue; + assert.notEqual(presenterResume.value.grant.credential.grantToken, presenterCredential.grantToken); + assert.equal(presenterResume.value.grant.credential.role, "presenter"); + if (presenterResume.value.grant.credential.role !== "presenter") continue; + presenterCredential = presenterResume.value.grant.credential as ScreenShareCredential<"presenter">; + credentials.push({ credential: presenterCredential, subject: presenterSubject }); + + for (const [viewerIndex, viewer] of viewers.entries()) { + const resumed = service.resume({ + type: "screen-share-resume-request", protocolVersion: 1, + requestId: `viewer-resume-${sessionIndex}-${viewerIndex}`, sequence: 2, timestampMs: now, + binding, credential: viewer.credential, lastReceivedSequence: viewer.sequence, + }, viewer.subject); + assert.equal(resumed.ok, true); + if (!resumed.ok) continue; + const rotated = resumed.value.grant.credential; + credentials.push({ credential: rotated, subject: viewer.subject }); + let deliveries = 0; + const subscription = service.subscribe(rotated, viewer.subject, () => { deliveries += 1; }); + assert.equal(subscription.ok, true); + if (!subscription.ok) continue; + subscription.value(); + const before = deliveries; + const offered = service.signal({ + type: "screen-share-signal-request", protocolVersion: 1, sequence: 3 + viewerIndex, timestampMs: now, + binding, credential: presenterCredential, targetParticipantId: rotated.participantId, + signal: { kind: "sdp", descriptionType: "offer", sdp: "v=0" }, + }, presenterSubject); + assert.equal(offered.ok, true); + unsubscribedDeliveries.push(deliveries - before); + } + + presenters.push({ binding, subject: presenterSubject, credential: presenterCredential }); + now += 1; + } + + assert.equal(service.sessionCount(), MEDIA_SESSIONS); + assert.equal(credentials.length, MEDIA_SESSIONS * (VIEWERS_PER_SHARE + 1)); + for (const [index, presenter] of presenters.entries()) { + const stopped = service.leave({ + type: "screen-share-stop-request", protocolVersion: 1, + requestId: `stop-${index}`, sequence: 3 + VIEWERS_PER_SHARE, + timestampMs: now, binding: presenter.binding, + credential: presenter.credential, reason: "presenter-stopped", + }, presenter.subject, false); + assert.equal(stopped.ok, true); + } + + assert.ok(unsubscribedDeliveries.every((count) => count === 0), "unsubscribed listeners receive no later offers"); + assert.equal(service.sessionCount(), 0); + assert.ok(credentials.every(({ credential, subject }) => + !service.revalidate(credential, subject).ok + ), "destroyed sessions retain no usable grant for its original owner"); + }); + + it("rotates realtime tokens, disconnects listeners, rate-limits, and expires all sessions", () => { + let now = 2_000_000; + const service = createRealtimeService({ + now: () => now, + maximumSessions: REALTIME_SESSIONS, + maximumSessionsPerCell: REALTIME_SESSIONS, + maximumPoseUpdatesPerWindow: 4, + rateWindowMs: 10_000, + disconnectGraceMs: 500, + sessionTtlMs: 30_000, + }); + const sessions: Array<{ sessionId: string; token: string; actorId: string; subject: string }> = []; + let disconnectedDeliveries = 0; + + for (let index = 0; index < REALTIME_SESSIONS; index += 1) { + const actorId = `soak-actor-${index}`; + const subject = `soak-subject-${index}`; + const joined = service.join({ + requestId: `soak-join-${index}`, actorId, subject, role: "member", + interests: [{ kind: "floor", officeId: "lumbridge-hq", floorId: "level-1" }], + }); + assert.equal(joined.ok, true); + if (!joined.ok) continue; + let token = joined.value.token; + const connected = service.connect(joined.value.sessionId, token, () => { disconnectedDeliveries += 1; }); + assert.equal(connected.ok, true); + if (connected.ok) connected.value.disconnect(); + + for (let rotation = 0; rotation < 3; rotation += 1) { + const request: ResumeRequest = { + type: "resume-request", protocolVersion: 1, + requestId: `soak-resume-${index}-${rotation}`, + sessionId: joined.value.sessionId, resumeToken: token, + serverEpoch: joined.value.grant.serverEpoch, lastReceivedSequence: 0, + interests: joined.value.grant.interests, + }; + const resumed = service.resume(request, subject); + assert.equal(resumed.ok, true); + if (!resumed.ok) break; + assert.notEqual(resumed.value.resumeToken, token); + token = resumed.value.resumeToken; + } + sessions.push({ sessionId: joined.value.sessionId, token, actorId, subject }); + } + + assert.equal(sessions.length, REALTIME_SESSIONS); + assert.equal(service.sessionCount(), REALTIME_SESSIONS); + const publisher = sessions[0] as (typeof sessions)[number]; + for (let sequence = 1; sequence <= 5; sequence += 1) { + now += 100; + const result = service.submitPose( + publisher.sessionId, + publisher.token, + actorPose(publisher.actorId, sequence, now, sequence / 100), + ); + assert.equal(result.ok, sequence <= 4); + if (sequence === 5 && !result.ok) assert.equal(result.code, "rate-limited"); + } + assert.equal(disconnectedDeliveries, 0, "disconnected realtime listeners receive no broadcast"); + + for (let index = 0; index < sessions.length / 2; index += 1) { + const session = sessions[index] as (typeof sessions)[number]; + assert.equal(service.leave(session.sessionId, session.token).ok, true); + } + assert.equal(service.sessionCount(), REALTIME_SESSIONS / 2); + now += 501; + assert.equal(service.cleanup(), REALTIME_SESSIONS / 2); + assert.equal(service.sessionCount(), 0); + assert.ok(sessions.every((session) => !service.authenticate(session.sessionId, session.token).ok)); + }); + + it("bounds ICE issuance across subject and address churn without retaining credentials", () => { + let now = 3_000_000; + const provider = createIceCredentialProvider(iceConfig(), { now: () => now }); + const credential: ScreenShareCredential = { + sessionId: "soak-media-session", participantId: "soak-participant", + role: "presenter", grantToken: "soak-private-grant-token", + }; + const binding = mediaBinding(999); + const request: IceConfigRequest = { + type: "ice-config-request", protocolVersion: 1, requestId: "soak-ice", + binding, credential, + }; + for (let attempt = 1; attempt <= 5; attempt += 1) { + const result = provider.issue(request, { + subject: "same-subject", + trustedIp: `203.0.113.${attempt}`, + }, now + 90_000); + assert.equal(result.ok, attempt <= 4, "subject limit survives address rotation"); + } + now += 10_000; + for (let attempt = 1; attempt <= 5; attempt += 1) { + const result = provider.issue(request, { + subject: `rotating-subject-${attempt}`, + trustedIp: "198.51.100.10", + }, now + 90_000); + assert.equal(result.ok, attempt <= 4, "address limit survives subject rotation"); + } + }); +}); + +function mediaBinding(index: number): ScreenShareBinding { + return { officeId: "soak-office", levelId: "level-1", roomId: "room", screenId: `screen-${index}` }; +} + +function mediaCreate(binding: ScreenShareBinding, index: number): ScreenShareCreateRequest { + return { + type: "screen-share-create-request", protocolVersion: 1, requestId: `create-${index}`, + sequence: 1, timestampMs: 1_000_000, binding, role: "presenter", + }; +} + +function mediaJoin(binding: ScreenShareBinding, session: number, viewer: number): ScreenShareJoinRequest { + return { + type: "screen-share-join-request", protocolVersion: 1, requestId: `join-${session}-${viewer}`, + sequence: 1, timestampMs: 1_000_000, binding, role: "viewer", viewerOptIn: true, + }; +} + +function actorPose(actorId: string, sequence: number, timestampMs: number, xM: number): ActorPoseSnapshot { + return { + entity: "actor", actorId, kind: "humanoid", sequence, timestampMs, + pose: { + space: "local", cell: { kind: "floor", officeId: "lumbridge-hq", floorId: "level-1" }, + xM, yM: 0, zM: 0, headingDeg: 0, pitchDeg: 0, + }, + velocity: { xMps: 0.1, yMps: 0, zMps: 0, yawDegPerSec: 0 }, + }; +} + +function iceConfig(): IceConfig { + return { + configured: true, + urls: ["turn:relay.example.test:3478"], + sharedSecret: "soak-turn-shared-secret-with-at-least-thirty-two-bytes", + credentialTtlSeconds: 60, + rateAttempts: 4, + rateWindowSeconds: 10, + }; +} diff --git a/src/media/iceValidation.ts b/src/media/iceValidation.ts index b53174b..57df154 100644 --- a/src/media/iceValidation.ts +++ b/src/media/iceValidation.ts @@ -50,12 +50,17 @@ function screenBinding(value: unknown): boolean { function screenCredential(value: unknown): boolean { return exact(value, ["sessionId", "participantId", "role", "grantToken"]) && - identifier(value.sessionId) && identifier(value.participantId) && + opaqueIdentifier(value.sessionId) && opaqueIdentifier(value.participantId) && (value.role === "presenter" || value.role === "viewer") && typeof value.grantToken === "string" && value.grantToken.length >= 16 && value.grantToken.length <= 512; } +/** Server credentials use unpadded base64url and may begin with `_` or `-`. */ +function opaqueIdentifier(value: unknown): value is string { + return typeof value === "string" && /^[a-zA-Z0-9_-]{1,256}$/.test(value); +} + export function parseIceConfigResponse(value: unknown): IceConfigValidationResult { if (!plain(value) || value.protocolVersion !== ICE_CONFIG_PROTOCOL_VERSION || !identifier(value.requestId)) { return failure("invalid response envelope"); diff --git a/src/test/iceConfig.test.ts b/src/test/iceConfig.test.ts index ee41ff9..6f62b9f 100644 --- a/src/test/iceConfig.test.ts +++ b/src/test/iceConfig.test.ts @@ -38,6 +38,10 @@ const grant = (): IceConfigGrant => ({ describe("ICE configuration wire contract", () => { it("accepts exact requests and bounded ephemeral grants", () => { assert.equal(parseIceConfigRequest(request()).ok, true); + assert.equal(parseIceConfigRequest({ + ...request(), + credential: { ...request().credential, sessionId: "_opaque-session", participantId: "-opaque-participant" }, + }).ok, true, "accepts every server-generated base64url prefix"); assert.equal(parseIceConfigResponse(JSON.parse(JSON.stringify(grant()))).ok, true); assert.equal(isIceConfigGrantActive(grant(), 300_000), true); assert.equal(isIceConfigGrantActive(grant(), 601_000), false); diff --git a/src/test/longDurationSoak.test.ts b/src/test/longDurationSoak.test.ts new file mode 100644 index 0000000..73605b7 --- /dev/null +++ b/src/test/longDurationSoak.test.ts @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { ActorController, type ActorControllerOptions } from "../actors/controller.ts"; +import { AircraftController, type AircraftWaypoint } from "../aircraft/controller.ts"; +import { Plan } from "../interiors/plan.ts"; +import type { Level, Office, Room, Wall } from "../interiors/types.ts"; +import { createWalker } from "../interiors/walker.ts"; +import CALIFORNIA_TRANSPORT from "../transport/california.ts"; +import { VehicleController } from "../transport/vehicleController.ts"; +import { buildRoutePath, VehicleSimulation } from "../transport/vehicleSim.ts"; + +const SOAK_TIMEOUT_MS = 5_000; +const HOUR_AT_TEN_HZ = 36_000; + +function finiteNumbers(value: unknown, path = "state"): void { + if (typeof value === "number") { + assert.ok(Number.isFinite(value), `${path} must remain finite`); + return; + } + if (typeof value !== "object" || value === null) return; + for (const [key, nested] of Object.entries(value)) finiteNumbers(nested, `${path}.${key}`); +} + +function inside(value: number, minimum: number, maximum: number, label: string): void { + assert.ok(value >= minimum && value <= maximum, `${label} ${value} outside [${minimum}, ${maximum}]`); +} + +function countForwardWrap(previous: number, next: number): number { + return previous > 0.9 && next < 0.1 ? 1 : 0; +} + +describe("accelerated long-duration deterministic simulation", () => { + it("runs both California routes and ambient traffic through repeated completions", { timeout: SOAK_TIMEOUT_MS }, () => { + for (const routeId of ["la-sf-us-101", "la-sf-i-5"] as const) { + const path = buildRoutePath(CALIFORNIA_TRANSPORT, routeId); + const first = new VehicleSimulation(CALIFORNIA_TRANSPORT, { routeId, count: 24, seed: 115, timeScale: 900 }); + const replay = new VehicleSimulation(CALIFORNIA_TRANSPORT, { routeId, count: 24, seed: 115, timeScale: 900 }); + let wraps = 0; + let prior = first.poses()[0]?.progress ?? 0; + + // 15 rendered minutes at the production 900x traffic scale is more than + // nine simulated days, advanced in bounded 20 Hz fixed steps. + for (let frame = 0; frame < 3_600; frame += 1) { + first.tick(0.25); + replay.tick(0.25); + const hero = first.poses()[0]; + assert.ok(hero); + wraps += countForwardWrap(prior, hero.progress); + prior = hero.progress; + if (frame % 60 !== 0) continue; + for (const pose of first.poses()) { + finiteNumbers(pose, `${routeId}.${pose.id}`); + inside(pose.progress, 0, 1, `${pose.id}.progress`); + inside(pose.distanceM, 0, path.lengthM, `${pose.id}.distanceM`); + inside(pose.lat, 32, 43, `${pose.id}.lat`); + inside(pose.lng, -125, -113, `${pose.id}.lng`); + } + } + assert.ok(wraps >= 10, `${routeId} hero completed only ${wraps} circuits`); + assert.deepEqual(first.poses(), replay.poses(), `${routeId} seeded traffic drifted on replay`); + } + }); + + it("drives the playable vehicle for an accelerated hour without drift or escape", { timeout: SOAK_TIMEOUT_MS }, () => { + const options = { + routeId: "la-sf-us-101", + fixedStepSeconds: 0.1, + initialSpeedMps: 20, + travelScale: 900, + guardrailOffsetM: 5, + } as const; + const first = new VehicleController(CALIFORNIA_TRANSPORT, options); + const replay = new VehicleController(CALIFORNIA_TRANSPORT, options); + const path = buildRoutePath(CALIFORNIA_TRANSPORT, options.routeId); + let prior = first.state().progress; + let completions = 0; + + for (let step = 0; step < HOUR_AT_TEN_HZ; step += 1) { + const actions = step % 1_200 < 60 + ? { throttle: 0.75, steering: Math.sin(step / 20) * 0.35 } + : step % 1_200 === 60 ? { modeRequest: "assisted" as const } : undefined; + first.stepFixed(actions); + replay.stepFixed(actions); + const state = first.state(); + completions += countForwardWrap(prior, state.progress); + prior = state.progress; + if (step % 300 !== 0) continue; + finiteNumbers(state); + inside(state.progress, 0, 1, "vehicle.progress"); + inside(state.distanceM, 0, path.lengthM, "vehicle.distanceM"); + inside(state.lateralOffsetM, -5, 5, "vehicle.lateralOffsetM"); + inside(state.speedMps, 0, 58, "vehicle.speedMps"); + } + assert.ok(completions >= 20, `playable vehicle completed only ${completions} route circuits`); + assert.ok(first.state().elapsedSteps === HOUR_AT_TEN_HZ); + assert.deepEqual(first.snapshot(), replay.snapshot()); + }); + + it("flies a crow for an accelerated hour inside strict 3D bounds", { timeout: SOAK_TIMEOUT_MS }, () => { + const options: ActorControllerOptions = { + kind: "crow", + mode: "flight", + identity: { id: "soak-crow", displayName: "Soak Crow", authenticated: false, profile: {} }, + position: { x: 0, y: 25, z: 0 }, + groundY: 0, + minFlightAltitude: 5, + maxFlightAltitude: 50, + horizontalBounds: { minX: -200, maxX: 200, minZ: -200, maxZ: 200 }, + fixedStepSeconds: 0.1, + }; + const first = new ActorController(options); + const replay = new ActorController(options); + const visited = new Set(); + + for (let step = 0; step < HOUR_AT_TEN_HZ; step += 1) { + const phase = step % 1_800; + const actions = { + forward: 0.65 + 0.3 * Math.sin(step / 173), + turn: phase < 900 ? 0.23 : -0.31, + pitch: Math.sin(step / 257) * 0.7, + climb: Math.sin(step / 401) * 0.8, + glide: phase >= 1_500, + }; + first.stepFixed(actions); + replay.stepFixed(actions); + const state = first.state(); + if (step % 120 !== 0) continue; + finiteNumbers(state); + inside(state.x, -200, 200, "crow.x"); + inside(state.z, -200, 200, "crow.z"); + inside(state.y, 5, 50, "crow.y"); + inside(state.posePhase, 0, Math.PI * 2, "crow.posePhase"); + visited.add(`${Math.round(state.x / 20)}:${Math.round(state.z / 20)}:${Math.round(state.y / 5)}`); + } + assert.ok(first.state().distanceM > 20_000, + `crow accumulated only ${first.state().distanceM.toFixed(1)} m of flight distance`); + assert.ok(visited.size > 25, `crow explored only ${visited.size} coarse cells`); + assert.deepEqual(first.snapshot(), replay.snapshot()); + }); + + it("walks against office walls for an accelerated hour without tunnelling or sticking", { timeout: SOAK_TIMEOUT_MS }, () => { + const room: Room = { + id: "soak-room", name: "Soak Room", floor: "floor" as never, + outline: [{ x: 0, z: 0 }, { x: 30, z: 0 }, { x: 30, z: 20 }, { x: 0, z: 20 }], + }; + const walls: Wall[] = [ + { id: "vertical", from: { x: 15, z: 0 }, to: { x: 15, z: 20 }, + openings: [{ kind: "door", start: 8.5, width: 3, sill: 0, head: 2.2 }] }, + { id: "horizontal", from: { x: 0, z: 10 }, to: { x: 12, z: 10 } }, + ]; + const level: Level = { + id: "ground", name: "Ground", elevation: 0, wallHeight: 3, wallThickness: 0.12, + floorplan: { rooms: [room], walls }, + }; + const plan = new Plan({ id: "soak-office", name: "Soak Office", levels: [level], viewpoints: [] } as Office, + { warn: false }); + const create = () => createWalker(plan, { + levelId: "ground", position: { x: 4, z: 4 }, radius: 0.3, + speed: 2.4, fixedStep: 0.1, maxCatchUpSteps: 1, + }); + const first = create(); + const replay = create(); + const directions = [ + { x: 1, z: 0.23 }, { x: 0.18, z: 1 }, { x: -1, z: -0.17 }, { x: -0.11, z: -1 }, + ] as const; + const visited = new Set(); + + for (let step = 0; step < HOUR_AT_TEN_HZ; step += 1) { + const action = directions[Math.floor(step / 450) % directions.length]!; + const a = first.tick(0.1, action); + replay.tick(0.1, action); + if (step % 30 === 0) visited.add(`${Math.round(a.position.x)}:${Math.round(a.position.z)}`); + if (step % 120 !== 0) continue; + finiteNumbers(a); + inside(a.position.x, 0.3, 29.7, "walker.x"); + inside(a.position.z, 0.3, 19.7, "walker.z"); + assert.equal(plan.blocked("ground", a.position, a.position, 0.3), false, "walker entered collision geometry"); + } + assert.ok(first.state().distance > 200, + `walker accumulated only ${first.state().distance.toFixed(1)} m of distance`); + assert.ok(visited.size > 20, `walker visited only ${visited.size} cells`); + assert.deepEqual(first.state(), replay.state()); + }); + + it("circuits an assisted aircraft route for an accelerated hour within its envelope", { timeout: SOAK_TIMEOUT_MS }, () => { + const route: readonly AircraftWaypoint[] = [ + { id: "west", lat: 34.05, lng: -118.28, altitudeM: 1_200 }, + { id: "north", lat: 34.13, lng: -118.20, altitudeM: 1_650 }, + { id: "east", lat: 34.05, lng: -118.12, altitudeM: 1_350 }, + ]; + const options = { + route, + initialPosition: route[0], + initialAltitudeM: 1_200, + initialHeadingDeg: 45, + initialSpeedMps: 55, + fixedStepSeconds: 0.1, + } as const; + const first = new AircraftController(options); + const replay = new AircraftController(options); + let priorIndex = first.state().routeWaypointIndex; + let waypointTransitions = 0; + let routeCompletions = 0; + + for (let step = 0; step < HOUR_AT_TEN_HZ; step += 1) { + first.stepFixed(); + replay.stepFixed(); + const state = first.state(); + if (state.routeWaypointIndex !== priorIndex) { + waypointTransitions += 1; + if (priorIndex === route.length - 1 && state.routeWaypointIndex === 0) routeCompletions += 1; + priorIndex = state.routeWaypointIndex; + } + if (step % 120 !== 0) continue; + finiteNumbers(state); + inside(state.lat, 32.4, 42.1, "aircraft.lat"); + inside(state.lng, -124.6, -114, "aircraft.lng"); + inside(state.altitudeM, 75, 6_000, "aircraft.altitudeM"); + inside(state.speedMps, 20, 95, "aircraft.speedMps"); + inside(state.headingDeg, 0, 360, "aircraft.headingDeg"); + } + assert.ok(waypointTransitions >= 6, `aircraft made only ${waypointTransitions} waypoint transitions`); + assert.ok(routeCompletions >= 2, `aircraft completed only ${routeCompletions} circuits`); + assert.equal(first.state().elapsedSteps, HOUR_AT_TEN_HZ); + assert.deepEqual(first.snapshot(), replay.snapshot()); + }); +});