fix(k1): harden live handoff and camera recovery
This commit is contained in:
@@ -4,13 +4,28 @@ export type LiveViewerDiagnosticEventCode =
|
||||
| "live_receiver_recovered"
|
||||
| "live_receiver_recovery_exhausted"
|
||||
| "live_receiver_active_store_admitted"
|
||||
| "live_receiver_error";
|
||||
| "live_receiver_error"
|
||||
| "live_camera_transport_restart_requested"
|
||||
| "live_camera_transport_playing";
|
||||
|
||||
export type LiveViewerFailureStage =
|
||||
| "recording-open-timeout"
|
||||
| "viewer-start"
|
||||
| "module-load"
|
||||
| "receiver-stalled";
|
||||
| "receiver-stalled"
|
||||
| "camera-first-media-timeout"
|
||||
| "camera-first-playable-timeout"
|
||||
| "camera-queue-capacity"
|
||||
| "camera-append-quota"
|
||||
| "camera-append-error"
|
||||
| "camera-source-buffer-error"
|
||||
| "camera-fragment-decode"
|
||||
| "camera-websocket-error"
|
||||
| "camera-websocket-close"
|
||||
| "camera-heartbeat-reopen"
|
||||
| "camera-visibility-reopen"
|
||||
| "camera-network-online-reopen"
|
||||
| "camera-page-restore-reopen";
|
||||
|
||||
export interface LiveViewerDiagnostic {
|
||||
eventCode: LiveViewerDiagnosticEventCode;
|
||||
@@ -20,6 +35,14 @@ export interface LiveViewerDiagnostic {
|
||||
viewerRangeMaxNs?: number | null;
|
||||
stalledForMs?: number | null;
|
||||
recoveryAttempt?: number | null;
|
||||
cameraQueueBytes?: number | null;
|
||||
cameraQueueSegments?: number | null;
|
||||
cameraRetryCount?: number | null;
|
||||
websocketCloseCode?: number | null;
|
||||
transportEpoch?: number | null;
|
||||
cameraAppendErrorName?: string | null;
|
||||
cameraMediaSourceState?: "closed" | "open" | "ended" | null;
|
||||
cameraVideoErrorCode?: number | null;
|
||||
}
|
||||
|
||||
export interface LiveViewerLineage {
|
||||
@@ -69,7 +92,6 @@ const DEVELOPMENT_UI_BUILD_ID = "development";
|
||||
const SAFE_STREAM_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const HASHED_UI_BUILD_ID = /^\/assets\/[A-Za-z0-9._/-]+-[A-Za-z0-9_-]{8,}\.js$/;
|
||||
const UI_BUILD_CHECK_INTERVAL_MILLISECONDS = 15_000;
|
||||
const UI_BUILD_RELOAD_DELAY_MILLISECONDS = 50;
|
||||
|
||||
let documentInstanceId: string | null = null;
|
||||
let sharedUiBuildCoordinator: UiBuildStaleCoordinator | null = null;
|
||||
@@ -172,32 +194,45 @@ export function liveViewerDiagnosticBody(
|
||||
...(safeInteger(event.recoveryAttempt) === undefined
|
||||
? {}
|
||||
: { recovery_attempt: safeInteger(event.recoveryAttempt) }),
|
||||
...(safeInteger(event.cameraQueueBytes) === undefined
|
||||
? {}
|
||||
: { camera_queue_bytes: safeInteger(event.cameraQueueBytes) }),
|
||||
...(safeInteger(event.cameraQueueSegments) === undefined
|
||||
? {}
|
||||
: { camera_queue_segments: safeInteger(event.cameraQueueSegments) }),
|
||||
...(safeInteger(event.cameraRetryCount) === undefined
|
||||
? {}
|
||||
: { camera_retry_count: safeInteger(event.cameraRetryCount) }),
|
||||
...(safeInteger(event.websocketCloseCode) === undefined
|
||||
? {}
|
||||
: { websocket_close_code: safeInteger(event.websocketCloseCode) }),
|
||||
...(safeInteger(event.transportEpoch) === undefined
|
||||
? {}
|
||||
: { transport_epoch: safeInteger(event.transportEpoch) }),
|
||||
...(event.cameraAppendErrorName
|
||||
? { camera_append_error_name: event.cameraAppendErrorName }
|
||||
: {}),
|
||||
...(event.cameraMediaSourceState
|
||||
? { camera_media_source_state: event.cameraMediaSourceState }
|
||||
: {}),
|
||||
...(safeInteger(event.cameraVideoErrorCode) === undefined
|
||||
? {}
|
||||
: { camera_video_error_code: safeInteger(event.cameraVideoErrorCode) }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createUiBuildStaleCoordinator({
|
||||
scheduleReload,
|
||||
reload,
|
||||
}: {
|
||||
scheduleReload: (callback: () => void, delayMilliseconds: number) => void;
|
||||
reload: () => void;
|
||||
}): UiBuildStaleCoordinator {
|
||||
const listeners = new Set<(event: StaleUiBuild) => void>();
|
||||
export function createUiBuildStaleCoordinator(): UiBuildStaleCoordinator {
|
||||
let staleEvent: StaleUiBuild | null = null;
|
||||
let reloadScheduled = false;
|
||||
return {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
if (staleEvent) listener(staleEvent);
|
||||
return () => listeners.delete(listener);
|
||||
subscribe(_listener) {
|
||||
// Build drift is diagnostic-only. The loaded document and its local
|
||||
// Rerun/camera transports remain authoritative until normal unmount or
|
||||
// their existing bounded recovery replaces them.
|
||||
return () => undefined;
|
||||
},
|
||||
report(event) {
|
||||
if (event.loadedUiBuildId === event.expectedUiBuildId || staleEvent) return;
|
||||
staleEvent = event;
|
||||
for (const listener of [...listeners]) listener(event);
|
||||
if (reloadScheduled) return;
|
||||
reloadScheduled = true;
|
||||
scheduleReload(reload, UI_BUILD_RELOAD_DELAY_MILLISECONDS);
|
||||
},
|
||||
stale() {
|
||||
return staleEvent !== null;
|
||||
@@ -206,12 +241,7 @@ export function createUiBuildStaleCoordinator({
|
||||
}
|
||||
|
||||
function browserUiBuildCoordinator(): UiBuildStaleCoordinator {
|
||||
sharedUiBuildCoordinator ??= createUiBuildStaleCoordinator({
|
||||
scheduleReload: (callback, delayMilliseconds) => {
|
||||
window.setTimeout(callback, delayMilliseconds);
|
||||
},
|
||||
reload: () => window.location.reload(),
|
||||
});
|
||||
sharedUiBuildCoordinator ??= createUiBuildStaleCoordinator();
|
||||
return sharedUiBuildCoordinator;
|
||||
}
|
||||
|
||||
@@ -254,10 +284,17 @@ export function verifyLiveViewerClientBuild(
|
||||
lineage: Pick<LiveViewerLineage, "uiBuildId">,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
if (signal?.aborted || lineage.uiBuildId === DEVELOPMENT_UI_BUILD_ID) return;
|
||||
if (
|
||||
signal?.aborted ||
|
||||
lineage.uiBuildId === DEVELOPMENT_UI_BUILD_ID ||
|
||||
browserUiBuildCoordinator().stale()
|
||||
) return;
|
||||
void fetch("/api/v1/viewer/client-contract", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-MissionCore-UI-Build": lineage.uiBuildId,
|
||||
},
|
||||
cache: "no-store",
|
||||
signal,
|
||||
}).then(async (response) => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { MissionRuntimeState } from "../runtime/contracts";
|
||||
|
||||
export function viewerSettingsTargetIdentity(
|
||||
state: MissionRuntimeState | null,
|
||||
): string {
|
||||
const deviceSessionId = state?.deviceSession?.sessionId?.trim();
|
||||
const sessionDeviceId = state?.deviceSession?.deviceId?.trim();
|
||||
if (deviceSessionId || sessionDeviceId) {
|
||||
return ["device-session", deviceSessionId, sessionDeviceId]
|
||||
.filter(Boolean)
|
||||
.join(":");
|
||||
}
|
||||
|
||||
return [
|
||||
state?.activeDevice?.pluginId,
|
||||
state?.activeDevice?.modelId,
|
||||
state?.activeDevice?.instanceId,
|
||||
].filter(Boolean).join(":") || "local-runtime";
|
||||
}
|
||||
Reference in New Issue
Block a user