cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

refactor(portal): extract stream runtime

Dami committed 6d8197b
commit
6d8197b1c12fb0132375a158b2ba3e3e258c1db9
parent
a8f423fcca0641592e53d39ea5f6789bcf213ce0
author
Dami <dami.etoile@gmail.com>
3 files changed, +752 -276
Mportal/main.js330++++-----------------------
Aportal/stream-runtime.mjs361++++++++++++++++++++++++++++++
Aportal/stream-runtime.test.mjs337++++++++++++++++++++++++++++
M portal/main.js +54 -276
@@ -12,21 +12,8 @@ import {
1212 validatePointerPositionFeedback,1313 browserMouseButtonCode,1414 } from './input-state.mjs';15-import { parseFrameEnvelope } from './frame-envelope.mjs';16-import {17- formatVideoDiscardTelemetry,18- isCurrentFrameGeneration,19- normalizeFrameStatsPayload,20-} from './frame-stats.mjs';15+import { formatVideoDiscardTelemetry } from './frame-stats.mjs';2116 import { networkDiagnosticsPresentation } from './network-diagnostics.mjs';22-import {23- FRAME_CHANNEL_CAPACITY,24- activateFrameSession,25- isActiveFrameSession,26- newFrameSession,27- stageFrameAcknowledgment,28- stageLegacyFrame,29-} from './frame-session.mjs';3017 import { newPointerSession, parsePointerFeedbackMessage } from './pointer-feedback.mjs';3118 import { newConnectionState } from './connection-state.mjs';3219 import { formatSignedMilliseconds } from './av-sync.mjs';
@@ -59,10 +46,9 @@ import {
5946 createVideoPipelineSession,6047 } from './video-pipeline.mjs';6148 import {62- AdaptiveFeedbackPublisher,6349 formatAdaptiveDecision,64- normalizeAdaptiveDecisionEnvelope,6550 } from './adaptive-feedback.mjs';51+import { createStreamRuntime } from './stream-runtime.mjs';6652 import {6753 formatInvitationExpiry,6854 grantLabel,
@@ -79,9 +65,6 @@ let fittedStreamAspect = null;
7965 let pendingStreamFit = null;8066 let lastObservedWindowSize = null;8167 let streamWindowResizeTimer = null;82-let activeFrameChannel = null;83-let activeFrameGeneration = null;84-let activeFrameSession = null;8568 let activePointerSession = null;8669 let controllerActivationInProgress = false;8770 let controlTransitionInProgress = false;
@@ -134,16 +117,13 @@ function handlePointerPositionFeedback(message, session) {
134117 const invoke = (...args) => window.__TAURI__.core.invoke(...args);135118 const listen = (...args) => window.__TAURI__.event.listen(...args);136119 let videoPipeline = null;120+let streamRuntime = null;137121 const audioPipeline = createAudioPipelineSession({138122 invokeCommand: invoke,139123 onUpdate: updateAudioUI,140124 resetAvSync: (...args) => videoPipeline?.resetAvSync(...args),141125 isDisconnecting: () => connectionState.disconnecting,142126 });143-const adaptiveFeedbackPublisher = new AdaptiveFeedbackPublisher({ invokeCommand: invoke });144-let adaptiveFeedbackAvailable = false;145-let adaptiveFeedbackError = null;146-let adaptiveDecision = null;147127 148128 function updatePanelVisibility() {149129 const streamSection = document.getElementById('stream-section');
@@ -434,22 +414,16 @@ async function toggleAudioMute() {
434414 async function createConnectionAttempt({ createChannel: Channel }) {435415 // Reset before opening the channel so early frames cannot race a436416 // post-connect reset and disappear from the live diagnostics.437- if (activeFrameSession) activeFrameSession.closing = true;438- activeFrameSession = null;439- activeFrameGeneration = null;440- adaptiveFeedbackPublisher.stop();441- adaptiveFeedbackAvailable = false;442- adaptiveFeedbackError = null;443- adaptiveDecision = null;444- resetStreamTelemetry();417+ streamRuntime.prepareConnection();445418 if (typeof Channel !== 'function') throw new Error('Tauri binary channels are unavailable');446419 const audioSession = audioPipeline.beginAttempt();447- const frameSession = newFrameSession();420+ const frameSession = streamRuntime.openFrameSession();448421 const pointerSession = newPointerSession();449- activeFrameSession = frameSession;450422 activePointerSession = pointerSession;451423 const audioAttempt = await audioPipeline.createAttemptChannel(Channel, audioSession);452- activeFrameChannel = new Channel((message) => handleBinaryFrameMessage(message, frameSession));424+ const frameChannel = new Channel(425+ (message) => streamRuntime.handleBinaryFrame(message, frameSession),426+ );453427 pointerSession.channel = new Channel(454428 (message) => handlePointerPositionFeedback(message, pointerSession),455429 );
@@ -459,7 +433,7 @@ async function createConnectionAttempt({ createChannel: Channel }) {
459433 pointerSession,460434 audioSupport: audioAttempt.support,461435 connectionArgs: {462- frameChannel: activeFrameChannel,436+ frameChannel,463437 audioChannel: audioAttempt.channel,464438 pointerChannel: pointerSession.channel,465439 audioSupported: audioAttempt.support.supported,
@@ -491,25 +465,19 @@ function validateConnectedAttempt(result, attempt) {
491465 }492466 493467 function activateConnectedAttempt(result, attempt) {494- activateConnectedFrameSession(attempt.frameSession, result.media_generation);495- adaptiveFeedbackAvailable = result.adaptive_feedback_available === true;496- adaptiveFeedbackError = typeof result.adaptive_feedback_error === 'string'497- ? result.adaptive_feedback_error : null;498- adaptiveFeedbackPublisher.start(result.media_generation, adaptiveFeedbackAvailable);468+ streamRuntime.activateFrameSession(attempt.frameSession, {469+ generation: result.media_generation,470+ adaptiveFeedbackAvailable: result.adaptive_feedback_available === true,471+ adaptiveFeedbackError: result.adaptive_feedback_error,472+ });499473 }500474 501475 function closeConnectionAttempt(attempt, { committed = false } = {}) {502- if (attempt?.frameSession) attempt.frameSession.closing = true;503- if (activeFrameSession === attempt?.frameSession) activeFrameSession = null;504- activeFrameGeneration = null;505- adaptiveFeedbackPublisher.stop();506- adaptiveFeedbackAvailable = false;507- adaptiveDecision = null;476+ streamRuntime.closeFrameSession(attempt?.frameSession);508477 if (committed && attempt?.pointerSession) attempt.pointerSession.closing = true;509478 }510479 511480 async function teardownConnectionAttempt(attempt) {512- activeFrameChannel = null;513481 const pointerSession = attempt?.pointerSession ?? activePointerSession;514482 if (activePointerSession === pointerSession) activePointerSession = null;515483 if (pointerSession) pointerSession.channel = null;
@@ -555,13 +523,7 @@ async function showConnectedAttempt({ result, attempt }) {
555523 }556524 557525 async function prepareDisconnect() {558- if (activeFrameSession) activeFrameSession.closing = true;559- activeFrameSession = null;560- activeFrameGeneration = null;561- adaptiveFeedbackPublisher.stop();562- adaptiveFeedbackAvailable = false;563- adaptiveFeedbackError = null;564- adaptiveDecision = null;526+ streamRuntime.prepareDisconnect();565527 controlTransitionGeneration += 1;566528 controlTransitionInProgress = false;567529 controllerActivationGate.reset();
@@ -576,12 +538,11 @@ async function prepareDisconnect() {
576538 }577539 578540 async function teardownConnectedResources() {579- activeFrameChannel = null;580541 const pointerSession = activePointerSession;581542 activePointerSession = null;582543 if (pointerSession) pointerSession.channel = null;583544 await audioPipeline.teardown();584- videoPipeline.teardown();545+ streamRuntime.teardown();585546 }586547 587548 async function showDisconnectedState() {
@@ -680,34 +641,6 @@ let hasWebCodecs = ('VideoDecoder' in window);
680641 })();681642 682643 // ─── Frame decoding ───────────────────────────────────────────────────────────683-let transportDroppedFrames = 0;684-let transportObjectDroppedFrames = null;685-let transportLateObjectDroppedFrames = null;686-let frontendDroppedFrames = 0;687-let frontendQueueDroppedFrames = null;688-let frontendResyncDroppedFrames = null;689-let frontendQueueStats = null;690-let frontendResyncStats = null;691-let transportIntervalStats = null;692-let frontendIpcSendDurationStats = null;693-let rustTimingWindow = null;694-let networkDiagnostics = null;695-let lastTransportFps = 0;696-let lastFrontendSendFps = 0;697-let streamPathMode = 'unknown';698-let streamRttMs = null;699-let lastMediaSequence = null;700-701-function requestDecoderKeyframe(reason) {702- if (!Number.isSafeInteger(activeFrameGeneration) || activeFrameGeneration < 1) return;703- void invoke('iroh_client_request_keyframe', {704- generation: activeFrameGeneration,705- reason,706- }).catch((error) => {707- console.warn(`keyframe request failed (${reason}):`, error);708- });709-}710-711644 const canvas = document.getElementById('frame-canvas');712645 const remotePointer = document.getElementById('remote-pointer');713646 const ctx = canvas.getContext('2d');
@@ -715,7 +648,7 @@ videoPipeline = createVideoPipelineSession({
715648 hasWebCodecs,716649 canvas,717650 context: ctx,718- requestKeyframe: requestDecoderKeyframe,651+ requestKeyframe: (reason) => streamRuntime?.requestKeyframe(reason),719652 resetAudioSync: audioPipeline.resetSyncTelemetry,720653 sampleAudioSkew: audioPipeline.sampleSkew,721654 onFormatChanged: () => {
@@ -723,28 +656,12 @@ videoPipeline = createVideoPipelineSession({
723656 if (connectionState.connected) void fitWindowToIncomingStream();724657 },725658 });726-727-function resetStreamTelemetry() {728- videoPipeline.reset();729- transportDroppedFrames = 0;730- transportObjectDroppedFrames = null;731- transportLateObjectDroppedFrames = null;732- frontendDroppedFrames = 0;733- frontendQueueDroppedFrames = null;734- frontendResyncDroppedFrames = null;735- frontendQueueStats = null;736- frontendResyncStats = null;737- transportIntervalStats = null;738- frontendIpcSendDurationStats = null;739- rustTimingWindow = null;740- networkDiagnostics = null;741- lastTransportFps = 0;742- lastFrontendSendFps = 0;743- streamPathMode = 'unknown';744- streamRttMs = null;745- lastMediaSequence = null;746- adaptiveDecision = null;747-}659+streamRuntime = createStreamRuntime({660+ invokeCommand: invoke,661+ videoPipeline,662+ isConnected: () => connectionState.connected,663+ disconnect,664+});748665 749666 function formatCadence(summary) {750667 if (!summary.count) return '—';
@@ -759,7 +676,28 @@ function formatRustDurationSummary(summary) {
759676 760677 function updateStreamStats() {761678 const now = performance.now();762- const video = videoPipeline.snapshot(now);679+ const {680+ video,681+ transportDroppedFrames,682+ transportObjectDroppedFrames,683+ transportLateObjectDroppedFrames,684+ frontendDroppedFrames,685+ frontendQueueDroppedFrames,686+ frontendResyncDroppedFrames,687+ frontendQueueStats,688+ frontendResyncStats,689+ transportIntervalStats,690+ frontendIpcSendDurationStats,691+ rustTimingWindow,692+ networkDiagnostics,693+ lastTransportFps,694+ lastFrontendSendFps,695+ streamPathMode,696+ streamRttMs,697+ adaptiveFeedbackAvailable,698+ adaptiveFeedbackError,699+ adaptiveDecision,700+ } = streamRuntime.snapshot(now);763701 const networkPresentation = networkDiagnosticsPresentation(networkDiagnostics);764702 document.getElementById('stream-received').textContent = video.receivedFrames;765703 document.getElementById('stream-decoder-input').textContent = video.decoderInputFrames;
@@ -839,25 +777,7 @@ function updateStreamStats() {
839777 adaptiveFeedbackAvailable,840778 );841779 842- adaptiveFeedbackPublisher.publish({843- lastSequence: lastMediaSequence,844- frontendQueueDepth: frontendQueueStats?.depth ?? 0,845- frontendQueueCapacity: frontendQueueStats?.capacity ?? 4,846- decoderQueueDepth: video.decoderQueueDepth,847- decoderQueueCapacity: video.decoderQueueCapacity,848- presenterQueueDepth: video.presenterQueueDepth,849- presenterQueueCapacity: video.presenterQueueCapacity,850- transportDroppedTotal: transportDroppedFrames,851- frontendDroppedTotal: frontendDroppedFrames,852- decoderDroppedTotal: video.droppedFrames,853- presenterDroppedTotal: video.presentationDroppedFrames,854- // Portal has no clock-synchronized capture-to-delivery measurement yet.855- // IPC send duration is a different boundary and must not be mislabeled.856- transportDeliveryP95Ms: null,857- decodeLatencyP95Ms: video.decodePercentiles.p95,858- presentationLatencyP95Ms: video.presentPercentiles.p95,859- resyncActive: video.recovering || frontendResyncStats?.active === true,860- });780+ streamRuntime.publishAdaptiveFeedback(video);861781 }862782 863783 setInterval(() => {
@@ -911,7 +831,7 @@ async function fitWindowToIncomingStream() {
911831 if (!connectionState.connected || frameWidth < 1 || frameHeight < 1) return false;912832 const aspect = streamAspectKey(frameWidth, frameHeight);913833 if (fittedStreamAspect === aspect || pendingStreamFit?.aspect === aspect) return false;914- const request = { aspect, generation: activeFrameGeneration, epoch: activeVideoFormatEpoch };834+ const request = { aspect, generation: streamRuntime.generation, epoch: activeVideoFormatEpoch };915835 pendingStreamFit = request;916836 const geometry = fitInitialStreamWindow({917837 frameWidth,
@@ -927,7 +847,7 @@ async function fitWindowToIncomingStream() {
927847 : null;928848 const current = pendingStreamFit === request929849 && connectionState.connected930- && activeFrameGeneration === request.generation850+ && streamRuntime.generation === request.generation931851 && currentFormat.epoch === request.epoch932852 && currentAspect === request.aspect;933853 if (pendingStreamFit === request) pendingStreamFit = null;
@@ -964,168 +884,26 @@ function scheduleStreamWindowAspectCorrection() {
964884 }, 80);965885 }966886 967-function sendFrameAcknowledgment(generation) {968- void invoke('iroh_client_ack_frame', { generation }).catch((error) => {969- console.warn('frame acknowledgment failed:', error);970- });971-}972-973-function acknowledgeFrame(session, generation = null) {974- const readyGeneration = stageFrameAcknowledgment(session, generation);975- if (readyGeneration !== null) sendFrameAcknowledgment(readyGeneration);976-}977-978-function activateConnectedFrameSession(session, generation) {979- if (!isActiveFrameSession(session, activeFrameSession)) {980- throw new Error('frame session was superseded during connect');981- }982- const activation = activateFrameSession(session, generation);983- activeFrameGeneration = generation;984- for (const pendingGeneration of activation.acknowledgments) {985- sendFrameAcknowledgment(pendingGeneration);986- }987- const pendingError = session.pendingFrameErrors.splice(0).find(988- (payload) => isCurrentFrameGeneration(payload?.generation, generation),989- );990- if (pendingError) throw new Error(pendingError.error || 'Media connection failed');991- for (const payload of activation.legacyFrames) {992- if (isCurrentFrameGeneration(payload?.generation, generation)) {993- videoPipeline.processFramePayload(payload);994- }995- }996-}997-998-function failFrameSession(session, error) {999- if (!isActiveFrameSession(session, activeFrameSession)) return;1000- session.failed = true;1001- session.failureDetail = `Frame delivery failed: ${error}`;1002- console.error(session.failureDetail);1003- if (connectionState.connected) void disconnect();1004-}1005-1006-function handleBinaryFrameMessage(message, session) {1007- if (!isActiveFrameSession(session, activeFrameSession)) return;1008- let acknowledged = false;1009- try {1010- const frame = parseFrameEnvelope(message);1011- // Parsing establishes the exact envelope and payload bounds. Release the1012- // Rust-side delivery permit before codec parsing/decode enqueue so a slow1013- // invoke round trip cannot turn one scheduling hiccup into a full-GOP1014- // discard. WebCodecs remains independently bounded by decodeQueueSize.1015- acknowledgeFrame(session);1016- acknowledged = true;1017- videoPipeline.processFramePayload({1018- width: frame.width,1019- height: frame.height,1020- data: null,1021- codec: frame.codec,1022- keyframe: frame.keyframe,1023- codecConfig: frame.codecConfig,1024- sequence: frame.sequence,1025- pts_micros: frame.ptsMicros,1026- discontinuity: frame.discontinuity,1027- }, frame.data);1028- } catch (error) {1029- failFrameSession(session, error);1030- } finally {1031- if (!acknowledged) {1032- // A malformed envelope disconnects above, but must never deadlock the1033- // bounded sender while teardown reaches Rust.1034- try {1035- acknowledgeFrame(session);1036- } catch (error) {1037- failFrameSession(session, error);1038- }1039- }1040- }1041-}1042-1043887 // The software decoder/JPEG compatibility path intentionally remains an event:1044888 // it is only selected when WebCodecs is unavailable and is not latency-critical.1045889 listen('frame', (event) => {1046- const session = activeFrameSession;1047- if (!isActiveFrameSession(session, activeFrameSession)) return;1048- try {1049- const delivery = stageLegacyFrame(session, event.payload);1050- for (const generation of delivery.acknowledgments) {1051- sendFrameAcknowledgment(generation);1052- }1053- if (delivery.accepted) videoPipeline.processFramePayload(event.payload);1054- } catch (error) {1055- failFrameSession(session, error);1056- }890+ streamRuntime.handleLegacyFrame(event.payload);1057891 });1058892 1059893 listen('frame-stats', (event) => {1060- if (!isCurrentFrameGeneration(event.payload?.generation, activeFrameGeneration)) return;1061- const diagnostics = normalizeFrameStatsPayload(event.payload);1062- transportDroppedFrames = diagnostics.transportDroppedFrames ?? transportDroppedFrames;1063- transportObjectDroppedFrames = diagnostics.objectDroppedFrames;1064- transportLateObjectDroppedFrames = diagnostics.lateObjectDroppedFrames;1065- frontendDroppedFrames = diagnostics.frontendDroppedFrames ?? frontendDroppedFrames;1066- frontendQueueDroppedFrames = diagnostics.queueDroppedFrames;1067- frontendResyncDroppedFrames = diagnostics.resyncDroppedFrames;1068- frontendQueueStats = diagnostics.queue;1069- frontendResyncStats = diagnostics.resync;1070- transportIntervalStats = diagnostics.transportIntervals;1071- frontendIpcSendDurationStats = diagnostics.ipcSendDurations;1072- rustTimingWindow = diagnostics.timingWindow;1073- networkDiagnostics = diagnostics.networkDiagnostics;1074- streamPathMode = typeof event.payload.path_mode === 'string'1075- ? event.payload.path_mode1076- : streamPathMode;1077- streamRttMs = Number.isFinite(event.payload.path_rtt_ms)1078- ? event.payload.path_rtt_ms1079- : streamRttMs;1080- lastMediaSequence = Number.isSafeInteger(event.payload.sequence) && event.payload.sequence >= 01081- ? event.payload.sequence : lastMediaSequence;1082- lastTransportFps = Number.isFinite(event.payload.transport_receive_fps)1083- ? event.payload.transport_receive_fps1084- : Number.isFinite(event.payload.transport_fps)1085- ? event.payload.transport_fps1086- : event.payload.fps;1087- lastFrontendSendFps = Number.isFinite(event.payload.frontend_send_fps)1088- ? event.payload.frontend_send_fps1089- : Number.isFinite(event.payload.frontend_fps)1090- ? event.payload.frontend_fps1091- : event.payload.fps;1092- updateStreamStats();894+ if (streamRuntime.handleFrameStats(event.payload)) updateStreamStats();1093895 });1094896 1095897 listen('adaptive-bitrate-decision', (event) => {1096- try {1097- const decision = normalizeAdaptiveDecisionEnvelope(event.payload, activeFrameGeneration);1098- if (decision === null) return;1099- adaptiveDecision = decision;1100- updateStreamStats();1101- } catch (error) {1102- console.warn('invalid adaptive bitrate diagnostic:', error);1103- }898+ if (streamRuntime.handleAdaptiveDecision(event.payload)) updateStreamStats();1104899 });1105900 1106901 listen('adaptive-feedback-state', (event) => {1107- if (!isCurrentFrameGeneration(event.payload?.generation, activeFrameGeneration)) return;1108- if (event.payload?.available !== false) return;1109- adaptiveFeedbackAvailable = false;1110- adaptiveFeedbackError = typeof event.payload.error === 'string'1111- ? event.payload.error : 'feedback stream closed';1112- adaptiveFeedbackPublisher.stop();1113- updateStreamStats();902+ if (streamRuntime.handleAdaptiveFeedbackState(event.payload)) updateStreamStats();1114903 });1115904 1116905 listen('frame-error', (event) => {1117- const session = activeFrameSession;1118- if (!isActiveFrameSession(session, activeFrameSession)) return;1119- if (session.expectedGeneration === null) {1120- if (session.pendingFrameErrors.length < FRAME_CHANNEL_CAPACITY) {1121- session.pendingFrameErrors.push(event.payload);1122- } else {1123- failFrameSession(session, 'pre-connect frame error capacity exceeded');1124- }1125- return;1126- }1127- if (!isCurrentFrameGeneration(event.payload?.generation, session.expectedGeneration)) return;1128- failFrameSession(session, event.payload?.error || 'Media connection failed');906+ streamRuntime.handleFrameError(event.payload);1129907 });1130908 1131909 listen('audio-state', (event) => {
A portal/stream-runtime.mjs +361 -0
@@ -0,0 +1,361 @@
1+import { parseFrameEnvelope } from './frame-envelope.mjs';2+import {3+ isCurrentFrameGeneration,4+ normalizeFrameStatsPayload,5+} from './frame-stats.mjs';6+import {7+ FRAME_CHANNEL_CAPACITY,8+ activateFrameSession as activatePendingFrameSession,9+ isActiveFrameSession,10+ newFrameSession,11+ stageFrameAcknowledgment,12+ stageLegacyFrame,13+} from './frame-session.mjs';14+import {15+ AdaptiveFeedbackPublisher,16+ normalizeAdaptiveDecisionEnvelope,17+} from './adaptive-feedback.mjs';18+19+function newTransportTelemetry() {20+ return {21+ transportDroppedFrames: 0,22+ transportObjectDroppedFrames: null,23+ transportLateObjectDroppedFrames: null,24+ frontendDroppedFrames: 0,25+ frontendQueueDroppedFrames: null,26+ frontendResyncDroppedFrames: null,27+ frontendQueueStats: null,28+ frontendResyncStats: null,29+ transportIntervalStats: null,30+ frontendIpcSendDurationStats: null,31+ rustTimingWindow: null,32+ networkDiagnostics: null,33+ lastTransportFps: 0,34+ lastFrontendSendFps: 0,35+ streamPathMode: 'unknown',36+ streamRttMs: null,37+ lastMediaSequence: null,38+ };39+}40+41+function assertDependency(value, name) {42+ if (typeof value !== 'function') throw new TypeError(`${name} must be a function`);43+}44+45+export function createStreamRuntime({46+ invokeCommand,47+ videoPipeline,48+ isConnected = () => false,49+ disconnect = () => {},50+ adaptiveFeedbackPublisher = null,51+ logger = console,52+} = {}) {53+ assertDependency(invokeCommand, 'invokeCommand');54+ assertDependency(isConnected, 'isConnected');55+ assertDependency(disconnect, 'disconnect');56+ if (!videoPipeline57+ || typeof videoPipeline.processFramePayload !== 'function'58+ || typeof videoPipeline.reset !== 'function'59+ || typeof videoPipeline.teardown !== 'function'60+ || typeof videoPipeline.snapshot !== 'function') {61+ throw new TypeError('videoPipeline must implement the stream pipeline interface');62+ }63+ const feedbackPublisher = adaptiveFeedbackPublisher64+ ?? new AdaptiveFeedbackPublisher({ invokeCommand });65+ if (!feedbackPublisher66+ || typeof feedbackPublisher.start !== 'function'67+ || typeof feedbackPublisher.stop !== 'function'68+ || typeof feedbackPublisher.publish !== 'function') {69+ throw new TypeError('adaptiveFeedbackPublisher must implement start, stop, and publish');70+ }71+72+ let activeFrameSession = null;73+ let activeFrameGeneration = null;74+ let adaptiveFeedbackAvailable = false;75+ let adaptiveFeedbackError = null;76+ let adaptiveDecision = null;77+ let transport = newTransportTelemetry();78+79+ function sendFrameAcknowledgment(generation) {80+ void invokeCommand('iroh_client_ack_frame', { generation }).catch((error) => {81+ logger.warn('frame acknowledgment failed:', error);82+ });83+ }84+85+ function acknowledgeFrame(session, generation = null) {86+ const readyGeneration = stageFrameAcknowledgment(session, generation);87+ if (readyGeneration !== null) sendFrameAcknowledgment(readyGeneration);88+ }89+90+ function failFrameSession(session, error) {91+ if (!isActiveFrameSession(session, activeFrameSession)) return false;92+ session.failed = true;93+ session.failureDetail = `Frame delivery failed: ${error}`;94+ logger.error(session.failureDetail);95+ if (isConnected()) void disconnect();96+ return true;97+ }98+99+ function prepareConnection() {100+ if (activeFrameSession) activeFrameSession.closing = true;101+ activeFrameSession = null;102+ activeFrameGeneration = null;103+ feedbackPublisher.stop();104+ adaptiveFeedbackAvailable = false;105+ adaptiveFeedbackError = null;106+ adaptiveDecision = null;107+ videoPipeline.reset();108+ transport = newTransportTelemetry();109+ }110+111+ function openFrameSession() {112+ const session = newFrameSession();113+ activeFrameSession = session;114+ return session;115+ }116+117+ function activateFrameSession(session, {118+ generation,119+ adaptiveFeedbackAvailable: feedbackAvailable = false,120+ adaptiveFeedbackError: feedbackError = null,121+ }) {122+ if (!isActiveFrameSession(session, activeFrameSession)) {123+ throw new Error('frame session was superseded during connect');124+ }125+ const activation = activatePendingFrameSession(session, generation);126+ activeFrameGeneration = generation;127+ for (const pendingGeneration of activation.acknowledgments) {128+ sendFrameAcknowledgment(pendingGeneration);129+ }130+ const pendingError = session.pendingFrameErrors.splice(0).find(131+ (payload) => isCurrentFrameGeneration(payload?.generation, generation),132+ );133+ if (pendingError) throw new Error(pendingError.error || 'Media connection failed');134+ for (const payload of activation.legacyFrames) {135+ if (isCurrentFrameGeneration(payload?.generation, generation)) {136+ videoPipeline.processFramePayload(payload);137+ }138+ }139+ adaptiveFeedbackAvailable = feedbackAvailable === true;140+ adaptiveFeedbackError = typeof feedbackError === 'string' ? feedbackError : null;141+ feedbackPublisher.start(generation, adaptiveFeedbackAvailable);142+ }143+144+ function closeFrameSession(session) {145+ const closingSession = session ?? activeFrameSession;146+ if (closingSession) closingSession.closing = true;147+ if (activeFrameSession === closingSession) activeFrameSession = null;148+ activeFrameGeneration = null;149+ feedbackPublisher.stop();150+ adaptiveFeedbackAvailable = false;151+ adaptiveDecision = null;152+ }153+154+ function prepareDisconnect() {155+ if (activeFrameSession) activeFrameSession.closing = true;156+ activeFrameSession = null;157+ activeFrameGeneration = null;158+ feedbackPublisher.stop();159+ adaptiveFeedbackAvailable = false;160+ adaptiveFeedbackError = null;161+ adaptiveDecision = null;162+ }163+164+ function teardown() {165+ videoPipeline.teardown();166+ }167+168+ function requestKeyframe(reason) {169+ if (!Number.isSafeInteger(activeFrameGeneration) || activeFrameGeneration < 1) return false;170+ void invokeCommand('iroh_client_request_keyframe', {171+ generation: activeFrameGeneration,172+ reason,173+ }).catch((error) => {174+ logger.warn(`keyframe request failed (${reason}):`, error);175+ });176+ return true;177+ }178+179+ function handleBinaryFrame(message, session) {180+ if (!isActiveFrameSession(session, activeFrameSession)) return false;181+ let acknowledged = false;182+ try {183+ const frame = parseFrameEnvelope(message);184+ // Parsing establishes the exact envelope and payload bounds. Release the185+ // Rust-side delivery permit before codec parsing/decode enqueue so a slow186+ // invoke round trip cannot turn one scheduling hiccup into a full-GOP187+ // discard. WebCodecs remains independently bounded by decodeQueueSize.188+ acknowledgeFrame(session);189+ acknowledged = true;190+ videoPipeline.processFramePayload({191+ width: frame.width,192+ height: frame.height,193+ data: null,194+ codec: frame.codec,195+ keyframe: frame.keyframe,196+ codecConfig: frame.codecConfig,197+ sequence: frame.sequence,198+ pts_micros: frame.ptsMicros,199+ discontinuity: frame.discontinuity,200+ }, frame.data);201+ return true;202+ } catch (error) {203+ failFrameSession(session, error);204+ return false;205+ } finally {206+ if (!acknowledged) {207+ // A malformed envelope disconnects above, but must never deadlock the208+ // bounded sender while teardown reaches Rust.209+ try {210+ acknowledgeFrame(session);211+ } catch (error) {212+ failFrameSession(session, error);213+ }214+ }215+ }216+ }217+218+ function handleLegacyFrame(payload) {219+ const session = activeFrameSession;220+ if (!isActiveFrameSession(session, activeFrameSession)) return false;221+ try {222+ const delivery = stageLegacyFrame(session, payload);223+ for (const generation of delivery.acknowledgments) {224+ sendFrameAcknowledgment(generation);225+ }226+ if (delivery.accepted) videoPipeline.processFramePayload(payload);227+ return delivery.accepted;228+ } catch (error) {229+ failFrameSession(session, error);230+ return false;231+ }232+ }233+234+ function handleFrameStats(payload) {235+ if (!isCurrentFrameGeneration(payload?.generation, activeFrameGeneration)) return false;236+ const diagnostics = normalizeFrameStatsPayload(payload);237+ transport.transportDroppedFrames = diagnostics.transportDroppedFrames238+ ?? transport.transportDroppedFrames;239+ transport.transportObjectDroppedFrames = diagnostics.objectDroppedFrames;240+ transport.transportLateObjectDroppedFrames = diagnostics.lateObjectDroppedFrames;241+ transport.frontendDroppedFrames = diagnostics.frontendDroppedFrames242+ ?? transport.frontendDroppedFrames;243+ transport.frontendQueueDroppedFrames = diagnostics.queueDroppedFrames;244+ transport.frontendResyncDroppedFrames = diagnostics.resyncDroppedFrames;245+ transport.frontendQueueStats = diagnostics.queue;246+ transport.frontendResyncStats = diagnostics.resync;247+ transport.transportIntervalStats = diagnostics.transportIntervals;248+ transport.frontendIpcSendDurationStats = diagnostics.ipcSendDurations;249+ transport.rustTimingWindow = diagnostics.timingWindow;250+ transport.networkDiagnostics = diagnostics.networkDiagnostics;251+ transport.streamPathMode = typeof payload.path_mode === 'string'252+ ? payload.path_mode253+ : transport.streamPathMode;254+ transport.streamRttMs = Number.isFinite(payload.path_rtt_ms)255+ ? payload.path_rtt_ms256+ : transport.streamRttMs;257+ transport.lastMediaSequence = Number.isSafeInteger(payload.sequence) && payload.sequence >= 0258+ ? payload.sequence : transport.lastMediaSequence;259+ transport.lastTransportFps = Number.isFinite(payload.transport_receive_fps)260+ ? payload.transport_receive_fps261+ : Number.isFinite(payload.transport_fps)262+ ? payload.transport_fps263+ : payload.fps;264+ transport.lastFrontendSendFps = Number.isFinite(payload.frontend_send_fps)265+ ? payload.frontend_send_fps266+ : Number.isFinite(payload.frontend_fps)267+ ? payload.frontend_fps268+ : payload.fps;269+ return true;270+ }271+272+ function handleAdaptiveDecision(payload) {273+ try {274+ const decision = normalizeAdaptiveDecisionEnvelope(payload, activeFrameGeneration);275+ if (decision === null) return false;276+ adaptiveDecision = decision;277+ return true;278+ } catch (error) {279+ logger.warn('invalid adaptive bitrate diagnostic:', error);280+ return false;281+ }282+ }283+284+ function handleAdaptiveFeedbackState(payload) {285+ if (!isCurrentFrameGeneration(payload?.generation, activeFrameGeneration)) return false;286+ if (payload?.available !== false) return false;287+ adaptiveFeedbackAvailable = false;288+ adaptiveFeedbackError = typeof payload.error === 'string'289+ ? payload.error : 'feedback stream closed';290+ feedbackPublisher.stop();291+ return true;292+ }293+294+ function handleFrameError(payload) {295+ const session = activeFrameSession;296+ if (!isActiveFrameSession(session, activeFrameSession)) return false;297+ if (session.expectedGeneration === null) {298+ if (session.pendingFrameErrors.length < FRAME_CHANNEL_CAPACITY) {299+ session.pendingFrameErrors.push(payload);300+ return true;301+ }302+ failFrameSession(session, 'pre-connect frame error capacity exceeded');303+ return false;304+ }305+ if (!isCurrentFrameGeneration(payload?.generation, session.expectedGeneration)) return false;306+ return failFrameSession(session, payload?.error || 'Media connection failed');307+ }308+309+ function snapshot(at) {310+ return {311+ video: at === undefined ? videoPipeline.snapshot() : videoPipeline.snapshot(at),312+ generation: activeFrameGeneration,313+ ...transport,314+ adaptiveFeedbackAvailable,315+ adaptiveFeedbackError,316+ adaptiveDecision,317+ };318+ }319+320+ function publishAdaptiveFeedback(video) {321+ return feedbackPublisher.publish({322+ lastSequence: transport.lastMediaSequence,323+ frontendQueueDepth: transport.frontendQueueStats?.depth ?? 0,324+ frontendQueueCapacity: transport.frontendQueueStats?.capacity ?? 4,325+ decoderQueueDepth: video.decoderQueueDepth,326+ decoderQueueCapacity: video.decoderQueueCapacity,327+ presenterQueueDepth: video.presenterQueueDepth,328+ presenterQueueCapacity: video.presenterQueueCapacity,329+ transportDroppedTotal: transport.transportDroppedFrames,330+ frontendDroppedTotal: transport.frontendDroppedFrames,331+ decoderDroppedTotal: video.droppedFrames,332+ presenterDroppedTotal: video.presentationDroppedFrames,333+ // Portal has no clock-synchronized capture-to-delivery measurement yet.334+ // IPC send duration is a different boundary and must not be mislabeled.335+ transportDeliveryP95Ms: null,336+ decodeLatencyP95Ms: video.decodePercentiles.p95,337+ presentationLatencyP95Ms: video.presentPercentiles.p95,338+ resyncActive: video.recovering || transport.frontendResyncStats?.active === true,339+ });340+ }341+342+ return Object.freeze({343+ prepareConnection,344+ openFrameSession,345+ activateFrameSession,346+ closeFrameSession,347+ prepareDisconnect,348+ teardown,349+ requestKeyframe,350+ handleBinaryFrame,351+ handleLegacyFrame,352+ handleFrameStats,353+ handleAdaptiveDecision,354+ handleAdaptiveFeedbackState,355+ handleFrameError,356+ snapshot,357+ publishAdaptiveFeedback,358+ get generation() { return activeFrameGeneration; },359+ get activeSession() { return activeFrameSession; },360+ });361+}
A portal/stream-runtime.test.mjs +337 -0
@@ -0,0 +1,337 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+4+import { FRAME_CHANNEL_CAPACITY } from './frame-session.mjs';5+import { createStreamRuntime } from './stream-runtime.mjs';6+7+function frameEnvelope({ sequence = 11, ptsMicros = 22, payload = [0x65] } = {}) {8+ const buffer = new ArrayBuffer(40 + payload.length);9+ const bytes = new Uint8Array(buffer);10+ const view = new DataView(buffer);11+ bytes.set([0x53, 0x47, 0x46, 0x52]);12+ bytes[4] = 1;13+ bytes[5] = 1;14+ bytes[6] = 0b00000101;15+ view.setUint16(8, 1280, false);16+ view.setUint16(10, 800, false);17+ view.setUint32(12, payload.length, false);18+ view.setBigUint64(16, BigInt(sequence), false);19+ view.setBigUint64(24, 0xffffffffffffffffn, false);20+ view.setBigInt64(32, BigInt(ptsMicros), false);21+ bytes.set(payload, 40);22+ return buffer;23+}24+25+function videoSnapshot(overrides = {}) {26+ return {27+ decoderQueueDepth: 1,28+ decoderQueueCapacity: 2,29+ presenterQueueDepth: 1,30+ presenterQueueCapacity: 2,31+ droppedFrames: 3,32+ presentationDroppedFrames: 4,33+ decodePercentiles: { p95: 5 },34+ presentPercentiles: { p95: 6 },35+ recovering: false,36+ ...overrides,37+ };38+}39+40+function subject({ connected = false } = {}) {41+ const events = [];42+ const video = {43+ processFramePayload(payload, data = null) {44+ events.push({ type: 'process', payload, data });45+ },46+ reset() { events.push({ type: 'video-reset' }); },47+ teardown() { events.push({ type: 'video-teardown' }); },48+ snapshot(at) {49+ events.push({ type: 'video-snapshot', at });50+ return videoSnapshot();51+ },52+ };53+ const publisher = {54+ start(generation, available) {55+ events.push({ type: 'publisher-start', generation, available });56+ },57+ stop() { events.push({ type: 'publisher-stop' }); },58+ publish(snapshot) {59+ events.push({ type: 'publisher-publish', snapshot });60+ return true;61+ },62+ };63+ const logger = {64+ warn(...args) { events.push({ type: 'warn', args }); },65+ error(...args) { events.push({ type: 'error', args }); },66+ };67+ const runtime = createStreamRuntime({68+ invokeCommand(command, args) {69+ events.push({ type: 'invoke', command, args });70+ return Promise.resolve(true);71+ },72+ videoPipeline: video,73+ isConnected: () => connected,74+ disconnect: async () => { events.push({ type: 'disconnect' }); },75+ adaptiveFeedbackPublisher: publisher,76+ logger,77+ });78+ return { runtime, events, video, publisher };79+}80+81+function activate(runtime, session, generation = 7, overrides = {}) {82+ runtime.activateFrameSession(session, {83+ generation,84+ adaptiveFeedbackAvailable: true,85+ adaptiveFeedbackError: null,86+ ...overrides,87+ });88+}89+90+test('connection lifecycle resets stream state while close preserves the adaptive error quirk', () => {91+ const { runtime, events } = subject();92+ runtime.prepareConnection();93+ const session = runtime.openFrameSession();94+ activate(runtime, session, 7, {95+ adaptiveFeedbackAvailable: false,96+ adaptiveFeedbackError: 'not supported',97+ });98+ assert.equal(runtime.snapshot().adaptiveFeedbackError, 'not supported');99+100+ runtime.closeFrameSession(session);101+ assert.equal(session.closing, true);102+ assert.equal(runtime.generation, null);103+ assert.equal(runtime.snapshot().adaptiveFeedbackAvailable, false);104+ assert.equal(runtime.snapshot().adaptiveFeedbackError, 'not supported');105+106+ runtime.prepareConnection();107+ const reset = runtime.snapshot(123);108+ assert.equal(reset.adaptiveFeedbackError, null);109+ assert.equal(reset.transportDroppedFrames, 0);110+ assert.equal(reset.streamPathMode, 'unknown');111+ assert.ok(events.some((event) => event.type === 'video-reset'));112+113+ runtime.prepareDisconnect();114+ assert.equal(runtime.snapshot().adaptiveFeedbackError, null);115+ runtime.teardown();116+ assert.ok(events.some((event) => event.type === 'video-teardown'));117+});118+119+test('closing a partially constructed attempt falls back to the active frame session', () => {120+ const { runtime } = subject();121+ runtime.prepareConnection();122+ const session = runtime.openFrameSession();123+124+ runtime.closeFrameSession();125+126+ assert.equal(session.closing, true);127+ assert.equal(runtime.activeSession, null);128+ assert.equal(runtime.generation, null);129+});130+131+test('activation flushes binary and legacy acknowledgments before staged legacy decode', () => {132+ const { runtime, events } = subject();133+ runtime.prepareConnection();134+ const session = runtime.openFrameSession();135+ assert.equal(runtime.handleBinaryFrame(frameEnvelope(), session), true);136+ assert.equal(runtime.handleLegacyFrame({ generation: 7, sequence: 12, data: 'jpeg' }), false);137+138+ events.length = 0;139+ activate(runtime, session, 7);140+ assert.deepEqual(events.map((event) => event.type), [141+ 'invoke',142+ 'invoke',143+ 'process',144+ 'publisher-start',145+ ]);146+ assert.deepEqual(events.slice(0, 2).map((event) => event.args.generation), [7, 7]);147+ assert.equal(events[2].payload.sequence, 12);148+});149+150+test('a current pending frame error aborts activation after acknowledgments and before legacy decode', () => {151+ const { runtime, events } = subject();152+ runtime.prepareConnection();153+ const session = runtime.openFrameSession();154+ runtime.handleLegacyFrame({ generation: 9, sequence: 1, data: 'jpeg' });155+ runtime.handleFrameError({ generation: 9, error: 'native failed' });156+ events.length = 0;157+158+ assert.throws(() => activate(runtime, session, 9), /native failed/);159+ assert.deepEqual(events.map((event) => event.type), ['invoke']);160+ assert.equal(runtime.generation, 9);161+});162+163+test('active binary delivery acknowledges before decode and malformed delivery still acknowledges', () => {164+ const { runtime, events } = subject({ connected: true });165+ runtime.prepareConnection();166+ const session = runtime.openFrameSession();167+ activate(runtime, session, 5);168+ events.length = 0;169+170+ assert.equal(runtime.handleBinaryFrame(frameEnvelope({ sequence: 31 }), session), true);171+ assert.deepEqual(events.slice(0, 2).map((event) => event.type), ['invoke', 'process']);172+ assert.equal(events[0].args.generation, 5);173+ assert.equal(events[1].payload.sequence, 31);174+ assert.ok(events[1].data instanceof Uint8Array);175+176+ events.length = 0;177+ assert.equal(runtime.handleBinaryFrame(new ArrayBuffer(1), session), false);178+ assert.equal(session.failed, true);179+ assert.match(session.failureDetail, /shorter than its header/);180+ assert.deepEqual(events.map((event) => event.type), ['error', 'disconnect', 'invoke']);181+ assert.equal(events[2].args.generation, 5);182+});183+184+test('legacy deliveries acknowledge every active generation but decode only the current one', () => {185+ const { runtime, events } = subject();186+ runtime.prepareConnection();187+ const session = runtime.openFrameSession();188+ activate(runtime, session, 8);189+ events.length = 0;190+191+ assert.equal(runtime.handleLegacyFrame({ generation: 7, sequence: 1 }), false);192+ assert.equal(runtime.handleLegacyFrame({ generation: 8, sequence: 2 }), true);193+ assert.deepEqual(events.map((event) => event.type), ['invoke', 'invoke', 'process']);194+ assert.deepEqual(events.slice(0, 2).map((event) => event.args.generation), [7, 8]);195+});196+197+test('pre-connect frame errors are bounded and stale active errors are ignored', () => {198+ const { runtime, events } = subject({ connected: true });199+ runtime.prepareConnection();200+ const session = runtime.openFrameSession();201+ for (let index = 0; index < FRAME_CHANNEL_CAPACITY; index++) {202+ assert.equal(runtime.handleFrameError({ generation: 20 + index, error: `${index}` }), true);203+ }204+ assert.equal(runtime.handleFrameError({ generation: 99, error: 'overflow' }), false);205+ assert.equal(session.failed, true);206+ assert.ok(events.some((event) => event.type === 'disconnect'));207+208+ runtime.prepareConnection();209+ const next = runtime.openFrameSession();210+ activate(runtime, next, 30);211+ assert.equal(runtime.handleFrameError({ generation: 29, error: 'stale' }), false);212+ assert.equal(next.failed, false);213+});214+215+test('frame stats keep legacy FPS fallbacks and reset to exact defaults', () => {216+ const { runtime } = subject();217+ runtime.prepareConnection();218+ const session = runtime.openFrameSession();219+ activate(runtime, session, 4);220+ assert.equal(runtime.handleFrameStats({ generation: 3, fps: 1 }), false);221+ assert.equal(runtime.handleFrameStats({222+ generation: 4,223+ stats_version: 3,224+ sequence_dropped_total: 6,225+ frontend_dropped_total: 7,226+ transport_object_dropped_total: 5,227+ transport_late_object_dropped_total: 2,228+ frontend_queue_dropped_total: 4,229+ frontend_resync_dropped_total: 3,230+ frontend_queue_depth: 1,231+ frontend_queue_peak: 2,232+ frontend_queue_capacity: 4,233+ frontend_resync_episode_total: 1,234+ frontend_resync_duration_ms_total: 12,235+ frontend_resync_duration_ms_max: 12,236+ frontend_resync_active: true,237+ frontend_resync_duration_ms_current: 2,238+ transport_interval_sample_count: 0,239+ frontend_ipc_send_duration_sample_count: 0,240+ timing_window_ms: 1000,241+ timing_sample_capacity: 120,242+ path_mode: 'future-mode-is-kept-verbatim',243+ path_rtt_ms: 8.5,244+ sequence: 41,245+ fps: 44,246+ }), true);247+ const snapshot = runtime.snapshot();248+ assert.equal(snapshot.transportDroppedFrames, 6);249+ assert.equal(snapshot.transportObjectDroppedFrames, 5);250+ assert.equal(snapshot.transportLateObjectDroppedFrames, 2);251+ assert.equal(snapshot.frontendDroppedFrames, 7);252+ assert.deepEqual(snapshot.frontendQueueStats, { depth: 1, peak: 2, capacity: 4 });253+ assert.equal(snapshot.frontendResyncStats.active, true);254+ assert.equal(snapshot.streamPathMode, 'future-mode-is-kept-verbatim');255+ assert.equal(snapshot.streamRttMs, 8.5);256+ assert.equal(snapshot.lastMediaSequence, 41);257+ assert.equal(snapshot.lastTransportFps, 44);258+ assert.equal(snapshot.lastFrontendSendFps, 44);259+260+ runtime.prepareConnection();261+ const reset = runtime.snapshot();262+ assert.equal(reset.lastTransportFps, 0);263+ assert.equal(reset.lastFrontendSendFps, 0);264+ assert.equal(reset.frontendQueueStats, null);265+});266+267+test('adaptive diagnostics reject stale generations and stop failed feedback', () => {268+ const { runtime, events } = subject();269+ runtime.prepareConnection();270+ const session = runtime.openFrameSession();271+ activate(runtime, session, 12);272+ const decision = {273+ generation: 12,274+ decision: {275+ decision_id: 1,276+ report_id: 2,277+ target_kbps: 8000,278+ floor_kbps: 2000,279+ ceiling_kbps: 12000,280+ state: 'decrease',281+ reasons: ['queue'],282+ applied: false,283+ },284+ };285+ assert.equal(runtime.handleAdaptiveDecision({ ...decision, generation: 11 }), false);286+ assert.equal(runtime.handleAdaptiveDecision(decision), true);287+ assert.equal(runtime.snapshot().adaptiveDecision.target_kbps, 8000);288+ assert.equal(runtime.handleAdaptiveFeedbackState({ generation: 11, available: false }), false);289+ assert.equal(runtime.handleAdaptiveFeedbackState({ generation: 12, available: false }), true);290+ assert.equal(runtime.snapshot().adaptiveFeedbackAvailable, false);291+ assert.equal(runtime.snapshot().adaptiveFeedbackError, 'feedback stream closed');292+ assert.ok(events.some((event) => event.type === 'publisher-stop'));293+294+ assert.equal(runtime.handleAdaptiveDecision({ generation: 12, decision: null }), false);295+ assert.equal(events.at(-1).type, 'warn');296+});297+298+test('adaptive feedback preserves bounded queue fallbacks and pressure signals', () => {299+ const { runtime, events } = subject();300+ runtime.prepareConnection();301+ const session = runtime.openFrameSession();302+ activate(runtime, session, 6);303+ runtime.handleFrameStats({304+ generation: 6,305+ stats_version: 2,306+ sequence_dropped_total: 10,307+ frontend_dropped_total: 11,308+ frontend_resync_active: true,309+ frontend_resync_episode_total: 1,310+ frontend_resync_duration_ms_total: 3,311+ frontend_resync_duration_ms_max: 3,312+ frontend_resync_duration_ms_current: 1,313+ fps: 60,314+ sequence: 99,315+ });316+ events.length = 0;317+ const video = videoSnapshot({ recovering: false });318+ assert.equal(runtime.publishAdaptiveFeedback(video), true);319+ const published = events[0].snapshot;320+ assert.deepEqual(published, {321+ lastSequence: 99,322+ frontendQueueDepth: 0,323+ frontendQueueCapacity: 4,324+ decoderQueueDepth: 1,325+ decoderQueueCapacity: 2,326+ presenterQueueDepth: 1,327+ presenterQueueCapacity: 2,328+ transportDroppedTotal: 10,329+ frontendDroppedTotal: 11,330+ decoderDroppedTotal: 3,331+ presenterDroppedTotal: 4,332+ transportDeliveryP95Ms: null,333+ decodeLatencyP95Ms: 5,334+ presentationLatencyP95Ms: 6,335+ resyncActive: true,336+ });337+});