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
+7 -8
View File
@@ -63,7 +63,7 @@ import {
workspacesForRoot,
type RootId,
} from "./productModel";
import { backendLabel, phaseLabel, phaseTone } from "./presentation";
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
import {
defaultSceneSettings,
type PointColorMode,
@@ -595,8 +595,8 @@ export default function App() {
setRecordedReplayLabel(null);
setSourceUrl("");
setSourceDraft("");
}, []);
observationLayout.activateAutomaticDefaults();
}, [observationLayout.activateAutomaticDefaults]);
useEffect(() => {
if (!["recordings", "lab-archive"].includes(activeDefinition?.kind ?? "")) {
setReplayTransitioning(false);
@@ -638,6 +638,8 @@ export default function App() {
const contentActions = useApplicationPanelActions({
definition: activeDefinition,
refreshRuntime: runtime.refresh,
resetConnectionScenario: runtime.resetConnectionScenario,
connectionScenarioResetting: runtime.pendingAction === "mode",
saveWorkspaceLayout,
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
systemUtilityActions: computeContourSettings.utilityActions,
@@ -728,10 +730,7 @@ export default function App() {
{
id: "local-contour",
label: "Локальный контур",
description:
runtime.state?.activeDevice?.endpointLabel ||
selection?.model.displayName ||
"Модель не выбрана",
description: selection ? "Подключение" : "Модель не выбрана",
icon: <Icon name="network" />,
active:
runtime.backendStatus !== "offline"
@@ -777,7 +776,7 @@ export default function App() {
headerTools={
activeDefinition.kind === "device" ? (
<StatusBadge tone={phaseTone(runtime.state?.phase)}>
{phaseLabel(runtime.state?.phase)}
{localConnectionPhaseLabel(runtime.state?.phase)}
</StatusBadge>
) : activeDefinition.kind === "spatial" ? (
<div className="observation-header-tools">
@@ -2,6 +2,14 @@ import { useEffect, useRef, useState } from "react";
import { Icon } from "@nodedc/ui-react";
import type { ObservationSourceDelivery } from "../core/runtime/contracts";
import {
cameraBrowserTransportIdentity,
cameraTransportCallbackIsCurrent,
initialCameraPlaybackRecoveryState,
reduceCameraPlaybackRecovery,
type CameraPlaybackRecoveryEvent,
} from "../core/observation/liveCameraRecovery";
import { subscribeToLiveViewerBuildFence } from "../core/observation/liveViewerDiagnostics";
type PlayerStatus = "connecting" | "buffering" | "playing" | "error";
@@ -10,7 +18,92 @@ export interface CameraLeaseRetryBudget {
count: number;
}
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000] as const;
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
// main-thread stall. Keep one bounded append margin above that complete batch;
// crossing either limit replaces the MSE epoch instead of dropping fragments.
export const CAMERA_PENDING_QUEUE_MAX_BYTES = 12 * 1024 * 1024;
export const CAMERA_PENDING_QUEUE_MAX_SEGMENTS = 96;
export type CameraStartupWatchdogStage = "first-media" | "first-playable-frame";
export interface CameraStartupWatchdog {
armFirstMedia: () => void;
markMediaReceived: () => void;
markPlaying: () => void;
clear: () => void;
pendingStage: () => CameraStartupWatchdogStage | null;
}
/**
* Supervise only the disposable browser transport. One fixed first-media
* deadline covers both an MSE that never opens and an open-but-silent socket.
* The first non-empty fragment then starts a separate first-playable-frame
* deadline; later fragments deliberately do not extend it.
*/
export function createCameraStartupWatchdog({
schedule,
cancel,
onTimeout,
firstMediaTimeoutMs = CAMERA_FIRST_MEDIA_TIMEOUT_MS,
firstPlayableFrameTimeoutMs = CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS,
}: {
schedule: (callback: () => void, timeoutMs: number) => number;
cancel: (handle: number) => void;
onTimeout: (stage: CameraStartupWatchdogStage) => void;
firstMediaTimeoutMs?: number;
firstPlayableFrameTimeoutMs?: number;
}): CameraStartupWatchdog {
let timer: number | undefined;
let stage: CameraStartupWatchdogStage | null = null;
let mediaReceived = false;
let playing = false;
const clearTimer = () => {
if (timer !== undefined) cancel(timer);
timer = undefined;
stage = null;
};
const arm = (nextStage: CameraStartupWatchdogStage, timeoutMs: number) => {
clearTimer();
stage = nextStage;
timer = schedule(() => {
if (stage !== nextStage || playing) return;
timer = undefined;
stage = null;
onTimeout(nextStage);
}, timeoutMs);
};
return {
armFirstMedia() {
if (mediaReceived || playing || stage !== null) return;
arm("first-media", firstMediaTimeoutMs);
},
markMediaReceived() {
if (mediaReceived || playing) return;
mediaReceived = true;
arm("first-playable-frame", firstPlayableFrameTimeoutMs);
},
markPlaying() {
if (playing) return;
playing = true;
clearTimer();
},
clear: clearTimer,
pendingStage: () => stage,
};
}
export function cameraStartupWatchdogRecoveryMessage(
stage: CameraStartupWatchdogStage,
): string {
return stage === "first-media"
? "Камера подключена, но не передаёт медиаданные; восстанавливаем browser-preview."
: "Медиаданные поступают, но первый кадр не воспроизводится; пересоздаём decoder.";
}
export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget {
return { deliveryId, count: 0 };
@@ -19,15 +112,60 @@ export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetr
export function consumeCameraLeaseRetry(
current: CameraLeaseRetryBudget,
deliveryId: string,
): { budget: CameraLeaseRetryBudget; delay: number | null } {
): { budget: CameraLeaseRetryBudget; delay: number } {
const count = current.deliveryId === deliveryId ? current.count : 0;
const delay = CAMERA_LEASE_RETRY_DELAYS[count] ?? null;
const delayIndex = Math.min(count, CAMERA_LEASE_RETRY_DELAYS.length - 1);
const delay = CAMERA_LEASE_RETRY_DELAYS[delayIndex];
return {
budget: { deliveryId, count: delay === null ? count : count + 1 },
budget: {
deliveryId,
count: Math.min(count + 1, CAMERA_LEASE_RETRY_DELAYS.length),
},
delay,
};
}
export function cameraTransportRecoveryIsCurrent(
activeAuthorityIdentity: string | null,
expectedAuthorityIdentity: string | null,
activeEpoch: number,
expectedEpoch: number,
disposed: boolean,
): boolean {
return Boolean(
expectedAuthorityIdentity
&& activeAuthorityIdentity === expectedAuthorityIdentity
&& cameraTransportCallbackIsCurrent(activeEpoch, expectedEpoch, disposed),
);
}
export function cameraTransportCanOpen(uiBuildStale: boolean): boolean {
return !uiBuildStale;
}
export function cameraPendingQueueCanAccept(
queuedBytes: number,
queuedSegments: number,
incomingBytes: number,
): boolean {
return incomingBytes > 0
&& queuedBytes + incomingBytes <= CAMERA_PENDING_QUEUE_MAX_BYTES
&& queuedSegments + 1 <= CAMERA_PENDING_QUEUE_MAX_SEGMENTS;
}
export function cameraTransportCloseRecoveryMessage(code: number): string {
if (code === 4_008) {
return "Browser-reader отстал от эфира; восстанавливаем текущую камеру.";
}
if (code === 1_008) {
return "Camera adapter освобождает прежний browser-reader; переподключаемся.";
}
if (code === 1_000) {
return "Browser-preview завершился; восстанавливаем текущую камеру.";
}
return "Browser-preview прерван; восстанавливаем текущую камеру.";
}
function websocketUrl(path: string): string {
const url = new URL(path, window.location.href);
if (url.protocol === "http:") url.protocol = "ws:";
@@ -41,61 +179,149 @@ function websocketUrl(path: string): string {
export function MseFmp4WebSocketPlayer({
delivery,
label,
recoveryAuthorityIdentity,
}: {
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" };
label: string;
recoveryAuthorityIdentity: string | null;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
const activeAuthorityRef = useRef(recoveryAuthorityIdentity);
const recoveryPendingAuthorityRef = useRef<string | null>(null);
const transportEpochRef = useRef(0);
const transportAuthorityRef = useRef(recoveryAuthorityIdentity);
const activeTransportDisposeRef = useRef<(() => void) | null>(null);
const uiBuildStaleRef = useRef(false);
const [attempt, setAttempt] = useState(0);
const [uiBuildStale, setUiBuildStale] = useState(false);
const [status, setStatus] = useState<PlayerStatus>("connecting");
const [message, setMessage] = useState("Подключение к локальному видеопотоку");
activeAuthorityRef.current = recoveryAuthorityIdentity;
const transportIdentity = cameraBrowserTransportIdentity(
delivery,
recoveryAuthorityIdentity,
);
useEffect(() => subscribeToLiveViewerBuildFence(() => {
// Setting this local fence tears down the effect-owned WebSocket and MSE
// buffer. It intentionally never invokes a plugin or device command.
uiBuildStaleRef.current = true;
activeTransportDisposeRef.current?.();
setUiBuildStale(true);
}), []);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (uiBuildStale || !cameraTransportCanOpen(uiBuildStaleRef.current)) {
video.pause();
video.removeAttribute("src");
video.load();
return;
}
if (transportAuthorityRef.current !== recoveryAuthorityIdentity) {
transportAuthorityRef.current = recoveryAuthorityIdentity;
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
}
let disposed = false;
const transportEpoch = transportEpochRef.current + 1;
transportEpochRef.current = transportEpoch;
const transportIsCurrent = () => cameraTransportCallbackIsCurrent(
transportEpochRef.current,
transportEpoch,
disposed || uiBuildStaleRef.current,
);
let socket: WebSocket | null = null;
let sourceBuffer: SourceBuffer | null = null;
let onBufferUpdateEnd: (() => void) | null = null;
let onBufferError: (() => void) | null = null;
let objectUrl = "";
let retryTimer: number | undefined;
let receivedMedia = false;
let failed = false;
let startupWatchdog: CameraStartupWatchdog | null = null;
const recovering = Boolean(
activeAuthorityRef.current
&& recoveryPendingAuthorityRef.current === activeAuthorityRef.current,
);
const queue: ArrayBuffer[] = [];
let queuedBytes = 0;
const fail = (copy: string) => {
if (disposed || failed) return;
if (!transportIsCurrent() || failed) return;
startupWatchdog?.clear();
failed = true;
recoveryPendingAuthorityRef.current = null;
setStatus("error");
setMessage(copy);
try {
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close(1011, "Live video buffer reset");
socket.close(4_000, "Live video buffer reset");
}
} catch {
// The manual reconnect button will create a fresh transport and MSE buffer.
}
};
const retryLease = () => {
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
leaseRetryRef.current = retry.budget;
if (retry.delay === null) {
fail("Camera adapter ещё занят предыдущим окном. Подключитесь повторно.");
const retryTransport = (copy: string) => {
if (!transportIsCurrent() || failed) return;
if (!cameraTransportRecoveryIsCurrent(
activeAuthorityRef.current,
recoveryAuthorityIdentity,
transportEpochRef.current,
transportEpoch,
disposed || uiBuildStaleRef.current,
)) {
fail(copy);
return;
}
startupWatchdog?.clear();
failed = true;
queue.length = 0;
queuedBytes = 0;
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
leaseRetryRef.current = retry.budget;
recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity;
setStatus("connecting");
setMessage("Освобождение предыдущего окна камеры");
setMessage(copy);
try {
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close(4_001, "Live video transport recovery");
}
} catch {
// The replacement effect still fences and releases this transport.
}
retryTimer = window.setTimeout(() => {
if (!disposed) setAttempt((value) => value + 1);
if (cameraTransportRecoveryIsCurrent(
activeAuthorityRef.current,
recoveryAuthorityIdentity,
transportEpochRef.current,
transportEpoch,
disposed || uiBuildStaleRef.current,
)) {
setAttempt((value) => value + 1);
}
}, retry.delay);
};
if (recoveryAuthorityIdentity) {
startupWatchdog = createCameraStartupWatchdog({
schedule: (callback, timeoutMs) => window.setTimeout(callback, timeoutMs),
cancel: (handle) => window.clearTimeout(handle),
onTimeout: (stage) => {
retryTransport(cameraStartupWatchdogRecoveryMessage(stage));
},
});
}
const onPlaying = () => {
if (disposed) return;
if (!transportIsCurrent() || failed) return;
startupWatchdog?.markPlaying();
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
recoveryPendingAuthorityRef.current = null;
setStatus("playing");
setMessage("");
};
@@ -103,25 +329,32 @@ export function MseFmp4WebSocketPlayer({
video.addEventListener("playing", onPlaying);
const appendNext = () => {
if (disposed || !sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
if (
!transportIsCurrent()
|| failed
|| !sourceBuffer
|| sourceBuffer.updating
|| queue.length === 0
) return;
const chunk = queue.shift();
if (!chunk) return;
queuedBytes -= chunk.byteLength;
try {
sourceBuffer.appendBuffer(chunk);
} catch (error) {
fail(error instanceof DOMException && error.name === "QuotaExceededError"
? "Live-буфер переполнен и сброшен, чтобы не накапливать задержку."
: "Не удалось добавить видеосегмент. Повторите подключение.");
retryTransport(error instanceof DOMException && error.name === "QuotaExceededError"
? "Live-буфер переполнен; восстанавливаем канал без накопленной задержки."
: "Не удалось добавить видеосегмент; восстанавливаем browser-preview.");
}
};
const enqueue = (chunk: ArrayBuffer) => {
if (disposed || chunk.byteLength === 0) return;
if (!transportIsCurrent() || failed || chunk.byteLength === 0) return;
startupWatchdog?.markMediaReceived();
// Never drop arbitrary fMP4 fragments: the following samples may depend
// on them. A bounded reset is safer and keeps live latency deterministic.
if (queuedBytes + chunk.byteLength > 2 * 1024 * 1024) {
fail("Видеодекодер не успевает за эфиром. Live-буфер сброшен.");
if (!cameraPendingQueueCanAccept(queuedBytes, queue.length, chunk.byteLength)) {
retryTransport("Видеодекодер отстал от эфира; восстанавливаем live-буфер.");
return;
}
queue.push(chunk);
@@ -142,13 +375,16 @@ export function MseFmp4WebSocketPlayer({
}
setStatus("connecting");
setMessage("Подключение к локальному видеопотоку");
setMessage(recovering
? "Восстановление камеры после разрыва браузерного канала"
: "Подключение к локальному видеопотоку");
const mediaSource = new MediaSource();
objectUrl = URL.createObjectURL(mediaSource);
video.src = objectUrl;
startupWatchdog?.armFirstMedia();
const onSourceOpen = () => {
if (disposed) return;
if (!transportIsCurrent() || failed) return;
try {
sourceBuffer = mediaSource.addSourceBuffer(mediaType);
} catch {
@@ -156,8 +392,8 @@ export function MseFmp4WebSocketPlayer({
return;
}
sourceBuffer.addEventListener("updateend", () => {
if (disposed || !sourceBuffer) return;
onBufferUpdateEnd = () => {
if (!transportIsCurrent() || failed || !sourceBuffer) return;
const buffered = sourceBuffer.buffered;
if (buffered.length > 0) {
const end = buffered.end(buffered.length - 1);
@@ -166,7 +402,9 @@ export function MseFmp4WebSocketPlayer({
if (!receivedMedia) {
receivedMedia = true;
setStatus("buffering");
setMessage("Запуск первого декодированного кадра");
setMessage(recovering
? "Запуск первого кадра восстановленной камеры"
: "Запуск первого декодированного кадра");
void video.play().catch(() => undefined);
}
const removeBefore = end - 3;
@@ -180,10 +418,12 @@ export function MseFmp4WebSocketPlayer({
}
}
appendNext();
});
sourceBuffer.addEventListener("error", () => {
fail("MSE сообщил об ошибке декодирования видеосегмента.");
});
};
onBufferError = () => {
retryTransport("MSE сбросил видеосегмент; восстанавливаем decoder.");
};
sourceBuffer.addEventListener("updateend", onBufferUpdateEnd);
sourceBuffer.addEventListener("error", onBufferError);
try {
socket = new WebSocket(websocketUrl(delivery.url));
@@ -193,41 +433,49 @@ export function MseFmp4WebSocketPlayer({
}
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
if (disposed) return;
if (!transportIsCurrent() || failed) return;
startupWatchdog?.armFirstMedia();
setStatus("buffering");
setMessage("Ожидание первого видеокадра");
setMessage(recovering
? "Ожидание первого кадра после восстановления"
: "Ожидание первого видеокадра");
});
socket.addEventListener("message", (event) => {
if (!transportIsCurrent() || failed) return;
if (event.data instanceof ArrayBuffer) {
enqueue(event.data);
} else if (event.data instanceof Blob) {
void event.data.arrayBuffer().then(enqueue).catch(() => {
fail("Получен повреждённый видеосегмент.");
retryTransport("Получен повреждённый видеосегмент; восстанавливаем канал.");
});
}
});
socket.addEventListener("error", () => {
fail("Соединение с локальным video adapter потеряно.");
retryTransport("Связь с локальным video adapter потеряна; переподключаемся.");
});
socket.addEventListener("close", (event) => {
if (!disposed && !failed && event.code === 1008) {
retryLease();
} else if (!disposed) {
fail(event.code === 1000
? "Видеопоток завершён. Можно подключиться повторно."
: "Видеопоток прерван. Проверьте устройство и повторите подключение.");
}
if (!transportIsCurrent() || failed) return;
retryTransport(cameraTransportCloseRecoveryMessage(event.code));
});
};
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
return () => {
const disposeTransport = () => {
if (disposed) return;
disposed = true;
startupWatchdog?.clear();
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
queue.length = 0;
socket?.close(1000, "Источник скрыт оператором");
socket?.close(1000, "Browser preview transport replaced or hidden");
video.removeEventListener("playing", onPlaying);
mediaSource.removeEventListener("sourceopen", onSourceOpen);
if (sourceBuffer && onBufferUpdateEnd) {
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
}
if (sourceBuffer && onBufferError) {
sourceBuffer.removeEventListener("error", onBufferError);
}
try {
if (sourceBuffer?.updating) sourceBuffer.abort();
} catch {
@@ -243,7 +491,69 @@ export function MseFmp4WebSocketPlayer({
video.load();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attempt, delivery.id, delivery.mediaType, delivery.url]);
activeTransportDisposeRef.current = disposeTransport;
return () => {
if (activeTransportDisposeRef.current === disposeTransport) {
activeTransportDisposeRef.current = null;
}
disposeTransport();
};
}, [attempt, recoveryAuthorityIdentity, transportIdentity, uiBuildStale]);
useEffect(() => {
if (
!recoveryAuthorityIdentity
|| uiBuildStale
|| !cameraTransportCanOpen(uiBuildStaleRef.current)
) return;
let recovery = initialCameraPlaybackRecoveryState(
recoveryAuthorityIdentity,
Date.now(),
);
const dispatchRecovery = (event: CameraPlaybackRecoveryEvent) => {
if (uiBuildStaleRef.current) return;
const decision = reduceCameraPlaybackRecovery(recovery, event, {
activeAuthorityIdentity: activeAuthorityRef.current,
now: Date.now(),
documentVisible: document.visibilityState === "visible",
networkOnline: navigator.onLine !== false,
});
recovery = decision.state;
if (!decision.reopen) return;
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity;
setStatus("connecting");
setMessage("Восстановление камеры после разрыва браузерного канала");
// The transport effect cleans up the old WebSocket and MSE object before
// opening their replacement. No plugin action or device command occurs.
setAttempt((value) => value + 1);
};
const onVisibilityChange = () => {
dispatchRecovery({
type: document.visibilityState === "hidden"
? "document-hidden"
: "document-visible",
});
};
const onOnline = () => dispatchRecovery({ type: "network-online" });
const onPageShow = (event: PageTransitionEvent) => {
dispatchRecovery({ type: "page-restore", persisted: event.persisted });
};
const heartbeat = window.setInterval(() => {
dispatchRecovery({ type: "heartbeat" });
}, 1_000);
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("online", onOnline);
window.addEventListener("pageshow", onPageShow);
return () => {
window.clearInterval(heartbeat);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("online", onOnline);
window.removeEventListener("pageshow", onPageShow);
};
}, [delivery.id, recoveryAuthorityIdentity, uiBuildStale]);
return (
<div className="mse-fmp4-player" data-status={status}>
@@ -265,6 +575,7 @@ export function MseFmp4WebSocketPlayer({
type="button"
onClick={() => {
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
recoveryPendingAuthorityRef.current = null;
setAttempt((value) => value + 1);
}}
>
@@ -14,6 +14,7 @@ import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
const sourceIcon: Record<ObservationSourceModality, IconName> = {
"point-cloud": "globe",
@@ -63,7 +64,13 @@ export function ObservationMedia({
source.delivery?.kind === "mse-fmp4-websocket" &&
source.modality === "video"
) {
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
return (
<MseFmp4WebSocketPlayer
delivery={source.delivery}
label={source.label}
recoveryAuthorityIdentity={liveCameraPlaybackAuthorityIdentity(source)}
/>
);
}
if (
@@ -2,14 +2,22 @@ import { useEffect, useRef, useState } from "react";
import type { SceneSettings } from "../sceneSettings";
import {
advanceLiveReceiverOpenWatchdog,
advanceLiveReceiverWatchdog,
initialLiveReceiverOpenWatchdogState,
initialLiveReceiverRecoveryState,
initialLiveReceiverWatchdogState,
LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
LIVE_RECEIVER_OPEN_MAX_AGE_MS,
requestLiveReceiverRecovery,
} from "../core/observation/liveReceiverWatchdog";
import { postLiveViewerDiagnostic } from "../core/observation/liveViewerDiagnostics";
import type { LiveViewerFailureStage } from "../core/observation/liveViewerDiagnostics";
import {
createLiveViewerDiagnosticLifecycle,
createLiveViewerInstanceId,
createLiveViewerLineage,
subscribeToLiveViewerBuildFence,
type LiveViewerFailureStage,
} from "../core/observation/liveViewerDiagnostics";
import {
fetchPerceptionPreparationStatus,
perceptionPreparationMessage,
@@ -81,6 +89,7 @@ export interface RerunViewportProps {
followLive?: boolean;
liveActivitySequence?: number | null;
liveStreamId?: string | null;
liveRecoveryAuthorityIdentity?: string | null;
autoplayWhenReady?: boolean;
presentationGate?: RecordedAdmissionPhase;
expectedTimelineStartSeconds?: number;
@@ -197,6 +206,51 @@ export function createRecordedOpenWatchdog<T>({
};
}
export function createReentrantViewerDisposer(
cleanupOnce: () => void,
releaseNativeViewer: () => void,
): () => void {
let cleanupComplete = false;
return () => {
try {
if (!cleanupComplete) {
cleanupComplete = true;
cleanupOnce();
}
} finally {
// `viewer.start()` can resolve after an earlier pre-ready stop. Reapply
// native release on every disposal boundary so that a stale viewer can
// never reopen after React and diagnostics have already unmounted it.
releaseNativeViewer();
}
};
}
interface ActiveLiveViewerOwner {
release: () => void;
}
let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null;
/**
* Own exactly one native live receiver per application document.
*
* React route/StrictMode transitions can overlap two mounted workspaces for a
* render turn. Rerun keeps each native gRPC receiver alive independently, so
* the overlap used to consume the bounded live replay slots and leave the
* operator's visible canvas black. Claiming the next owner synchronously
* retires the previous native receiver before the next one starts.
*/
export function claimExclusiveLiveViewer(release: () => void): () => void {
const owner = { release };
const previous = activeLiveViewerOwner;
activeLiveViewerOwner = owner;
previous?.release();
return () => {
if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null;
};
}
export interface RecordedPlaybackBufferState {
bufferedEndNs: number | null;
expectedStartNs: number | null;
@@ -813,6 +867,7 @@ export function RerunViewport({
followLive = false,
liveActivitySequence = null,
liveStreamId = null,
liveRecoveryAuthorityIdentity = null,
autoplayWhenReady = false,
presentationGate = "ready",
expectedTimelineStartSeconds,
@@ -845,7 +900,14 @@ export function RerunViewport({
liveActivitySequenceRef.current = liveActivitySequence;
const liveStreamIdRef = useRef<string | null>(liveStreamId);
liveStreamIdRef.current = liveStreamId;
const liveRecoveryAuthorityRef = useRef<string | null>(liveRecoveryAuthorityIdentity);
liveRecoveryAuthorityRef.current = liveRecoveryAuthorityIdentity;
const liveRecoveryRef = useRef(initialLiveReceiverRecoveryState());
const liveViewerInstanceIdRef = useRef<string | null>(null);
liveViewerInstanceIdRef.current ??= createLiveViewerInstanceId();
const liveViewerLifecycleGenerationRef = useRef(0);
const activeViewerLifecycleRef = useRef<(() => void) | null>(null);
const uiBuildStaleRef = useRef(false);
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
@@ -871,14 +933,22 @@ export function RerunViewport({
recordedArtifact !== null,
);
useEffect(() => subscribeToLiveViewerBuildFence(() => {
uiBuildStaleRef.current = true;
// A stale document may only release its own browser transports. Device
// START/STOP remains owned by the acquisition authority while the fresh
// application document is loaded.
activeViewerLifecycleRef.current?.();
}), []);
useEffect(() => {
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
}, [followLive, liveStreamId, sourceUrl]);
}, [followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl]);
useEffect(() => {
const normalizedSource = sourceUrl.trim();
const host = hostRef.current;
if (!normalizedSource || !host) {
if (!normalizedSource || !host || uiBuildStaleRef.current) {
setStatus("idle");
setRecordingBufferProgress(null);
onStatusChange?.("idle");
@@ -887,6 +957,7 @@ export function RerunViewport({
onPlaybackControllerChange?.(null);
return;
}
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource);
let resolvedSource: string;
try {
@@ -912,18 +983,29 @@ export function RerunViewport({
return;
}
liveViewerLifecycleGenerationRef.current += 1;
const diagnosticLifecycle = createLiveViewerDiagnosticLifecycle({
lineage: createLiveViewerLineage(
liveViewerInstanceIdRef.current!,
liveViewerLifecycleGenerationRef.current,
),
});
diagnosticLifecycle.verifyBuild();
let disposed = false;
let disposeViewer: (() => void) | undefined;
let recordingOpenTimer: number | undefined;
let liveRecordingDiscoveryTimer: number | undefined;
let recordedOpenWatchdog: {
arm: () => void;
clear: () => void;
pending: () => boolean;
} | null = null;
let playbackRangeTimer: number | undefined;
let liveRecoveryRetryTimer: number | undefined;
let recordingOpened = false;
let recordingOpenTimedOut = false;
let liveOpenWatchdog = initialLiveReceiverOpenWatchdogState(
liveActivitySequenceRef.current,
);
let viewerStartResolved = false;
let latestLiveRangeMaxNs: number | null = null;
let liveWatchdog = initialLiveReceiverWatchdogState();
@@ -948,22 +1030,68 @@ export function RerunViewport({
}
};
const clearRecordedAdmissionWatchdog = () => recordedOpenWatchdog?.clear();
const clearLiveRecordingOpenTimer = () => {
if (recordingOpenTimer !== undefined) {
window.clearTimeout(recordingOpenTimer);
recordingOpenTimer = undefined;
}
const clearLiveRecordingOpenTimer = diagnosticLifecycle.clearAdmissionTimeout;
function refreshOpeningLiveReceiver(openForMs: number) {
if (disposed || recordingOpened) return;
diagnosticLifecycle.post({
eventCode: "live_receiver_restart_requested",
failureStage: "recording-open-timeout",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
viewerRangeMaxNs: latestLiveRangeMaxNs,
stalledForMs: Math.round(openForMs),
recoveryAttempt: liveRecoveryRef.current.attempts || null,
});
setStatus("loading");
onStatusChange?.(
"loading",
"Живой визуализатор обновляет приёмник продолжающегося потока.",
);
disposeViewer?.();
setRetryNonce((nonce) => nonce + 1);
}
const armLiveRecordingOpenTimer = () => {
clearLiveRecordingOpenTimer();
diagnosticLifecycle.armAdmissionTimeout(() => {
if (disposed || recordingOpened) return;
const observed = advanceLiveReceiverOpenWatchdog(
liveOpenWatchdog,
liveRecoveryRef.current,
liveActivitySequenceRef.current,
Date.now(),
);
liveOpenWatchdog = observed.state;
liveRecoveryRef.current = observed.recoveryState;
if (observed.signal === "wait-for-store") {
// The backend is still publishing this exact acquisition. Preserve
// the receiver and its partially replayed store instead of throwing
// away startup work on every fixed timeout.
armLiveRecordingOpenTimer();
return;
}
if (observed.signal === "refresh-receiver") {
// Publication is healthy, so this is presentation-only maintenance:
// refresh the aged native receiver without spending (or clearing)
// recovery debt.
recordingOpenTimedOut = true;
refreshOpeningLiveReceiver(observed.openForMs);
return;
}
recordingOpenTimedOut = true;
requestLiveRecovery("recording-open-timeout");
}, LIVE_RECEIVER_OPEN_MAX_AGE_MS);
};
const clearLiveRecordingDiscoveryTimer = () => {
if (liveRecordingDiscoveryTimer !== undefined) {
window.clearInterval(liveRecordingDiscoveryTimer);
liveRecordingDiscoveryTimer = undefined;
}
const clearLiveRecordingDiscoveryTimer = diagnosticLifecycle.clearAdmissionInterval;
const clearLiveRecoveryRetryTimer = () => {
if (liveRecoveryRetryTimer === undefined) return;
window.clearTimeout(liveRecoveryRetryTimer);
liveRecoveryRetryTimer = undefined;
};
const clearRecordingTimers = () => {
clearRecordedAdmissionWatchdog();
clearLiveRecordingOpenTimer();
clearLiveRecordingDiscoveryTimer();
clearLiveRecoveryRetryTimer();
};
const clearPlaybackRangeTimer = () => {
if (playbackRangeTimer === undefined) return;
@@ -989,7 +1117,7 @@ export function RerunViewport({
const reportError = (message: string, failureStage?: LiveViewerFailureStage) => {
if (disposed) return;
if (followLive) {
postLiveViewerDiagnostic({
diagnosticLifecycle.post({
eventCode: "live_receiver_error",
failureStage,
streamId: liveStreamIdRef.current,
@@ -1008,7 +1136,7 @@ export function RerunViewport({
) => {
if (!followLive || disposed) return false;
if (emitErrorEvent) {
postLiveViewerDiagnostic({
diagnosticLifecycle.post({
eventCode: "live_receiver_error",
failureStage,
streamId: liveStreamIdRef.current,
@@ -1018,10 +1146,20 @@ export function RerunViewport({
recoveryAttempt: liveRecoveryRef.current.attempts || null,
});
}
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current);
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, {
activeAuthorityIdentity: liveRecoveryAuthorityRef.current,
expectedAuthorityIdentity: liveRecoveryAuthorityIdentity,
disposed,
});
liveRecoveryRef.current = recovery.state;
if (recovery.signal === "stale") {
// A newer runtime snapshot owns the next effect. This retired viewer
// may release itself but cannot schedule a receiver for that lineage.
disposeViewer?.();
return true;
}
if (recovery.signal === "exhausted") {
postLiveViewerDiagnostic({
diagnosticLifecycle.post({
eventCode: "live_receiver_recovery_exhausted",
failureStage,
streamId: liveStreamIdRef.current,
@@ -1037,7 +1175,7 @@ export function RerunViewport({
);
return true;
}
postLiveViewerDiagnostic({
diagnosticLifecycle.post({
eventCode: "live_receiver_restart_requested",
failureStage,
streamId: liveStreamIdRef.current,
@@ -1052,7 +1190,15 @@ export function RerunViewport({
"Живой визуализатор переподключается к продолжающемуся потоку.",
);
disposeViewer?.();
setRetryNonce((nonce) => nonce + 1);
clearLiveRecoveryRetryTimer();
liveRecoveryRetryTimer = window.setTimeout(() => {
liveRecoveryRetryTimer = undefined;
if (
disposed
|| liveRecoveryAuthorityRef.current !== liveRecoveryAuthorityIdentity
) return;
setRetryNonce((nonce) => nonce + 1);
}, recovery.delayMs ?? 0);
return true;
};
const observeLiveReceiver = (viewerRangeMaxNs: number | null) => {
@@ -1072,7 +1218,7 @@ export function RerunViewport({
(receiverOpenedAfterRecovery || observed.signal === "receiver-advanced") &&
liveRecoveryRef.current.awaitingRecovery
) {
postLiveViewerDiagnostic({
diagnosticLifecycle.post({
eventCode: "live_receiver_recovered",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
@@ -1084,7 +1230,7 @@ export function RerunViewport({
}
if (observed.signal !== "stalled") return;
postLiveViewerDiagnostic({
diagnosticLifecycle.post({
eventCode: "live_receiver_stalled",
failureStage: "receiver-stalled",
streamId: liveStreamIdRef.current,
@@ -1093,12 +1239,30 @@ export function RerunViewport({
stalledForMs: Math.round(observed.stalledForMs),
recoveryAttempt: Math.min(
liveRecoveryRef.current.attempts + 1,
LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
liveRecoveryAuthorityIdentity
? Number.MAX_SAFE_INTEGER
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
),
});
requestLiveRecovery("receiver-stalled", observed.stalledForMs, false);
};
let relinquishLiveViewerOwnership: () => void = () => {};
const disposeActiveViewerLifecycle = () => {
if (disposed) return;
disposed = true;
relinquishLiveViewerOwnership();
diagnosticLifecycle.dispose();
disposeViewer?.();
host.replaceChildren();
};
if (followLive) {
relinquishLiveViewerOwnership = claimExclusiveLiveViewer(
disposeActiveViewerLifecycle,
);
}
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
host.replaceChildren();
appliedPointColorKeyRef.current = null;
setStatus("loading");
@@ -1113,10 +1277,7 @@ export function RerunViewport({
if (disposed) return;
const viewer = new WebViewer();
let viewerDisposed = false;
disposeViewer = () => {
if (viewerDisposed) return;
viewerDisposed = true;
disposeViewer = createReentrantViewerDisposer(() => {
clearRecordingTimers();
clearPlaybackRangeTimer();
playbackTimeUpdates.cancel();
@@ -1150,6 +1311,7 @@ export function RerunViewport({
} catch {
// The viewer may already have closed all auxiliary channels.
}
}, () => {
try {
if (viewer.ready) viewer.close(resolvedSource);
} catch {
@@ -1162,7 +1324,7 @@ export function RerunViewport({
// startup failure.
}
host.replaceChildren();
};
});
if (isRecordedSource && recordedArtifact) {
recordedOpenWatchdog = createRecordedOpenWatchdog({
byteLength: recordedArtifact.byteLength,
@@ -1188,8 +1350,17 @@ export function RerunViewport({
) return;
recordingOpened = true;
if (!isRecordedSource) {
clearLiveRecordingOpenTimer();
clearLiveRecordingDiscoveryTimer();
diagnosticLifecycle.markAdmitted();
if (liveRecoveryRef.current.awaitingRecovery) {
diagnosticLifecycle.post({
eventCode: "live_receiver_recovered",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
viewerRangeMaxNs: latestLiveRangeMaxNs,
recoveryAttempt: liveRecoveryRef.current.attempts,
});
}
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
}
if (
recordedBlueprintUrl &&
@@ -1386,7 +1557,7 @@ export function RerunViewport({
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
// recording_open event to the JavaScript wrapper. The active store
// is the authoritative fallback and avoids hiding a ready canvas.
postLiveViewerDiagnostic({
diagnosticLifecycle.post({
eventCode: "live_receiver_active_store_admitted",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
@@ -1489,16 +1660,19 @@ export function RerunViewport({
}
if (!isRecordedSource) {
// Measure native store admission from the resolved viewer start,
// not from dynamic module import or React effect setup time.
liveOpenWatchdog = initialLiveReceiverOpenWatchdogState(
liveActivitySequenceRef.current,
Date.now(),
);
discoverActiveLiveRecording();
if (!recordingOpened) {
liveRecordingDiscoveryTimer = window.setInterval(
diagnosticLifecycle.armAdmissionInterval(
discoverActiveLiveRecording,
100,
);
recordingOpenTimer = window.setTimeout(() => {
recordingOpenTimedOut = true;
requestLiveRecovery("recording-open-timeout");
}, 12_000);
armLiveRecordingOpenTimer();
}
}
} catch {
@@ -1520,12 +1694,14 @@ export function RerunViewport({
});
return () => {
disposed = true;
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
activeViewerLifecycleRef.current = null;
}
disposeActiveViewerLifecycle();
clearRecordingTimers();
clearPlaybackRangeTimer();
playbackTimeUpdates.cancel();
unsubscribeAll();
disposeViewer?.();
onSelectionChange?.(null);
onPlaybackControllerChange?.(null);
onPlaybackChange?.(null);
@@ -1537,6 +1713,7 @@ export function RerunViewport({
followLive,
initialPlaybackStartSeconds,
liveStreamId,
liveRecoveryAuthorityIdentity,
onPlaybackChange,
onPlaybackControllerChange,
onSelectionChange,
@@ -6,14 +6,46 @@ import type { WorkspaceDefinition } from "../productModel";
interface ApplicationPanelActionsOptions {
definition: WorkspaceDefinition | null;
refreshRuntime: () => void;
resetConnectionScenario?: () => Promise<boolean>;
connectionScenarioResetting: boolean;
saveWorkspaceLayout: () => Promise<void>;
workspaceLayoutSaving: boolean;
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
}
export function deviceRuntimeUtilityAction({
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
}: Pick<
ApplicationPanelActionsOptions,
"refreshRuntime" | "resetConnectionScenario" | "connectionScenarioResetting"
>): ApplicationPanelUtilityAction {
return {
label: resetConnectionScenario
? connectionScenarioResetting
? "Сбрасываем подключение"
: "Сбросить подключение"
: "Обновить состояние локального контура",
icon: resetConnectionScenario && connectionScenarioResetting
? "activity"
: "refresh",
disabled: resetConnectionScenario && connectionScenarioResetting
? true
: undefined,
onClick: resetConnectionScenario
? connectionScenarioResetting
? () => undefined
: () => void resetConnectionScenario()
: refreshRuntime,
};
}
export function useApplicationPanelActions({
definition,
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
saveWorkspaceLayout,
workspaceLayoutSaving,
systemUtilityActions,
@@ -21,11 +53,11 @@ export function useApplicationPanelActions({
return useMemo(() => {
const actions: ApplicationPanelUtilityAction[] = [];
if (definition?.kind === "device") {
actions.push({
label: "Обновить состояние локального контура",
icon: "refresh",
onClick: refreshRuntime,
});
actions.push(deviceRuntimeUtilityAction({
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
}));
}
if (definition && ["spatial", "recordings"].includes(definition.kind)) {
actions.push({
@@ -40,6 +72,8 @@ export function useApplicationPanelActions({
}, [
definition,
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
saveWorkspaceLayout,
systemUtilityActions,
workspaceLayoutSaving,
@@ -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>;
}
+8
View File
@@ -23,6 +23,14 @@ export function phaseLabel(phase: RuntimePhase | null | undefined): string {
return phase ? phaseLabels[phase] : "Нет состояния";
}
export function localConnectionPhaseLabel(
phase: RuntimePhase | null | undefined,
): string {
if (phase === "configuring") return "Подключение";
if (phase === "connected") return "Подключение установлено";
return phaseLabel(phase);
}
export function phaseTone(phase: RuntimePhase | null | undefined): StatusTone {
if (!phase || phase === "unconfigured" || phase === "idle") return "neutral";
if (phase === "error") return "danger";
+2 -2
View File
@@ -190,8 +190,8 @@ export const workspaces: WorkspaceDefinition[] = [
{
id: "local-device",
root: "fleet",
label: "Локальное устройство",
title: "Локальное устройство",
label: "Подключение",
title: "Подключение",
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
icon: "network",
@@ -204,7 +204,105 @@
}
}
/* The shared shell switches to its compact overlay at 760 px, but Mission
Core's 20.75 rem navigation leaves less than the plugin's usable 32 rem
workspace before that point. Bridge the structural breakpoint locally so
761--929 px never renders a full sidebar beside a sub-32-rem device UI. */
@media (min-width: 761px) and (max-width: 929px) {
.nodedc-header-shell {
width: 100%;
padding: 0.85rem 1rem 0.65rem;
background: var(--nodedc-canvas);
}
.nodedc-header {
min-height: 8.6rem;
}
.nodedc-header__row {
min-height: 8.6rem;
grid-template-columns: minmax(0, 1fr) auto;
align-content: start;
row-gap: 0.65rem;
}
.nodedc-header__center {
order: 3;
grid-column: 1 / -1;
justify-content: stretch;
overflow-x: auto;
}
.nodedc-header__center .nodedc-segmented,
.nodedc-header__center .nodedc-header-navigation {
min-width: max-content;
}
.nodedc-app-shell {
--nodedc-app-header-height: 10.25rem;
--nodedc-app-page-pad: 0.7rem;
--nodedc-app-panel-gap: 0.7rem;
}
.nodedc-app-shell__stage {
padding: 0 var(--nodedc-app-page-pad) var(--nodedc-app-page-pad);
}
.nodedc-app-shell__navigation,
.nodedc-app-shell__content {
right: var(--nodedc-app-page-pad);
bottom: var(--nodedc-app-page-pad);
left: var(--nodedc-app-page-pad);
width: auto;
}
.nodedc-app-shell[data-navigation-open="true"] .nodedc-app-shell__stage,
.nodedc-app-shell[data-content-open="true"] .nodedc-app-shell__stage {
padding-left: var(--nodedc-app-page-pad);
pointer-events: none;
transform: translateX(calc(100vw + var(--nodedc-app-page-pad)));
}
.nodedc-app-shell[data-content-open="true"] .nodedc-app-shell__navigation {
opacity: 0;
pointer-events: none;
}
.nodedc-application-panel,
.nodedc-admin-panel {
width: 100%;
max-width: none;
}
.nodedc-application-panel {
padding: 0.75rem;
}
.nodedc-application-panel__titles p {
display: none;
}
.nodedc-application-panel__action:first-child {
display: none;
}
}
@media (max-width: 760px) {
/* The shared application panel is a grid. Its header has three fixed-size
controls, so the grid item's automatic min-content width can widen the
body past the panel padding on narrow phones. Keep both grid rows owned
by the panel's content box; long plugin content must wrap inside it. */
.nodedc-application-panel__head,
.nodedc-application-panel__body {
width: auto;
min-width: 0;
max-width: 100%;
}
.nodedc-application-panel__head {
grid-template-columns: minmax(0, 1fr) auto;
}
.polygon-review-lead,
.polygon-review-player__header,
.polygon-review-order,
@@ -37,6 +37,25 @@ interface ContourSnapshot {
error: string | null;
}
export function contourRuntimeAuthorityPresentation(
state: MissionRuntimeState | null,
) {
const deviceControlConnectivity = state?.deviceSession?.connectivity ?? null;
return {
aiActive: Boolean(
state?.sourceMode === "live"
&& state.phase === "streaming"
&& state.metrics?.aiFrameRateHz
&& state.metrics.aiFrameRateHz > 0
),
deviceControlConnectivity,
controlledDevice: state?.activeDevice
&& (deviceControlConnectivity === "connected" || deviceControlConnectivity === "degraded")
? state.activeDevice
: null,
};
}
function validControlPlaneHealth(value: unknown): value is ControlPlaneHealth {
if (!value || typeof value !== "object") return false;
const item = value as Partial<ControlPlaneHealth>;
@@ -155,16 +174,16 @@ export function ContourHealthWorkspace({
return () => window.clearInterval(timer);
}, [refresh]);
const aiActive = Boolean(
state?.metrics?.aiFrameRateHz
&& state.metrics.aiFrameRateHz > 0,
);
const {
aiActive,
deviceControlConnectivity,
controlledDevice,
} = contourRuntimeAuthorityPresentation(state);
const simulationWorker = snapshot?.simulationWorker ?? null;
const controlPlaneReady = Boolean(snapshot?.controlPlane?.ok);
const runtimeReady = snapshot?.pluginRuntimes.filter(
(runtime) => runtime.status === "ready",
).length ?? 0;
const connectedDevice = state?.activeDevice ?? null;
const processCount = (snapshot?.pluginRuntimes.length ?? 0) + 2;
const readyProcessCount = runtimeReady
+ (controlPlaneReady ? 1 : 0)
@@ -325,11 +344,16 @@ export function ContourHealthWorkspace({
</div>
</section>
{connectedDevice ? (
{controlledDevice ? (
<section className="contour-connected-device">
<span className="section-eyebrow">ПОДКЛЮЧЁННОЕ УСТРОЙСТВО</span>
<strong>{connectedDevice.displayName}</strong>
<small>{connectedDevice.endpointLabel ?? connectedDevice.modelId}</small>
<span className="section-eyebrow">ПОДТВЕРЖДЁННАЯ УПРАВЛЯЮЩАЯ СЕССИЯ</span>
<strong>{controlledDevice.displayName}</strong>
<small>
{controlledDevice.endpointLabel ?? controlledDevice.modelId}
{deviceControlConnectivity === "degraded"
? " · управление подтверждено, поток данных нарушен"
: " · управление подтверждено"}
</small>
</section>
) : null}
@@ -101,9 +101,8 @@ export function DeviceWorkspace({
<div className="device-plugin-slot">
<div className="device-plugin-slot__bar">
<div>
<span className="section-eyebrow">АКТИВНАЯ МОДЕЛЬ</span>
<strong>{selection.model.displayName}</strong>
<small>{selection.plugin.manifest.metadata.displayName} · v{selection.plugin.manifest.metadata.version}</small>
<span className="section-eyebrow">СЦЕНАРИЙ ПОДКЛЮЧЕНИЯ</span>
<strong>Модель выбрана</strong>
{selectionTransitionError ? (
<small className="device-plugin-slot__error" role="alert">
{selectionTransitionError}
@@ -16,6 +16,7 @@ import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import {
RerunViewport,
@@ -490,12 +491,14 @@ function SpatialWorkspace({
data-primary-focused={pointCloudFocused ? "true" : undefined}
data-media-maximized={floatingSourceMaximized ? "true" : undefined}
>
{sourceUrl.trim() && pointCloudVisible ? (
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
<RerunViewport
sourceUrl={sourceUrl}
recordedArtifact={recordedSource ? recordedReplay : null}
followLive={!recordedReplay && streamActive}
liveActivitySequence={metrics?.publishedFrameCount} liveStreamId={state?.spatialSource?.id}
liveActivitySequence={metrics?.publishedFrameCount}
liveStreamId={state?.spatialSource?.id}
liveRecoveryAuthorityIdentity={!recordedSource && streamActive ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) : null}
autoplayWhenReady={recordedSource}
presentationGate={recordedSessionGate}
expectedTimelineStartSeconds={recordedSource