wip(k1): checkpoint connection recovery rewrite

Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
DCCONSTRUCTIONS
2026-08-14 14:57:50 +03:00
parent aff331082f
commit 0ca7316a24
157 changed files with 152962 additions and 4036 deletions
@@ -23,6 +23,64 @@ interface DevicePluginHostValue {
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
export const DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY =
"nodedc.mission-core.device-model-selection.v1";
export interface DeviceModelSelectionStorage {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
}
function browserDeviceModelSelectionStorage(): DeviceModelSelectionStorage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
/**
* Restore only a model that is present in the current reviewed registry.
* Removed/renamed models and malformed browser values fail closed to the
* picker and are cleared so a later remount cannot keep retrying stale state.
*/
export function restorePersistedDeviceModelId(
registry: DevicePluginRegistry,
storage: DeviceModelSelectionStorage | null,
): string | null {
if (!storage) return null;
try {
const stored = storage.getItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
const modelId = stored?.trim() || null;
if (modelId && registry.resolveModel(modelId)) return modelId;
if (stored !== null) {
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
}
return null;
} catch {
return null;
}
}
/** Persist only an already-admitted host transition; browser storage is never authority. */
export function commitPersistedDeviceModelId(
modelId: string | null,
storage: DeviceModelSelectionStorage | null,
): void {
if (!storage) return;
try {
if (modelId === null) {
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
return;
}
storage.setItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY, modelId);
} catch {
// A denied/full localStorage must not block the in-memory host transition.
}
}
export function DevicePluginHostProvider({
plugins,
children,
@@ -31,7 +89,10 @@ export function DevicePluginHostProvider({
children: ReactNode;
}) {
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
restorePersistedDeviceModelId(registry, selectionStorage)
);
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
const transitionInFlight = useRef(false);
@@ -56,7 +117,12 @@ export function DevicePluginHostProvider({
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
}
if (nextModelId === selectedModelId) return true;
if (nextModelId === selectedModelId) {
// `clearSelection()` must clear a stale persisted value even when the
// current in-memory selection is already empty.
commitPersistedDeviceModelId(nextModelId, selectionStorage);
return true;
}
transitionInFlight.current = true;
setSelectionTransitionPending(true);
@@ -88,13 +154,14 @@ export function DevicePluginHostProvider({
}
}
setSelectedModelId(nextModelId);
commitPersistedDeviceModelId(nextModelId, selectionStorage);
return true;
} finally {
transitionInFlight.current = false;
setSelectionTransitionPending(false);
}
},
[registry, selectedModelId],
[registry, selectedModelId, selectionStorage],
);
const deactivationRegistrars = useMemo(
@@ -0,0 +1,204 @@
import type {
ObservationSourceDelivery,
ObservationSourceDescriptor,
} from "../runtime/contracts";
export const CAMERA_WAKE_GAP_MS = 5_000;
export const CAMERA_HIDDEN_REOPEN_MS = 1_000;
export const CAMERA_REOPEN_COOLDOWN_MS = 1_000;
export type CameraPlaybackRecoveryEvent =
| { type: "document-hidden" }
| { type: "document-visible" }
| { type: "network-online" }
| { type: "page-restore"; persisted: boolean }
| { type: "heartbeat" };
export interface CameraPlaybackRecoveryState {
authorityIdentity: string;
hiddenAt: number | null;
lastObservedAt: number;
lastReopenAt: number | null;
}
export interface CameraPlaybackRecoveryContext {
activeAuthorityIdentity: string | null;
now: number;
documentVisible: boolean;
networkOnline: boolean;
}
export interface CameraPlaybackRecoveryDecision {
state: CameraPlaybackRecoveryState;
reopen: boolean;
}
export function cameraTransportCallbackIsCurrent(
activeEpoch: number,
callbackEpoch: number,
disposed: boolean,
): boolean {
return !disposed && activeEpoch === callbackEpoch;
}
export function cameraBrowserTransportIdentity(
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" },
authorityIdentity: string | null,
): string {
return JSON.stringify([
delivery.id,
delivery.url,
delivery.mediaType,
authorityIdentity,
]);
}
function trimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function positiveInteger(value: unknown): value is number {
return Number.isInteger(value) && (value as number) > 0;
}
/**
* Bind browser-only recovery to one exact, server-authoritative live camera.
* A point-cloud recovery, retained delivery or selected camera without the
* active acquisition/session tuple must not reopen a decoder.
*/
export function liveCameraPlaybackAuthorityIdentity(
source: ObservationSourceDescriptor,
): string | null {
const delivery = source.delivery;
const activation = source.activation;
const deviceId = trimmedString(source.binding?.deviceId);
const deviceSessionId = trimmedString(source.binding?.deviceSessionId);
const acquisitionId = trimmedString(source.binding?.acquisitionId);
const sourceId = trimmedString(source.sourceId);
const descriptorId = trimmedString(source.id);
const activationGroupId = trimmedString(activation?.groupId);
const deliveryId = trimmedString(delivery?.id);
const deliveryUrl = trimmedString(delivery?.url);
const presentationLease = source.presentationLease;
const recoveryPresentation = presentationLease?.kind === "active-stream-recovery";
const recoveryLeaseValid = Boolean(
recoveryPresentation
&& trimmedString(presentationLease.runtimeId)
&& trimmedString(presentationLease.acquisitionId) === acquisitionId
&& positiveInteger(presentationLease.acquisitionStateRevision)
&& positiveInteger(presentationLease.producerGeneration)
&& positiveInteger(presentationLease.recoveryGeneration),
);
const availabilityAuthoritative = source.availability === "streaming"
|| (
recoveryLeaseValid
&& (source.availability === "connecting" || source.availability === "degraded")
);
const mediaType = delivery?.kind === "mse-fmp4-websocket"
? trimmedString(delivery.mediaType)
: "";
if (
source.modality !== "video"
|| !availabilityAuthoritative
|| (presentationLease != null && !recoveryLeaseValid)
|| delivery?.kind !== "mse-fmp4-websocket"
|| activation?.selected !== true
|| activation.maxActive !== 1
|| !activationGroupId
|| !descriptorId
|| !sourceId
|| !deliveryId
|| !deliveryUrl
|| !/^video\/mp4(?:\s*;|$)/i.test(mediaType)
|| !deviceId
|| !deviceSessionId
|| !acquisitionId
) {
return null;
}
return JSON.stringify([
descriptorId,
sourceId,
deviceId,
deviceSessionId,
acquisitionId,
activationGroupId,
activation.maxActive,
deliveryId,
deliveryUrl,
mediaType,
recoveryLeaseValid ? [
presentationLease?.runtimeId,
presentationLease?.acquisitionStateRevision,
presentationLease?.producerGeneration,
presentationLease?.recoveryGeneration,
] : null,
]);
}
export function initialCameraPlaybackRecoveryState(
authorityIdentity: string,
now: number,
): CameraPlaybackRecoveryState {
return {
authorityIdentity,
hiddenAt: null,
lastObservedAt: now,
lastReopenAt: null,
};
}
/**
* Reduce browser lifecycle signals without performing I/O. `reopen=true`
* means replace the current browser WebSocket + MSE pair; it never means a
* device START/STOP, camera selection or network mutation.
*/
export function reduceCameraPlaybackRecovery(
current: CameraPlaybackRecoveryState,
event: CameraPlaybackRecoveryEvent,
context: CameraPlaybackRecoveryContext,
): CameraPlaybackRecoveryDecision {
const { now } = context;
if (event.type === "document-hidden") {
return {
state: {
...current,
hiddenAt: now,
lastObservedAt: now,
},
reopen: false,
};
}
const authorityCurrent = Boolean(
context.activeAuthorityIdentity
&& context.activeAuthorityIdentity === current.authorityIdentity,
);
const documentReady = context.documentVisible && context.networkOnline;
let candidate = false;
let hiddenAt = current.hiddenAt;
if (event.type === "document-visible") {
candidate = hiddenAt !== null && now - hiddenAt >= CAMERA_HIDDEN_REOPEN_MS;
hiddenAt = null;
} else if (event.type === "network-online") {
candidate = true;
} else if (event.type === "page-restore") {
candidate = event.persisted;
} else if (event.type === "heartbeat") {
candidate = now - current.lastObservedAt >= CAMERA_WAKE_GAP_MS;
}
const outsideCooldown = current.lastReopenAt === null
|| now - current.lastReopenAt >= CAMERA_REOPEN_COOLDOWN_MS;
const reopen = candidate && authorityCurrent && documentReady && outsideCooldown;
return {
state: {
...current,
hiddenAt,
lastObservedAt: now,
lastReopenAt: reopen ? now : current.lastReopenAt,
},
reopen,
};
}
@@ -1,3 +1,8 @@
import type {
ObservationSourceDescriptor,
SpatialSourceDescriptor,
} from "../runtime/contracts";
export interface LiveReceiverWatchdogState {
lastBackendActivitySequence: number | null;
lastViewerRangeMaxNs: number | null;
@@ -19,16 +24,163 @@ export interface LiveReceiverRecoveryState {
awaitingRecovery: boolean;
}
export type LiveReceiverRecoverySignal = "retry" | "exhausted";
export interface LiveReceiverOpenWatchdogState {
lastBackendActivitySequence: number | null;
openedAtMs: number;
}
export type LiveReceiverOpenWatchdogSignal =
| "wait-for-store"
| "refresh-receiver"
| "restart-receiver";
export interface LiveReceiverOpenWatchdogResult {
state: LiveReceiverOpenWatchdogState;
recoveryState: LiveReceiverRecoveryState;
signal: LiveReceiverOpenWatchdogSignal;
openForMs: number;
}
export type LiveReceiverRecoverySignal = "retry" | "exhausted" | "stale";
export interface LiveReceiverRecoveryResult {
state: LiveReceiverRecoveryState;
signal: LiveReceiverRecoverySignal;
attempt: number;
delayMs: number | null;
}
export interface LiveReceiverRecoveryRequest {
activeAuthorityIdentity: string | null;
expectedAuthorityIdentity: string | null;
disposed?: boolean;
maxAttempts?: number;
}
export const LIVE_RECEIVER_STALL_THRESHOLD_MS = 5_000;
export const LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS = 3;
// The bridge publishes its URL only after StoreInfo, blueprint and static
// scene data have been flushed. A receiver that still has not admitted that
// store after one operator-visible four-second window is wedged, not merely
// slow; keeping it for 48 seconds made a healthy live scan look blank.
export const LIVE_RECEIVER_OPEN_MAX_AGE_MS = 4_000;
const LIVE_RECEIVER_RECOVERY_DELAYS_MS = [400, 1_000, 2_000, 5_000] as const;
export function liveReceiverRecoveryRetryDelay(attempt: number): number {
const normalizedAttempt = Number.isSafeInteger(attempt) && attempt > 0 ? attempt : 1;
return LIVE_RECEIVER_RECOVERY_DELAYS_MS[
Math.min(normalizedAttempt - 1, LIVE_RECEIVER_RECOVERY_DELAYS_MS.length - 1)
];
}
function trimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function positiveInteger(value: unknown): value is number {
return Number.isInteger(value) && (value as number) > 0;
}
/**
* Bind durable browser-only Rerun recovery to one exact authoritative spatial
* presentation. A healthy live descriptor is fenced by its acquisition and
* session tuple; a reconnecting/degraded descriptor additionally requires the
* server-issued active-stream-recovery generation lease.
*/
export function liveRerunRecoveryAuthorityIdentity(
source: ObservationSourceDescriptor | null | undefined,
spatialSource: SpatialSourceDescriptor | null | undefined,
): string | null {
if (!source || !spatialSource) return null;
const descriptorId = trimmedString(source.id);
const sourceId = trimmedString(source.sourceId);
const semanticChannelId = trimmedString(source.semanticChannelId);
const previewUrl = trimmedString(source.previewUrl);
const spatialId = trimmedString(spatialSource.id);
const spatialUrl = trimmedString(spatialSource.url);
const deviceId = trimmedString(source.binding?.deviceId);
const deviceSessionId = trimmedString(source.binding?.deviceSessionId);
const acquisitionId = trimmedString(source.binding?.acquisitionId);
const pluginId = trimmedString(source.provider?.pluginId);
const pluginVersion = trimmedString(source.provider?.pluginVersion);
const modelId = trimmedString(source.provider?.modelId);
const compatibilityProfileId = trimmedString(source.provider?.compatibilityProfileId);
const clockId = trimmedString(source.capabilities?.clockId);
const presentationLease = source.presentationLease;
const recoveryPresentation = presentationLease?.kind === "active-stream-recovery";
const recoveryLeaseValid = Boolean(
recoveryPresentation
&& trimmedString(presentationLease.runtimeId)
&& trimmedString(presentationLease.acquisitionId) === acquisitionId
&& positiveInteger(presentationLease.acquisitionStateRevision)
&& positiveInteger(presentationLease.producerGeneration)
&& positiveInteger(presentationLease.recoveryGeneration),
);
const availabilityAuthoritative = source.availability === "streaming"
|| (
recoveryLeaseValid
&& (source.availability === "connecting" || source.availability === "degraded")
);
if (
source.modality !== "point-cloud"
|| source.transport !== "rerun-grpc"
|| spatialSource.kind !== "rerun-grpc"
|| source.capabilities.timelineMode !== "live-only"
|| source.capabilities.spatialRegistration !== "native"
|| source.delivery != null
|| !availabilityAuthoritative
|| (presentationLease != null && !recoveryLeaseValid)
|| !descriptorId
|| !sourceId
|| !semanticChannelId
|| !previewUrl
|| previewUrl !== spatialUrl
|| !spatialId
|| spatialId !== acquisitionId
|| !deviceId
|| !deviceSessionId
|| !acquisitionId
|| clockId !== acquisitionId
|| !pluginId
|| !pluginVersion
|| !modelId
|| !compatibilityProfileId
) {
return null;
}
return JSON.stringify([
descriptorId,
sourceId,
semanticChannelId,
deviceId,
deviceSessionId,
acquisitionId,
spatialId,
spatialUrl,
pluginId,
pluginVersion,
modelId,
compatibilityProfileId,
recoveryLeaseValid ? [
presentationLease?.runtimeId,
presentationLease?.acquisitionStateRevision,
presentationLease?.producerGeneration,
presentationLease?.recoveryGeneration,
] : null,
]);
}
export function liveReceiverRecoveryAuthorityIsCurrent(
activeAuthorityIdentity: string | null,
expectedAuthorityIdentity: string | null,
disposed = false,
): boolean {
return Boolean(
!disposed
&& expectedAuthorityIdentity
&& activeAuthorityIdentity === expectedAuthorityIdentity,
);
}
export function initialLiveReceiverWatchdogState(): LiveReceiverWatchdogState {
return {
@@ -47,16 +199,104 @@ export function initialLiveReceiverRecoveryState(): LiveReceiverRecoveryState {
};
}
function validBackendActivitySequence(value: number | null | undefined): number | null {
return Number.isSafeInteger(value) && (value ?? -1) >= 0 ? (value as number) : null;
}
export function initialLiveReceiverOpenWatchdogState(
backendActivitySequence: number | null = null,
openedAtMs = Date.now(),
): LiveReceiverOpenWatchdogState {
return {
lastBackendActivitySequence: validBackendActivitySequence(backendActivitySequence),
openedAtMs: Number.isFinite(openedAtMs) ? openedAtMs : Date.now(),
};
}
/**
* Keep one still-opening Rerun receiver alive while the backend is proving
* fresh publication progress. Recreating the WASM receiver on a fixed timer
* can repeatedly discard an otherwise healthy late StoreInfo replay. Rolling
* patience is nevertheless bounded: a receiver that has not admitted a store
* by the absolute open-age limit is refreshed without consuming the recovery
* budget. A true lack of backend progress delegates to the bounded restart
* policy. Recovery debt is cleared only after viewer admission, never merely
* because the backend counter advanced.
*/
export function advanceLiveReceiverOpenWatchdog(
current: LiveReceiverOpenWatchdogState,
recoveryState: LiveReceiverRecoveryState,
backendActivitySequence: number | null,
nowMs = Date.now(),
maxOpenAgeMs = LIVE_RECEIVER_OPEN_MAX_AGE_MS,
): LiveReceiverOpenWatchdogResult {
const sequence = validBackendActivitySequence(backendActivitySequence);
const previous = current.lastBackendActivitySequence;
const backendAdvanced = sequence !== null && (
(previous === null && sequence > 0) ||
(previous !== null && sequence > previous)
);
const state = {
lastBackendActivitySequence: sequence === null
? previous
: Math.max(sequence, previous ?? 0),
openedAtMs: current.openedAtMs,
};
const openForMs = Math.max(0, nowMs - current.openedAtMs);
if (backendAdvanced) {
return {
state,
recoveryState,
signal: openForMs >= maxOpenAgeMs
? "refresh-receiver"
: "wait-for-store",
openForMs,
};
}
return {
state,
recoveryState,
signal: "restart-receiver",
openForMs,
};
}
/**
* Bound viewer-only restarts independently from scanner and acquisition
* lifecycle. The caller may dispose and recreate the browser receiver, but
* must never issue START/STOP or reconnect the physical device.
* lifecycle unless the caller proves that one exact live/recovery authority
* is still current. Under that fence retries remain durable and use a capped
* delay while the attempt counter stays truthful. The caller may dispose and
* recreate only the browser receiver; it must never issue START/STOP or
* reconnect the physical device.
*/
export function requestLiveReceiverRecovery(
current: LiveReceiverRecoveryState,
maxAttempts = LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
request?: LiveReceiverRecoveryRequest,
): LiveReceiverRecoveryResult {
if (current.attempts >= maxAttempts) {
const maxAttempts = positiveInteger(request?.maxAttempts)
? request.maxAttempts
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS;
const expectedAuthorityIdentity = request?.expectedAuthorityIdentity ?? null;
const exactAuthorityCurrent = liveReceiverRecoveryAuthorityIsCurrent(
request?.activeAuthorityIdentity ?? null,
expectedAuthorityIdentity,
request?.disposed === true,
);
if (
request
&& (
request.disposed === true
|| request.activeAuthorityIdentity !== expectedAuthorityIdentity
)
) {
return {
state: current,
signal: "stale",
attempt: current.attempts,
delayMs: null,
};
}
if (current.attempts >= maxAttempts && !exactAuthorityCurrent) {
return {
state: {
attempts: current.attempts,
@@ -64,6 +304,7 @@ export function requestLiveReceiverRecovery(
},
signal: "exhausted",
attempt: current.attempts,
delayMs: null,
};
}
const attempt = current.attempts + 1;
@@ -74,6 +315,7 @@ export function requestLiveReceiverRecovery(
},
signal: "retry",
attempt,
delayMs: exactAuthorityCurrent ? liveReceiverRecoveryRetryDelay(attempt) : 0,
};
}
@@ -91,10 +333,7 @@ export function advanceLiveReceiverWatchdog(
},
thresholdMs = LIVE_RECEIVER_STALL_THRESHOLD_MS,
): LiveReceiverWatchdogResult {
const backendSequence = Number.isSafeInteger(sample.backendActivitySequence) &&
(sample.backendActivitySequence ?? -1) >= 0
? sample.backendActivitySequence
: null;
const backendSequence = validBackendActivitySequence(sample.backendActivitySequence);
const viewerRange = Number.isFinite(sample.viewerRangeMaxNs) &&
(sample.viewerRangeMaxNs ?? -1) >= 0
? sample.viewerRangeMaxNs
@@ -22,7 +22,66 @@ export interface LiveViewerDiagnostic {
recoveryAttempt?: number | null;
}
export interface LiveViewerLineage {
uiBuildId: string;
documentInstanceId: string;
viewerInstanceId: string;
lifecycleGeneration: number;
}
export interface LiveViewerDiagnosticScheduler {
setTimeout(callback: () => void, delayMilliseconds: number): number;
clearTimeout(handle: number): void;
setInterval(callback: () => void, delayMilliseconds: number): number;
clearInterval(handle: number): void;
}
export interface LiveViewerDiagnosticLifecycle {
readonly lineage: LiveViewerLineage;
readonly signal: AbortSignal;
active(): boolean;
admitted(): boolean;
post(event: LiveViewerDiagnostic): void;
verifyBuild(): void;
armAdmissionTimeout(callback: () => void, delayMilliseconds: number): void;
armAdmissionInterval(callback: () => void, delayMilliseconds: number): void;
clearAdmissionTimeout(): void;
clearAdmissionInterval(): void;
markAdmitted(): void;
dispose(): void;
}
export interface StaleUiBuild {
loadedUiBuildId: string;
expectedUiBuildId: string;
}
export interface UiBuildStaleCoordinator {
subscribe(listener: (event: StaleUiBuild) => void): () => void;
report(event: StaleUiBuild): void;
stale(): boolean;
}
const LIVE_VIEWER_DIAGNOSTIC_SCHEMA = "missioncore.live-viewer-diagnostic/v2";
const LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA = "missioncore.live-viewer-client-contract/v1";
const UI_BUILD_HEADER = "x-missioncore-ui-build";
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;
let buildMonitorSubscribers = 0;
let buildMonitorInterval: number | null = null;
let buildMonitorAbort: AbortController | null = null;
let buildMonitorOnlineListener: (() => void) | null = null;
let buildMonitorVisibilityListener: (() => void) | null = null;
function randomInstanceId(): string {
return globalThis.crypto.randomUUID();
}
function safeInteger(value: number | null | undefined): number | undefined {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
@@ -30,11 +89,75 @@ function safeInteger(value: number | null | undefined): number | undefined {
: undefined;
}
export function postLiveViewerDiagnostic(event: LiveViewerDiagnostic): void {
function browserScheduler(): LiveViewerDiagnosticScheduler {
return {
setTimeout: (callback, delayMilliseconds) => window.setTimeout(callback, delayMilliseconds),
clearTimeout: (handle) => window.clearTimeout(handle),
setInterval: (callback, delayMilliseconds) => window.setInterval(callback, delayMilliseconds),
clearInterval: (handle) => window.clearInterval(handle),
};
}
export function uiBuildIdFromModuleScripts(
scriptSources: readonly string[],
baseUrl: string,
): string {
for (const source of scriptSources) {
try {
const pathname = new URL(source, baseUrl).pathname;
if (HASHED_UI_BUILD_ID.test(pathname)) return pathname;
} catch {
// A malformed non-entry script is not the running application build.
}
}
return DEVELOPMENT_UI_BUILD_ID;
}
export function currentUiBuildId(): string {
const scripts = Array.from(
document.querySelectorAll<HTMLScriptElement>('script[type="module"][src]'),
(script) => script.src,
);
return uiBuildIdFromModuleScripts(scripts, window.location.href);
}
export function liveViewerDocumentInstanceId(): string {
documentInstanceId ??= randomInstanceId();
return documentInstanceId;
}
export function createLiveViewerInstanceId(): string {
return randomInstanceId();
}
export function createLiveViewerLineage(
viewerInstanceId: string,
lifecycleGeneration: number,
overrides: Partial<Pick<LiveViewerLineage, "uiBuildId" | "documentInstanceId">> = {},
): LiveViewerLineage {
if (!Number.isSafeInteger(lifecycleGeneration) || lifecycleGeneration < 1) {
throw new Error("Live viewer lifecycle generation must be a positive safe integer");
}
return {
uiBuildId: overrides.uiBuildId ?? currentUiBuildId(),
documentInstanceId: overrides.documentInstanceId ?? liveViewerDocumentInstanceId(),
viewerInstanceId,
lifecycleGeneration,
};
}
export function liveViewerDiagnosticBody(
event: LiveViewerDiagnostic,
lineage: LiveViewerLineage,
): Record<string, string | number> {
const streamId = event.streamId?.trim();
const body = {
schema_version: "missioncore.live-viewer-diagnostic/v1",
return {
schema_version: LIVE_VIEWER_DIAGNOSTIC_SCHEMA,
event_code: event.eventCode,
ui_build_id: lineage.uiBuildId,
document_instance_id: lineage.documentInstanceId,
viewer_instance_id: lineage.viewerInstanceId,
lifecycle_generation: lineage.lifecycleGeneration,
...(event.failureStage ? { failure_stage: event.failureStage } : {}),
...(streamId && SAFE_STREAM_ID.test(streamId) ? { stream_id: streamId } : {}),
...(safeInteger(event.backendActivitySequence) === undefined
@@ -50,12 +173,252 @@ export function postLiveViewerDiagnostic(event: LiveViewerDiagnostic): void {
? {}
: { recovery_attempt: safeInteger(event.recoveryAttempt) }),
};
}
export function createUiBuildStaleCoordinator({
scheduleReload,
reload,
}: {
scheduleReload: (callback: () => void, delayMilliseconds: number) => void;
reload: () => void;
}): UiBuildStaleCoordinator {
const listeners = new Set<(event: StaleUiBuild) => void>();
let staleEvent: StaleUiBuild | null = null;
let reloadScheduled = false;
return {
subscribe(listener) {
listeners.add(listener);
if (staleEvent) listener(staleEvent);
return () => listeners.delete(listener);
},
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;
},
};
}
function browserUiBuildCoordinator(): UiBuildStaleCoordinator {
sharedUiBuildCoordinator ??= createUiBuildStaleCoordinator({
scheduleReload: (callback, delayMilliseconds) => {
window.setTimeout(callback, delayMilliseconds);
},
reload: () => window.location.reload(),
});
return sharedUiBuildCoordinator;
}
function inspectUiBuildResponse(
response: Response,
loadedUiBuildId: string,
signal?: AbortSignal,
): void {
if (signal?.aborted || loadedUiBuildId === DEVELOPMENT_UI_BUILD_ID) return;
const expectedUiBuildId = response.headers.get(UI_BUILD_HEADER);
if (
expectedUiBuildId &&
expectedUiBuildId !== loadedUiBuildId &&
(response.status === 409 || response.ok)
) {
browserUiBuildCoordinator().report({ loadedUiBuildId, expectedUiBuildId });
}
}
export function postLiveViewerDiagnostic(
event: LiveViewerDiagnostic,
lineage: LiveViewerLineage,
signal?: AbortSignal,
): void {
if (signal?.aborted) return;
void fetch("/api/v1/viewer/live-diagnostics", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify(liveViewerDiagnosticBody(event, lineage)),
keepalive: true,
signal,
}).then((response) => {
inspectUiBuildResponse(response, lineage.uiBuildId, signal);
}).catch(() => {
// Diagnostics must never interfere with the live receiver recovery path.
// Diagnostics and build fencing must never interfere with receiver recovery.
});
}
export function verifyLiveViewerClientBuild(
lineage: Pick<LiveViewerLineage, "uiBuildId">,
signal?: AbortSignal,
): void {
if (signal?.aborted || lineage.uiBuildId === DEVELOPMENT_UI_BUILD_ID) return;
void fetch("/api/v1/viewer/client-contract", {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
signal,
}).then(async (response) => {
if (signal?.aborted) return;
inspectUiBuildResponse(response, lineage.uiBuildId, signal);
if (!response.ok) return;
const contract = await response.json() as unknown;
if (
signal?.aborted ||
typeof contract !== "object" ||
contract === null ||
!("schema_version" in contract) ||
contract.schema_version !== LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA ||
!("ui_build_id" in contract) ||
typeof contract.ui_build_id !== "string" ||
contract.ui_build_id === lineage.uiBuildId
) return;
browserUiBuildCoordinator().report({
loadedUiBuildId: lineage.uiBuildId,
expectedUiBuildId: contract.ui_build_id,
});
}).catch(() => {
// Offline periods are handled by the existing live-stream recovery path.
});
}
function startBuildMonitor(): void {
if (buildMonitorAbort || typeof window === "undefined") return;
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
const controller = new AbortController();
buildMonitorAbort = controller;
const verify = createAbortFencedBuildVerifier(
controller.signal,
(signal) => verifyLiveViewerClientBuild(lineage, signal),
);
buildMonitorInterval = window.setInterval(
verify,
UI_BUILD_CHECK_INTERVAL_MILLISECONDS,
);
buildMonitorOnlineListener = verify;
buildMonitorVisibilityListener = () => {
if (document.visibilityState === "visible") verify();
};
window.addEventListener("online", buildMonitorOnlineListener);
document.addEventListener("visibilitychange", buildMonitorVisibilityListener);
verify();
}
export function createAbortFencedBuildVerifier(
signal: AbortSignal,
verifier: (signal: AbortSignal) => void,
): () => void {
return () => {
if (signal.aborted) return;
verifier(signal);
};
}
function stopBuildMonitor(): void {
buildMonitorAbort?.abort();
buildMonitorAbort = null;
if (buildMonitorInterval !== null) window.clearInterval(buildMonitorInterval);
buildMonitorInterval = null;
if (buildMonitorOnlineListener) {
window.removeEventListener("online", buildMonitorOnlineListener);
}
if (buildMonitorVisibilityListener) {
document.removeEventListener("visibilitychange", buildMonitorVisibilityListener);
}
buildMonitorOnlineListener = null;
buildMonitorVisibilityListener = null;
}
export function subscribeToLiveViewerBuildFence(
listener: (event: StaleUiBuild) => void,
): () => void {
const unsubscribe = browserUiBuildCoordinator().subscribe(listener);
buildMonitorSubscribers += 1;
if (buildMonitorSubscribers === 1) startBuildMonitor();
return () => {
unsubscribe();
buildMonitorSubscribers = Math.max(0, buildMonitorSubscribers - 1);
if (buildMonitorSubscribers === 0) stopBuildMonitor();
};
}
export function createLiveViewerDiagnosticLifecycle({
lineage,
scheduler = browserScheduler(),
diagnosticPoster = postLiveViewerDiagnostic,
buildVerifier = verifyLiveViewerClientBuild,
}: {
lineage: LiveViewerLineage;
scheduler?: LiveViewerDiagnosticScheduler;
diagnosticPoster?: (
event: LiveViewerDiagnostic,
lineage: LiveViewerLineage,
signal?: AbortSignal,
) => void;
buildVerifier?: (
lineage: Pick<LiveViewerLineage, "uiBuildId">,
signal?: AbortSignal,
) => void;
}): LiveViewerDiagnosticLifecycle {
const abort = new AbortController();
let isActive = true;
let isAdmitted = false;
let admissionTimeout: number | null = null;
let admissionInterval: number | null = null;
const clearAdmissionTimeout = () => {
if (admissionTimeout === null) return;
scheduler.clearTimeout(admissionTimeout);
admissionTimeout = null;
};
const clearAdmissionInterval = () => {
if (admissionInterval === null) return;
scheduler.clearInterval(admissionInterval);
admissionInterval = null;
};
return {
lineage,
signal: abort.signal,
active: () => isActive,
admitted: () => isAdmitted,
post(event) {
if (!isActive) return;
diagnosticPoster(event, lineage, abort.signal);
},
verifyBuild() {
if (!isActive) return;
buildVerifier(lineage, abort.signal);
},
armAdmissionTimeout(callback, delayMilliseconds) {
clearAdmissionTimeout();
if (!isActive || isAdmitted) return;
admissionTimeout = scheduler.setTimeout(() => {
admissionTimeout = null;
if (isActive && !isAdmitted) callback();
}, delayMilliseconds);
},
armAdmissionInterval(callback, delayMilliseconds) {
clearAdmissionInterval();
if (!isActive || isAdmitted) return;
admissionInterval = scheduler.setInterval(() => {
if (isActive && !isAdmitted) callback();
}, delayMilliseconds);
},
clearAdmissionTimeout,
clearAdmissionInterval,
markAdmitted() {
if (!isActive) return;
isAdmitted = true;
clearAdmissionTimeout();
clearAdmissionInterval();
},
dispose() {
if (!isActive) return;
isActive = false;
abort.abort();
clearAdmissionTimeout();
clearAdmissionInterval();
},
};
}
@@ -54,6 +54,7 @@ export interface ObservationLayoutController {
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
setViewportSize: (size: ObservationViewportSize) => void;
activateAutomaticDefaults: () => void;
snapshot: () => ObservationLayoutSnapshot | null;
restore: (snapshot: ObservationLayoutSnapshot) => void;
}
@@ -68,6 +69,66 @@ function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
);
}
/**
* One exact live presentation lease. The stable source id is deliberately not
* sufficient: the same live camera and even the same browser delivery generation
* can be reused by a later acquisition.
*/
export function automaticLivePresentationIdentity(
source: ObservationSourceDescriptor,
): string | null {
const acquisitionId = source.binding.acquisitionId?.trim();
const deliveryId = source.delivery?.id?.trim();
if (
!acquisitionId
|| !deliveryId
|| !canOpenByDefault(source)
|| source.activation?.selected !== true
) return null;
return JSON.stringify([source.id, acquisitionId, deliveryId]);
}
/** A deliberate close fences every delivery generation in that acquisition. */
export function livePresentationCloseFence(
source: ObservationSourceDescriptor,
): string | null {
const acquisitionId = source.binding.acquisitionId?.trim();
return acquisitionId ? JSON.stringify([source.id, acquisitionId]) : null;
}
export interface LiveDefaultPresentationAdmission {
visibleIds: string[];
removedIds: string[];
admittedIdentities: string[];
}
export function admitLiveDefaultPresentations(
currentIds: readonly string[],
sources: readonly ObservationSourceDescriptor[],
admittedIdentities: ReadonlySet<string>,
closedAcquisitionSources: ReadonlySet<string>,
): LiveDefaultPresentationAdmission {
let change = { visibleIds: [...currentIds], removedIds: [] as string[] };
const removed = new Set<string>();
const admitted: string[] = [];
for (const source of sources) {
const presentationIdentity = automaticLivePresentationIdentity(source);
if (
!presentationIdentity
|| admittedIdentities.has(presentationIdentity)
|| closedAcquisitionSources.has(livePresentationCloseFence(source) ?? "")
) continue;
change = openObservationSource(change.visibleIds, source.id, sources);
change.removedIds.forEach((sourceId) => removed.add(sourceId));
admitted.push(presentationIdentity);
}
return {
visibleIds: change.visibleIds,
removedIds: [...removed],
admittedIdentities: admitted,
};
}
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
return sources
.map((source) => [
@@ -107,6 +168,8 @@ export function useObservationLayout(
const viewportSizeRef = useRef<ObservationViewportSize | null>(null);
const desiredSnapshotRef = useRef<ObservationLayoutSnapshot | null>(null);
const restoredLayoutAuthorityRef = useRef(false);
const admittedLivePresentationIdentitiesRef = useRef(new Set<string>());
const closedLiveAcquisitionSourcesRef = useRef(new Set<string>());
const initializedCatalog = useRef<string | null>(null);
const sourceIdList = sources.map((source) => source.id).sort();
const sourceIdsIdentity = sourceIdList.join("\u0000");
@@ -284,31 +347,25 @@ export function useObservationLayout(
sources,
]);
const selectedDeliveryIdentity = sources
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
.map((source) => [
source.id,
source.delivery?.id,
source.activation?.groupId,
source.activation?.maxActive,
].join(":"))
.sort()
.join("|");
const selectedDeliveryIdentity = JSON.stringify(sources
.map(automaticLivePresentationIdentity)
.filter((candidate): candidate is string => candidate !== null)
.sort());
useEffect(() => {
if (restoredLayoutAuthorityRef.current) return;
const selected = sources.filter(
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
const admission = admitLiveDefaultPresentations(
visibleIdsRef.current,
sources,
admittedLivePresentationIdentitiesRef.current,
closedLiveAcquisitionSourcesRef.current,
);
if (!selected.length) return;
let change = { visibleIds: visibleIdsRef.current, removedIds: [] as string[] };
const removed = new Set<string>();
for (const source of selected) {
change = openObservationSource(change.visibleIds, source.id, sources);
change.removedIds.forEach((sourceId) => removed.add(sourceId));
}
commitVisibleIds(change.visibleIds);
clearPresentation([...removed], false);
if (!admission.admittedIdentities.length) return;
admission.admittedIdentities.forEach((presentationIdentity) => {
admittedLivePresentationIdentitiesRef.current.add(presentationIdentity);
});
commitVisibleIds(admission.visibleIds);
clearPresentation(admission.removedIds, false);
persistLiveLayout();
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
@@ -334,6 +391,8 @@ export function useObservationLayout(
markPending(source, false);
}
}
const closeFence = livePresentationCloseFence(source);
if (closeFence) closedLiveAcquisitionSourcesRef.current.add(closeFence);
restoredLayoutAuthorityRef.current = false;
const change = closeObservationSource(visibleIdsRef.current, sourceId);
commitVisibleIds(change.visibleIds);
@@ -356,6 +415,8 @@ export function useObservationLayout(
markPending(source, false);
}
}
const closeFence = livePresentationCloseFence(source);
if (closeFence) closedLiveAcquisitionSourcesRef.current.delete(closeFence);
restoredLayoutAuthorityRef.current = false;
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
commitVisibleIds(change.visibleIds);
@@ -427,6 +488,46 @@ export function useObservationLayout(
}
}, [applyDesiredSnapshot, persistLiveLayout]);
const activateAutomaticDefaults = useCallback(() => {
// A new operator-started live acquisition owns its initial presentation.
// Keep saved geometry, but do not let an older layout suppress a camera
// that the device plugin has just selected and delivered automatically.
restoredLayoutAuthorityRef.current = false;
const currentSources = sourcesRef.current;
let nextVisibleIds = visibleIdsRef.current;
for (const source of currentSources.filter(canOpenByDefault)) {
if (automaticLivePresentationIdentity(source)) continue;
nextVisibleIds = openObservationSource(
nextVisibleIds,
source.id,
currentSources,
).visibleIds;
}
const admission = admitLiveDefaultPresentations(
nextVisibleIds,
currentSources,
admittedLivePresentationIdentitiesRef.current,
closedLiveAcquisitionSourcesRef.current,
);
admission.admittedIdentities.forEach((presentationIdentity) => {
admittedLivePresentationIdentitiesRef.current.add(presentationIdentity);
});
nextVisibleIds = admission.visibleIds;
commitVisibleIds(nextVisibleIds);
clearPresentation(admission.removedIds, false);
const firstFloatingDefault = currentSources.find(
(source) => (
canOpenByDefault(source)
&& source.capabilities.overlay
&& nextVisibleIds.includes(source.id)
),
);
if (firstFloatingDefault) {
commitActiveFloatingSourceId(firstFloatingDefault.id);
}
persistLiveLayout();
}, [clearPresentation, commitActiveFloatingSourceId, commitVisibleIds, persistLiveLayout]);
const snapshot = useCallback((): ObservationLayoutSnapshot | null => {
if (!viewportSizeRef.current) return null;
persistLiveLayout();
@@ -464,6 +565,7 @@ export function useObservationLayout(
setFloatingMaximized,
setWindowRect,
setViewportSize,
activateAutomaticDefaults,
snapshot,
restore,
};
@@ -154,6 +154,15 @@ export interface ObservationSourceActivation {
controllable: boolean;
}
export interface ObservationSourcePresentationLease {
kind: "active-stream-recovery";
runtimeId: string;
acquisitionId: string;
acquisitionStateRevision: number;
producerGeneration: number;
recoveryGeneration: number;
}
export interface ObservationSourceDescriptor {
id: string;
sourceId: string;
@@ -168,6 +177,7 @@ export interface ObservationSourceDescriptor {
previewUrl?: string | null;
delivery?: ObservationSourceDelivery | null;
activation?: ObservationSourceActivation | null;
presentationLease?: ObservationSourcePresentationLease | null;
provider: ObservationSourceProvider;
binding: ObservationSourceBinding;
capabilities: ObservationSourceCapabilities;
@@ -201,6 +211,7 @@ export interface MissionRuntimeController {
backendStatus: BackendStatus;
pendingAction: string | null;
refresh: () => void | Promise<void>;
resetConnectionScenario?: () => Promise<boolean>;
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
setObservationSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>;
}