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
+6 -4
View File
@@ -321,10 +321,12 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
очищается после успешного ответа и не сохраняется в URL/local storage.
- Bridge и Direct Connect выполняют только отдельно рассмотренную provisioning-
запись; Quick Connect не пишет в GATT и передаёт пароль короткоживущему
CoreWLAN helper только через stdin. Случайные GATT writes и автоматические
повторы запрещены.
- Bridge и Direct Connect выполняют только отдельно рассмотренную 99-байтную
provisioning-запись. Quick Connect выполняет отдельную рассмотренную
100-байтную AP-enable запись и после AP-ready допускает ровно одну системную
ассоциацию с заранее материализованным exact-firmware профилем. Пароль не
попадает в браузер/API/argv; системный Wi-Fi Keychain, post-write prompt и
автоматические повторы запрещены.
- MQTT data live/replay остаётся subscribe-only. Команды изолированы в
plugin-owned canonical control session и доступны только через отдельные
operator-present UI checkpoints.
+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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { after, before, test } from "node:test";
import React, { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createServer } from "vite";
let server;
let DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY;
let DevicePluginHostProvider;
let commitPersistedDeviceModelId;
let restorePersistedDeviceModelId;
const hostSourceUrl = new URL(
"../src/core/device-plugins/DevicePluginHost.tsx",
import.meta.url,
);
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY,
DevicePluginHostProvider,
commitPersistedDeviceModelId,
restorePersistedDeviceModelId,
} = await server.ssrLoadModule("/src/core/device-plugins/DevicePluginHost.tsx"));
});
after(async () => {
await server?.close();
});
function memoryStorage(seed = {}) {
const values = new Map(Object.entries(seed));
const calls = [];
return {
calls,
getItem(key) {
calls.push(["get", key]);
return values.get(key) ?? null;
},
setItem(key, value) {
calls.push(["set", key, value]);
values.set(key, value);
},
removeItem(key) {
calls.push(["remove", key]);
values.delete(key);
},
value(key) {
return values.get(key) ?? null;
},
};
}
function registryWith(...modelIds) {
const registered = new Set(modelIds);
return {
resolveModel(modelId) {
return registered.has(modelId) ? { model: { id: modelId } } : null;
},
};
}
function fakePlugin(modelId) {
function RuntimeProvider({ activeModel, children }) {
return createElement(
"div",
{ "data-active-model": activeModel?.id ?? "" },
children,
);
}
function ConnectionView() {
return null;
}
return {
manifest: {
apiVersion: "missioncore.nodedc/v1alpha1",
kind: "DevicePlugin",
metadata: {
id: "test.device.plugin",
version: "1.0.0",
displayName: "Test device",
},
spec: {
hostApiRange: "v1alpha1",
runtime: {
backendEntrypoint: "test.device:plugin",
isolation: "transitional-in-process",
},
permissions: [],
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
models: [{
id: modelId,
vendor: "Test",
displayName: "Test model",
category: "test",
description: "test",
verified: true,
capabilities: [],
ui: {
slot: "device.connection",
componentKey: "test.connection",
},
}],
},
},
RuntimeProvider,
connectionViews: { "test.connection": ConnectionView },
};
}
test("persisted model restore admits only an id in the current plugin registry", () => {
const key = DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY;
const registry = registryWith("xgrids.lixelkity-k1");
const valid = memoryStorage({ [key]: " xgrids.lixelkity-k1 " });
assert.equal(
restorePersistedDeviceModelId(registry, valid),
"xgrids.lixelkity-k1",
);
assert.equal(valid.calls.some(([operation]) => operation === "remove"), false);
for (const staleValue of ["removed.model", " "]) {
const stale = memoryStorage({ [key]: staleValue });
assert.equal(restorePersistedDeviceModelId(registry, stale), null);
assert.equal(stale.value(key), null, "a stale model id must be cleared");
}
assert.equal(restorePersistedDeviceModelId(registry, null), null);
});
test("fresh provider mount immediately activates the registry-validated persisted model", () => {
const modelId = "test.model.one";
const storage = memoryStorage({
[DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY]: modelId,
});
const previousWindow = globalThis.window;
globalThis.window = { localStorage: storage };
try {
const markup = renderToStaticMarkup(createElement(
DevicePluginHostProvider,
{ plugins: [fakePlugin(modelId)] },
createElement("span", null, "runtime child"),
));
assert.match(markup, /data-active-model="test\.model\.one"/);
} finally {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
test("successful selection commits and explicit clear removes the same durable key", () => {
const storage = memoryStorage();
commitPersistedDeviceModelId("xgrids.lixelkity-k1", storage);
assert.equal(
storage.value(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY),
"xgrids.lixelkity-k1",
);
commitPersistedDeviceModelId(null, storage);
assert.equal(storage.value(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY), null);
assert.deepEqual(
storage.calls.slice(-1)[0],
["remove", DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY],
);
});
test("unavailable browser storage fails closed without blocking host state", () => {
const denied = {
getItem() {
throw new Error("storage denied");
},
setItem() {
throw new Error("storage denied");
},
removeItem() {
throw new Error("storage denied");
},
};
assert.equal(
restorePersistedDeviceModelId(registryWith("xgrids.lixelkity-k1"), denied),
null,
);
assert.doesNotThrow(() =>
commitPersistedDeviceModelId("xgrids.lixelkity-k1", denied)
);
assert.doesNotThrow(() => commitPersistedDeviceModelId(null, denied));
});
test("host writes persistence only after plugin deactivation succeeds", () => {
const source = readFileSync(hostSourceUrl, "utf8");
const failedDeactivation = source.indexOf("if (!(await deactivate()))");
const admittedState = source.indexOf("setSelectedModelId(nextModelId);");
const durableCommit = source.indexOf(
"commitPersistedDeviceModelId(nextModelId, selectionStorage);",
admittedState,
);
assert.ok(failedDeactivation >= 0);
assert.ok(
failedDeactivation < admittedState && admittedState < durableCommit,
"failed deactivation branches must return before in-memory and durable commit",
);
assert.match(
source,
/if \(nextModelId === selectedModelId\) \{[\s\S]*?commitPersistedDeviceModelId\(nextModelId, selectionStorage\);[\s\S]*?return true;/,
"explicit clear must remove stale persistence even from an already-empty host",
);
assert.match(
source,
/useState<string \| null>\(\(\) =>\s*restorePersistedDeviceModelId\(registry, selectionStorage\)/,
"a fresh provider mount must restore before runtime providers receive activeModel",
);
});
@@ -0,0 +1,977 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { after, before, test } from "node:test";
import React, { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createServer } from "vite";
let server;
let activeStreamForceFinishAuthority;
let activeStreamForceFinishAuthorityMatches;
let activeStreamRecoveredBrowserAuthority;
let activeStreamRecoveryPresentation;
let activeStreamRecoveryPresentationAuthority;
let activeStreamRecoveryOwnsPresentationDecision;
let exactActiveStreamRecoveryLineage;
let formatActiveStreamRecoveryElapsed;
let suppressGenericErrorDuringActiveStreamRecovery;
let isXgridsActiveStreamRecovery;
let K1AcquisitionPipeline;
let K1SpatialControlsView;
let runSpatialActiveStreamForceFinish;
let shouldRenderK1GenericRuntimeError;
let shouldRenderK1OperationalPanels;
let xgridsK1Actions;
let xgridsK1Api;
const hookSourceUrl = new URL(
"../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
import.meta.url,
);
const acquisitionSourceUrl = new URL(
"../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
import.meta.url,
);
const recoverySurfaceSourceUrl = new URL(
"../../../plugins/xgrids-k1/frontend/src/components/ActiveStreamRecoverySurface.tsx",
import.meta.url,
);
const spatialControlsSourceUrl = new URL(
"../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
import.meta.url,
);
const connectionSourceUrl = new URL(
"../../../plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx",
import.meta.url,
);
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
activeStreamForceFinishAuthority,
activeStreamForceFinishAuthorityMatches,
activeStreamRecoveredBrowserAuthority,
activeStreamRecoveryPresentation,
activeStreamRecoveryPresentationAuthority,
activeStreamRecoveryOwnsPresentationDecision,
exactActiveStreamRecoveryLineage,
formatActiveStreamRecoveryElapsed,
suppressGenericErrorDuringActiveStreamRecovery,
} = await server.ssrLoadModule("@xgrids-k1/frontend/activeStreamRecovery.ts"));
({ isXgridsActiveStreamRecovery, xgridsK1Api } = await server.ssrLoadModule(
"@xgrids-k1/frontend/api.ts",
));
({ xgridsK1Actions } = await server.ssrLoadModule(
"@xgrids-k1/frontend/manifest.ts",
));
({ K1AcquisitionPipeline } = await server.ssrLoadModule(
"@xgrids-k1/frontend/components/K1AcquisitionPipeline.tsx",
));
({
K1SpatialControlsView,
runSpatialActiveStreamForceFinish,
} = await server.ssrLoadModule(
"@xgrids-k1/frontend/components/K1SpatialControls.tsx",
));
({
shouldRenderK1GenericRuntimeError,
shouldRenderK1OperationalPanels,
} = await server.ssrLoadModule(
"@xgrids-k1/frontend/XgridsK1Connection.tsx",
));
});
after(async () => {
await server?.close();
});
function recoveryContract(overrides = {}) {
return {
schema_version: "missioncore.xgrids-k1-active-stream-recovery/v1",
state: "reconnecting",
generation: 7,
acquisition_id: "acquisition-recovery-001",
attempt: 3,
started_at_utc: "2026-08-11T19:31:00Z",
elapsed_ms: 12_400,
reason_code: "read-only-rebind-in-progress",
force_finish_allowed: true,
automatic_read_only_rebind: true,
automatic_command_retry: false,
start_performed: false,
stop_performed: false,
ble_operation_performed: false,
network_mutation_performed: false,
runtime_producer_generation: 11,
camera_recovery: "owned",
camera_media_state: "pending-first-media",
camera_media_ready: false,
camera_epoch: {
generation: 7,
init_committed: true,
init_committed_age_ms: 250,
first_media_committed: false,
first_media_committed_age_ms: null,
committed_media_segment_count: 0,
last_media_segment_age_ms: null,
},
...overrides,
};
}
function recoveryState(recoveryOverrides = {}, stateOverrides = {}) {
return {
snapshot_runtime_id: "snapshot-runtime-recovery-001",
snapshot_revision: 43,
producer_generation: 11,
phase: "reconnecting",
source_mode: "live",
acquisition: {
acquisition_id: "acquisition-recovery-001",
device_id: "device-k1-001",
device_session_id: "device-session-001",
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
control_mode: "plugin-commanded",
requested_streams: ["spatial.point-cloud.live"],
target_host: "127.0.0.1",
duration_seconds: 0,
evidence_policy: "required",
state: "acquiring",
state_revision: 9,
cleanup_pending: false,
},
connection_recovery: recoveryContract(recoveryOverrides),
...stateOverrides,
};
}
function coldRestartPrePclState() {
const state = recoveryState({
generation: 1,
acquisition_id: "acquisition-before-backend-restart-001",
attempt: 0,
started_at_utc: "2026-08-14T00:31:00Z",
elapsed_ms: 450,
reason_code: "restart-receiver-awaiting-first-pcl",
runtime_producer_generation: 1,
camera_recovery: "inactive",
camera_media_state: "inactive",
camera_media_ready: false,
camera_epoch: null,
}, {
snapshot_runtime_id: "snapshot-runtime-after-cold-restart-001",
snapshot_revision: 2,
producer_generation: 1,
acquisition: {
...recoveryState().acquisition,
acquisition_id: "acquisition-before-backend-restart-001",
state: "awaiting_external_start",
state_revision: 4,
},
camera_preview: {
activation_admission: {
state: "waiting-for-first-authoritative-pcl",
basis: "post-rerun-publish-pcl-frame",
runtime_producer_generation: 1,
device_command_sent: false,
},
},
});
return state;
}
function coldRestartRecoveredState() {
const state = coldRestartPrePclState();
return {
...state,
snapshot_revision: state.snapshot_revision + 1,
phase: "live",
acquisition: {
...state.acquisition,
state: "acquiring",
state_revision: state.acquisition.state_revision + 1,
},
connection_recovery: {
...state.connection_recovery,
state: "recovered",
elapsed_ms: null,
reason_code: null,
force_finish_allowed: false,
camera_recovery: "owned",
camera_media_state: "pending-epoch",
camera_media_ready: false,
camera_epoch: null,
},
camera_preview: {
activation_admission: {
state: "activating",
basis: "post-rerun-publish-pcl-frame",
runtime_producer_generation: 1,
device_command_sent: false,
},
},
};
}
function controller(state, overrides = {}) {
return {
state,
pendingAction: null,
error: null,
physicalStopIntentSpent: false,
physicalStopInFlight: false,
closeApplicationControlSession: async () => false,
prepareCanonicalAcquisition: async () => false,
startPreparedAcquisition: async () => false,
startReplay: async () => false,
stop: async () => false,
stopLocalReceiver: async () => false,
forceFinishActiveStreamLocally: async () => false,
abort: async () => false,
...overrides,
};
}
function renderPipeline(state, overrides = {}) {
return renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
controller: controller(state, overrides),
desiredConnectionMode: "bridge",
openSpatialScene() {},
activateAutomaticSpatialSource() {},
}));
}
function renderSpatialControls(state, overrides = {}) {
return renderToStaticMarkup(createElement(K1SpatialControlsView, {
controller: controller(state, overrides),
}));
}
function buttonsWithText(markup, text) {
return (markup.match(/<button\b[\s\S]*?<\/button>/g) ?? [])
.filter((button) => button.includes(text));
}
function sourceSlice(source, startMarker, endMarker) {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start + startMarker.length);
assert.notEqual(start, -1, `missing source marker: ${startMarker}`);
assert.notEqual(end, -1, `missing source marker: ${endMarker}`);
return source.slice(start, end);
}
test("active recovery contract is strict about all no-write invariants", () => {
assert.equal(isXgridsActiveStreamRecovery(recoveryContract()), true);
for (const field of [
"automatic_command_retry",
"start_performed",
"stop_performed",
"ble_operation_performed",
"network_mutation_performed",
]) {
assert.equal(
isXgridsActiveStreamRecovery(recoveryContract({ [field]: true })),
false,
field,
);
}
assert.equal(
isXgridsActiveStreamRecovery(recoveryContract({ state: "retrying-command" })),
false,
);
assert.equal(
isXgridsActiveStreamRecovery(recoveryContract({ elapsed_ms: -1 })),
false,
);
const missingMediaState = recoveryContract();
delete missingMediaState.camera_media_state;
assert.equal(isXgridsActiveStreamRecovery(missingMediaState), false);
assert.equal(
isXgridsActiveStreamRecovery(recoveryContract({ camera_media_ready: true })),
false,
);
assert.equal(
isXgridsActiveStreamRecovery(recoveryContract({
camera_media_state: "ready",
camera_media_ready: true,
camera_epoch: {
...recoveryContract().camera_epoch,
generation: 0,
first_media_committed: true,
first_media_committed_age_ms: 1,
committed_media_segment_count: 1,
last_media_segment_age_ms: 1,
},
})),
false,
);
});
test("cold restart before first PCL owns exact reconnect UI without camera or physical actions", () => {
const state = coldRestartPrePclState();
assert.equal(isXgridsActiveStreamRecovery(state.connection_recovery), true);
const lineage = exactActiveStreamRecoveryLineage(state);
assert.deepEqual(lineage && {
runtime: lineage.snapshotRuntimeId,
acquisition: lineage.acquisitionId,
revision: lineage.acquisitionStateRevision,
recovery: lineage.recoveryGeneration,
producer: lineage.runtimeProducerGeneration,
}, {
runtime: "snapshot-runtime-after-cold-restart-001",
acquisition: "acquisition-before-backend-restart-001",
revision: 4,
recovery: 1,
producer: 1,
});
assert.notEqual(activeStreamRecoveryPresentationAuthority(state), null);
assert.notEqual(activeStreamForceFinishAuthority(state), null);
assert.equal(activeStreamRecoveredBrowserAuthority(state), null);
assert.equal(state.connection_recovery.camera_recovery, "inactive");
assert.equal(state.connection_recovery.camera_media_state, "inactive");
assert.equal(state.connection_recovery.camera_media_ready, false);
assert.equal(state.connection_recovery.camera_epoch, null);
assert.deepEqual(state.camera_preview.activation_admission, {
state: "waiting-for-first-authoritative-pcl",
basis: "post-rerun-publish-pcl-frame",
runtime_producer_generation: 1,
device_command_sent: false,
});
const pipeline = renderPipeline(state);
assert.match(pipeline, /Восстанавливаем соединение/);
assert.match(pipeline, /START, STOP, Bluetooth и настройки устройства не отправляются/);
assert.equal(buttonsWithText(pipeline, "Прервать соединение").length, 1);
assert.equal(buttonsWithText(pipeline, "Запустить приём").length, 0);
assert.equal(buttonsWithText(pipeline, "Остановить устройство и запись").length, 0);
assert.equal(buttonsWithText(pipeline, "Остановить сканирование").length, 0);
assert.doesNotMatch(
pipeline,
/СВЯЗЬ ВОССТАНОВЛЕНА|Связь восстановлена · приём продолжается|Продолжаем тот же приём/,
);
const spatial = renderSpatialControls(state);
assert.match(spatial, /Восстанавливаем соединение/);
assert.equal(buttonsWithText(spatial, "Прервать соединение").length, 1);
assert.doesNotMatch(
spatial,
/Остановить устройство|Остановить K1|Завершить локальный приём/,
);
});
test("cold restart first PCL preserves lineage and renders recovered continuation copy", () => {
const beforePcl = coldRestartPrePclState();
const state = coldRestartRecoveredState();
assert.equal(isXgridsActiveStreamRecovery(state.connection_recovery), true);
assert.equal(
state.connection_recovery.acquisition_id,
beforePcl.connection_recovery.acquisition_id,
);
assert.equal(
state.connection_recovery.generation,
beforePcl.connection_recovery.generation,
);
assert.equal(
state.connection_recovery.runtime_producer_generation,
beforePcl.connection_recovery.runtime_producer_generation,
);
const lineage = exactActiveStreamRecoveryLineage(state);
const browserAuthority = activeStreamRecoveredBrowserAuthority(state);
assert.deepEqual(lineage && {
runtime: lineage.snapshotRuntimeId,
acquisition: lineage.acquisitionId,
revision: lineage.acquisitionStateRevision,
recovery: lineage.recoveryGeneration,
producer: lineage.runtimeProducerGeneration,
}, {
runtime: "snapshot-runtime-after-cold-restart-001",
acquisition: "acquisition-before-backend-restart-001",
revision: 5,
recovery: 1,
producer: 1,
});
assert.deepEqual(browserAuthority, lineage);
assert.equal(activeStreamRecoveryPresentationAuthority(state), null);
assert.equal(activeStreamForceFinishAuthority(state), null);
assert.equal(state.connection_recovery.camera_recovery, "owned");
assert.equal(state.camera_preview.activation_admission.device_command_sent, false);
const pipeline = renderPipeline(state);
assert.doesNotMatch(pipeline, /Восстанавливаем соединение|Прервать соединение/);
assert.match(pipeline, /СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ/);
assert.match(pipeline, /Связь восстановлена · приём продолжается/);
assert.match(pipeline, /Продолжаем тот же приём без нового START/);
assert.doesNotMatch(pipeline, /Назовите проект и запустите приём|Запустить приём/);
});
test("force-finish authority requires exact runtime, acquisition, revision and generations", () => {
const state = recoveryState();
const authority = activeStreamForceFinishAuthority(state);
assert.deepEqual(authority && {
snapshotRuntimeId: authority.snapshotRuntimeId,
acquisitionId: authority.acquisitionId,
acquisitionStateRevision: authority.acquisitionStateRevision,
recoveryGeneration: authority.recoveryGeneration,
runtimeProducerGeneration: authority.runtimeProducerGeneration,
}, {
snapshotRuntimeId: "snapshot-runtime-recovery-001",
acquisitionId: "acquisition-recovery-001",
acquisitionStateRevision: 9,
recoveryGeneration: 7,
runtimeProducerGeneration: 11,
});
assert.equal(activeStreamForceFinishAuthorityMatches(authority, state), true);
const staleCases = [
(() => {
const value = structuredClone(state);
value.snapshot_runtime_id = "snapshot-runtime-recovery-002";
return value;
})(),
(() => {
const value = structuredClone(state);
value.acquisition.acquisition_id = "acquisition-recovery-002";
return value;
})(),
(() => {
const value = structuredClone(state);
value.acquisition.state_revision += 1;
return value;
})(),
(() => {
const value = structuredClone(state);
value.connection_recovery.generation += 1;
return value;
})(),
(() => {
const value = structuredClone(state);
value.producer_generation += 1;
return value;
})(),
];
for (const stale of staleCases) {
assert.equal(activeStreamForceFinishAuthorityMatches(authority, stale), false);
}
assert.equal(
activeStreamForceFinishAuthority(recoveryState({ state: "recovered", force_finish_allowed: false })),
null,
);
assert.equal(
activeStreamForceFinishAuthority(recoveryState({ force_finish_allowed: false })),
null,
);
});
test("reconnecting presentation is exact and owns stale supervisor projection", () => {
const state = recoveryState({ force_finish_allowed: false });
const authority = activeStreamRecoveryPresentationAuthority(state);
assert.deepEqual(authority && {
runtime: authority.snapshotRuntimeId,
acquisition: authority.acquisitionId,
producer: authority.runtimeProducerGeneration,
recovery: authority.recoveryGeneration,
}, {
runtime: "snapshot-runtime-recovery-001",
acquisition: "acquisition-recovery-001",
producer: 11,
recovery: 7,
});
assert.equal(activeStreamRecoveryOwnsPresentationDecision(state), true);
const wrongProducer = structuredClone(state);
wrongProducer.producer_generation += 1;
assert.equal(activeStreamRecoveryPresentationAuthority(wrongProducer), null);
assert.equal(
activeStreamRecoveryOwnsPresentationDecision(wrongProducer),
true,
"a valid reconnect contract must block stale ordinary data projection",
);
for (const recoveryStateName of [
"blocked",
"standby",
"fault",
"force-finishing",
"force-finished",
]) {
const terminal = recoveryState({ state: recoveryStateName });
assert.equal(activeStreamRecoveryPresentationAuthority(terminal), null);
assert.equal(activeStreamRecoveryOwnsPresentationDecision(terminal), true);
}
assert.equal(
activeStreamRecoveryOwnsPresentationDecision(recoveryState({ state: "recovered" })),
false,
);
});
test("recovered browser carryover keeps exact lineage without restoring recovery controls", () => {
const state = recoveryState({
state: "recovered",
force_finish_allowed: false,
elapsed_ms: null,
reason_code: null,
}, {
phase: "live",
});
const authority = activeStreamRecoveredBrowserAuthority(state);
assert.deepEqual(authority && {
runtime: authority.snapshotRuntimeId,
acquisition: authority.acquisitionId,
revision: authority.acquisitionStateRevision,
producer: authority.runtimeProducerGeneration,
recovery: authority.recoveryGeneration,
}, {
runtime: "snapshot-runtime-recovery-001",
acquisition: "acquisition-recovery-001",
revision: 9,
producer: 11,
recovery: 7,
});
assert.equal(activeStreamRecoveryPresentation(state), null);
assert.equal(activeStreamForceFinishAuthority(state), null);
const staleProducer = structuredClone(state);
staleProducer.producer_generation += 1;
assert.equal(activeStreamRecoveredBrowserAuthority(staleProducer), null);
assert.equal(
activeStreamRecoveredBrowserAuthority(recoveryState({
state: "recovered",
camera_recovery: "blocked",
force_finish_allowed: false,
}, { phase: "live" })),
null,
);
});
test("only an exact reconnecting lineage suppresses the generic red error", () => {
const state = recoveryState();
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(state, null), true);
assert.equal(
shouldRenderK1GenericRuntimeError("Ошибка локального приёмника", false, state, null),
false,
);
assert.equal(
suppressGenericErrorDuringActiveStreamRecovery(state, "force-finish"),
false,
);
assert.equal(
shouldRenderK1GenericRuntimeError(
"Локальное завершение не выполнено",
false,
state,
"force-finish",
),
true,
"a failed explicit local finish must keep the generic error banner visible",
);
const wrongProducer = structuredClone(state);
wrongProducer.producer_generation += 1;
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(wrongProducer), false);
const wrongAcquisition = structuredClone(state);
wrongAcquisition.connection_recovery.acquisition_id = "acquisition-stale";
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(wrongAcquisition), false);
const missingRuntime = structuredClone(state);
delete missingRuntime.snapshot_runtime_id;
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(missingRuntime), false);
const blocked = recoveryState({ state: "blocked" });
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(blocked), false);
const nonOwned = recoveryState({ automatic_read_only_rebind: false });
assert.equal(suppressGenericErrorDuringActiveStreamRecovery(nonOwned), false);
const inactiveCases = [
recoveryState({}, { phase: "error" }),
recoveryState({}, { source_mode: "idle" }),
recoveryState({}, {
acquisition: {
...state.acquisition,
state: "failed",
},
}),
];
for (const inactive of inactiveCases) {
assert.equal(
suppressGenericErrorDuringActiveStreamRecovery(inactive, null),
false,
"stale recovery projection must fail open to the error banner",
);
}
});
test("reconnecting presentation is neutral, timed and exposes explicit local finish", () => {
const state = recoveryState();
const presentation = activeStreamRecoveryPresentation(state);
assert.equal(presentation?.state, "reconnecting");
assert.equal(presentation?.tone, "neutral");
assert.equal(presentation?.progressLabel, "Попытка 3 · 12 с");
assert.equal(presentation?.forceFinishAvailable, true);
const markup = renderPipeline(state);
assert.match(markup, /Восстанавливаем соединение/);
assert.match(markup, /Попытка 3 · 12 с/);
assert.match(markup, /class="nodedc-activity-indicator/);
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 1);
assert.match(markup, /START, STOP, Bluetooth и настройки устройства не отправляются/);
assert.doesNotMatch(markup, /Ошибка локального приёмника/);
assert.equal(shouldRenderK1OperationalPanels(state), true);
});
test("spatial scene owns the same recovery spinner and explicit local finish", () => {
const markup = renderSpatialControls(recoveryState());
assert.match(markup, /Восстанавливаем соединение/);
assert.match(markup, /Попытка 3 · 12 с/);
assert.match(markup, /class="nodedc-activity-indicator/);
assert.match(markup, /data-recovery-state="reconnecting"/);
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 1);
assert.match(markup, /локальный front\/back-приём/);
assert.doesNotMatch(
markup,
/Остановить устройство|Остановить K1|Завершить локальный приём/,
"recovery must not expose canonical STOP or the generic receiver stop",
);
});
test("spatial blocked and fault recovery copy is terminal and truthful", () => {
const blocked = renderSpatialControls(recoveryState({
state: "blocked",
reason_code: "exact-binding-changed",
}));
assert.match(blocked, /Связь не восстановлена/);
assert.match(blocked, /Восстановление остановлено/);
assert.doesNotMatch(blocked, /class="nodedc-activity-indicator/);
assert.equal(buttonsWithText(blocked, "Прервать соединение").length, 1);
const fault = renderSpatialControls(recoveryState({
state: "fault",
force_finish_allowed: false,
reason_code: "active-stream-recovery-system-error",
}));
assert.match(fault, /K1 сообщил об ошибке/);
assert.match(fault, /Автоматических команд и повторов нет/);
assert.equal(buttonsWithText(fault, "Прервать соединение").length, 0);
});
test("spatial recovery interaction routes only to exact local force-finish", async () => {
let forceFinishCalls = 0;
const current = recoveryState();
const invoked = await runSpatialActiveStreamForceFinish({
state: current,
forceFinishActiveStreamLocally: async () => {
forceFinishCalls += 1;
return true;
},
});
assert.equal(invoked, true);
assert.equal(forceFinishCalls, 1);
const stale = structuredClone(current);
stale.connection_recovery.runtime_producer_generation += 1;
const rejected = await runSpatialActiveStreamForceFinish({
state: stale,
forceFinishActiveStreamLocally: async () => {
forceFinishCalls += 1;
return true;
},
});
assert.equal(rejected, false);
assert.equal(forceFinishCalls, 1, "stale lineage must not dispatch any action");
});
test("spatial force-finish pending owns the surface without a second action", () => {
const markup = renderSpatialControls(
recoveryState({
state: "force-finishing",
acquisition_id: null,
force_finish_allowed: false,
automatic_read_only_rebind: false,
camera_recovery: "inactive",
}),
{ pendingAction: "force-finish" },
);
assert.match(markup, /Завершаем локальный приём/);
assert.match(markup, /data-recovery-state="force-finishing"/);
assert.match(markup, /Команда STOP устройству не отправляется/);
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 0);
});
test("blocked, camera-blocked, standby and fault copy stay truthful", () => {
const blocked = recoveryState({ state: "blocked", reason_code: "exact-binding-changed" });
const blockedMarkup = renderPipeline(blocked);
assert.match(blockedMarkup, /Связь не восстановлена/);
assert.match(blockedMarkup, /Восстановление остановлено/);
assert.doesNotMatch(blockedMarkup, /class="nodedc-activity-indicator/);
assert.equal(buttonsWithText(blockedMarkup, "Прервать соединение").length, 1);
const cameraBlocked = recoveryState({
state: "blocked",
camera_recovery: "blocked",
reason_code: "camera-recovery-failed",
});
assert.match(renderPipeline(cameraBlocked), /Видеопоток не восстановлен/);
const standby = recoveryState({
state: "standby",
force_finish_allowed: false,
reason_code: "device-reported-standby",
});
const standbyMarkup = renderPipeline(standby);
assert.match(standbyMarkup, /Устройство перешло в ожидание/);
assert.match(standbyMarkup, /без команды STOP/);
assert.equal(buttonsWithText(standbyMarkup, "Прервать соединение").length, 0);
assert.equal(shouldRenderK1OperationalPanels(standby), true);
const fault = recoveryState({
state: "fault",
force_finish_allowed: false,
reason_code: "active-stream-recovery-system-error",
});
const faultMarkup = renderPipeline(fault);
assert.match(faultMarkup, /K1 сообщил об ошибке/);
assert.match(faultMarkup, /Автоматических команд и повторов нет/);
assert.equal(buttonsWithText(faultMarkup, "Прервать соединение").length, 0);
assert.equal(shouldRenderK1OperationalPanels(fault), true);
});
test("recovered active lineage renders the continued session and one exact STOP", () => {
const state = recoveryState({
state: "recovered",
force_finish_allowed: false,
elapsed_ms: null,
reason_code: null,
}, {
phase: "live",
compatibility: {
vendor_writes_enabled: true,
permitted_mode: "active-control",
},
application_control_session: {
session_generation: 5,
state_revision: 8,
state: "scanning",
can_stop: true,
control_socket_open: true,
},
connection_policy: {
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
facts: { retained_context_is_presence: false },
allowed_actions: ["stop-acquisition"],
actions: {
"stop-acquisition": {
allowed: true,
reason_codes: [],
target_source: "connection-supervisor",
required_transport_ref: null,
required_connection_mode: null,
requires_live_gatt_validation: false,
automatic_retry: false,
},
},
},
});
assert.equal(activeStreamRecoveryPresentation(state), null);
const markup = renderPipeline(state);
assert.doesNotMatch(markup, /Восстанавливаем соединение|Прервать соединение/);
assert.match(markup, /СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ/);
assert.match(markup, /Связь восстановлена · приём продолжается/);
assert.match(markup, /Продолжаем тот же приём без нового START/);
assert.doesNotMatch(markup, /Назовите проект и запустите приём|Запустить приём/);
const stopButtons = buttonsWithText(markup, "Остановить устройство и запись");
assert.equal(stopButtons.length, 1);
assert.doesNotMatch(stopButtons[0], /\bdisabled(?:=|\s|>)/);
});
test("stale recovered marker on an idle released runtime fails closed to idle UI", () => {
const state = recoveryState({
state: "recovered",
force_finish_allowed: false,
elapsed_ms: null,
reason_code: null,
}, {
phase: "idle",
source_mode: "idle",
acquisition: {
...recoveryState().acquisition,
state: "completed",
cleanup_pending: false,
},
application_control_session: {
session_generation: 5,
state_revision: 9,
state: "completed",
can_stop: false,
control_socket_open: false,
},
});
assert.equal(activeStreamRecoveryPresentation(state), null);
const markup = renderPipeline(state);
assert.doesNotMatch(
markup,
/СВЯЗЬ ВОССТАНОВЛЕНА|Связь восстановлена · приём продолжается|Продолжаем тот же приём/,
);
assert.match(markup, /Назовите проект и запустите приём/);
assert.equal(buttonsWithText(markup, "Остановить устройство и запись").length, 0);
assert.equal(buttonsWithText(markup, "Остановить сканирование").length, 0);
});
test("force-finishing shows one local-only pending owner and no second action", () => {
const state = recoveryState({
state: "force-finishing",
acquisition_id: null,
force_finish_allowed: false,
automatic_read_only_rebind: false,
runtime_producer_generation: 11,
camera_recovery: "inactive",
});
const markup = renderPipeline(state, { pendingAction: "force-finish" });
assert.match(markup, /Завершаем локальный приём/);
assert.match(markup, /Команда STOP устройству не отправляется/);
assert.match(markup, /class="nodedc-activity-indicator/);
assert.equal(buttonsWithText(markup, "Прервать соединение").length, 0);
});
test("elapsed presentation is deterministic", () => {
assert.equal(formatActiveStreamRecoveryElapsed(null), null);
assert.equal(formatActiveStreamRecoveryElapsed(-1), null);
assert.equal(formatActiveStreamRecoveryElapsed(999), "0 с");
assert.equal(formatActiveStreamRecoveryElapsed(59_999), "59 с");
assert.equal(formatActiveStreamRecoveryElapsed(60_000), "1 мин");
assert.equal(formatActiveStreamRecoveryElapsed(125_900), "2 мин 5 с");
});
test("force-finish manifest/API sends the exact fenced local-only request", async () => {
assert.equal(
xgridsK1Actions.acquisitionForceFinishLocal,
"acquisition.force-finish-local",
);
const request = {
expected_snapshot_runtime_id: "snapshot-runtime-recovery-001",
acquisition_id: "acquisition-recovery-001",
expected_state_revision: 9,
expected_recovery_generation: 7,
operator_confirmed: true,
operation_id: "op-00000000-0000-4000-8000-000000000321",
idempotency_key:
"acquisition.force-finish-local:op-00000000-0000-4000-8000-000000000321",
deadline_seconds: 30,
};
let capturedUrl = null;
let capturedInit = null;
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
capturedUrl = String(input);
capturedInit = init;
return new Response(JSON.stringify({ state: recoveryState() }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await xgridsK1Api.forceFinishAcquisitionLocally(request);
} finally {
globalThis.fetch = originalFetch;
}
assert.match(
capturedUrl,
/\/actions\/acquisition\.force-finish-local$/,
);
assert.equal(capturedInit.method, "POST");
assert.deepEqual(JSON.parse(capturedInit.body), { input: request });
});
test("state API rejects a drifted active recovery contract", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({
state: recoveryState({ stop_performed: true }),
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
try {
await assert.rejects(
() => xgridsK1Api.getState(),
/некорректное состояние/,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("frontend boundary keeps recovery automatic work read-only and local finish explicit", () => {
const hookSource = readFileSync(hookSourceUrl, "utf8");
const forceFinish = sourceSlice(
hookSource,
"const forceFinishActiveStreamLocally",
"const abort",
);
assert.match(forceFinish, /run\("force-finish"/);
assert.match(forceFinish, /activeStreamForceFinishAuthority\(latestState\.current\)/);
assert.match(forceFinish, /expected_snapshot_runtime_id:\s*authority\.snapshotRuntimeId/);
assert.match(forceFinish, /acquisition_id:\s*authority\.acquisitionId/);
assert.match(forceFinish, /expected_state_revision:\s*authority\.acquisitionStateRevision/);
assert.match(forceFinish, /expected_recovery_generation:\s*authority\.recoveryGeneration/);
assert.match(forceFinish, /operator_confirmed:\s*true/);
assert.match(
forceFinish,
/newMutationContext\("acquisition\.force-finish-local"\)/,
);
assert.equal(
(forceFinish.match(/forceFinishAcquisitionLocally\(/g) ?? []).length,
1,
);
assert.doesNotMatch(
forceFinish,
/startAcquisition|stopAcquisition|scanBle|selectCameraPreview|connect\(/,
);
const acquisitionSource = readFileSync(acquisitionSourceUrl, "utf8");
assert.equal(
(acquisitionSource.match(/forceFinishActiveStreamLocally\(\)/g) ?? []).length,
1,
"the explicit recovery button is the only frontend caller",
);
const recoverySurface = readFileSync(recoverySurfaceSourceUrl, "utf8");
assert.match(recoverySurface, /<ActivityIndicator/);
assert.match(recoverySurface, /<Button[\s\S]*?variant="secondary"/);
assert.doesNotMatch(recoverySurface, /<button\b|style=\{/);
const spatialSource = readFileSync(spatialControlsSourceUrl, "utf8");
assert.match(spatialSource, /variant="compact"/);
assert.match(
spatialSource,
/runSpatialActiveStreamForceFinish\(\{[\s\S]*?forceFinishActiveStreamLocally/,
);
const spatialForceFinish = sourceSlice(
spatialSource,
"export function runSpatialActiveStreamForceFinish",
"export function K1SpatialControlsView",
);
assert.match(spatialForceFinish, /activeStreamForceFinishAuthority\(controller\.state\)/);
assert.equal(
(spatialForceFinish.match(/controller\.forceFinishActiveStreamLocally\(\)/g) ?? []).length,
1,
);
assert.doesNotMatch(spatialForceFinish, /\bstop\(|stopLocalReceiver|start|connect|scanBle/);
const connectionSource = readFileSync(connectionSourceUrl, "utf8");
assert.match(
connectionSource,
/shouldRenderK1GenericRuntimeError\([\s\S]*?errorCorrelation\?\.action/,
);
assert.match(
connectionSource,
/\{showGenericRuntimeError && error \? \([\s\S]*?<K1OperatorError/,
);
});
File diff suppressed because it is too large Load Diff
@@ -4,9 +4,14 @@ import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let advanceLiveReceiverOpenWatchdog;
let advanceLiveReceiverWatchdog;
let initialLiveReceiverOpenWatchdogState;
let initialLiveReceiverWatchdogState;
let initialLiveReceiverRecoveryState;
let liveReceiverRecoveryAuthorityIsCurrent;
let liveReceiverRecoveryRetryDelay;
let liveRerunRecoveryAuthorityIdentity;
let requestLiveReceiverRecovery;
before(async () => {
@@ -16,9 +21,14 @@ before(async () => {
server: { middlewareMode: true },
});
({
advanceLiveReceiverOpenWatchdog,
advanceLiveReceiverWatchdog,
initialLiveReceiverOpenWatchdogState,
initialLiveReceiverRecoveryState,
initialLiveReceiverWatchdogState,
liveReceiverRecoveryAuthorityIsCurrent,
liveReceiverRecoveryRetryDelay,
liveRerunRecoveryAuthorityIdentity,
requestLiveReceiverRecovery,
} = await server.ssrLoadModule("/src/core/observation/liveReceiverWatchdog.ts"));
});
@@ -92,3 +102,217 @@ test("startup failures request only three bounded viewer restarts", () => {
assert.equal(exhausted.attempt, 3);
assert.equal(exhausted.state.awaitingRecovery, false);
});
function livePointCloudDescriptor(overrides = {}) {
return {
id: "xgrids-k1:lixelkity-k1:sensor.lidar.primary",
sourceId: "sensor.lidar.primary",
semanticChannelId: "spatial.point-cloud.live",
label: "K1 point cloud",
description: "live",
modality: "point-cloud",
role: "primary",
availability: "streaming",
transport: "rerun-grpc",
endpointLabel: "Rerun gRPC",
previewUrl: "rerun+http://127.0.0.1:9877/proxy",
delivery: null,
activation: null,
presentationLease: null,
provider: {
pluginId: "xgrids-k1",
pluginVersion: "0.1.0",
modelId: "lixelkity-k1",
compatibilityProfileId: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
},
binding: {
deviceId: "device-k1-001",
deviceSessionId: "device-session-001",
acquisitionId: "acquisition-001",
},
capabilities: {
overlay: false,
fullscreen: true,
resizable: false,
defaultVisible: true,
timelineMode: "live-only",
seekable: false,
sessionRecording: false,
clockId: "acquisition-001",
spatialRegistration: "native",
},
...overrides,
};
}
const liveSpatialSource = {
id: "acquisition-001",
url: "rerun+http://127.0.0.1:9877/proxy",
label: "Live",
kind: "rerun-grpc",
};
test("exact live Rerun authority gets durable retries with capped delay", () => {
const authority = liveRerunRecoveryAuthorityIdentity(
livePointCloudDescriptor(),
liveSpatialSource,
);
assert.ok(authority);
assert.equal(liveReceiverRecoveryAuthorityIsCurrent(authority, authority), true);
let state = initialLiveReceiverRecoveryState();
const delays = [];
for (let attempt = 1; attempt <= 8; attempt += 1) {
const recovery = requestLiveReceiverRecovery(state, {
activeAuthorityIdentity: authority,
expectedAuthorityIdentity: authority,
});
assert.equal(recovery.signal, "retry");
assert.equal(recovery.attempt, attempt);
delays.push(recovery.delayMs);
state = recovery.state;
}
assert.deepEqual(delays, [400, 1_000, 2_000, 5_000, 5_000, 5_000, 5_000, 5_000]);
assert.equal(state.attempts, 8);
assert.equal(liveReceiverRecoveryRetryDelay(100), 5_000);
});
test("Rerun durable retry fails closed when exact authority is replaced", () => {
const authority = liveRerunRecoveryAuthorityIdentity(
livePointCloudDescriptor(),
liveSpatialSource,
);
assert.ok(authority);
const replacement = liveRerunRecoveryAuthorityIdentity(
livePointCloudDescriptor({
binding: {
deviceId: "device-k1-001",
deviceSessionId: "device-session-002",
acquisitionId: "acquisition-002",
},
capabilities: {
...livePointCloudDescriptor().capabilities,
clockId: "acquisition-002",
},
}),
{ ...liveSpatialSource, id: "acquisition-002" },
);
assert.ok(replacement);
const stale = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState(), {
activeAuthorityIdentity: replacement,
expectedAuthorityIdentity: authority,
});
assert.equal(stale.signal, "stale");
assert.equal(stale.delayMs, null);
assert.deepEqual(stale.state, initialLiveReceiverRecoveryState());
});
test("connecting Rerun authority requires an exact recovery generation lease", () => {
const recoveryLease = {
kind: "active-stream-recovery",
runtimeId: "runtime-recovery-001",
acquisitionId: "acquisition-001",
acquisitionStateRevision: 4,
producerGeneration: 17,
recoveryGeneration: 6,
};
const recoveredAuthority = liveRerunRecoveryAuthorityIdentity(
livePointCloudDescriptor({
availability: "connecting",
presentationLease: recoveryLease,
}),
liveSpatialSource,
);
assert.ok(recoveredAuthority);
assert.equal(
liveRerunRecoveryAuthorityIdentity(
livePointCloudDescriptor({ availability: "connecting" }),
liveSpatialSource,
),
null,
);
assert.equal(
liveRerunRecoveryAuthorityIdentity(
livePointCloudDescriptor({
availability: "connecting",
presentationLease: { ...recoveryLease, producerGeneration: 0 },
}),
liveSpatialSource,
),
null,
);
});
test("opening receiver gets bounded rolling patience while backend publication advances", () => {
let openState = initialLiveReceiverOpenWatchdogState(0, 0);
let recoveryState = initialLiveReceiverRecoveryState();
const samples = [
[134, 3_999, "wait-for-store"],
[266, 4_000, "refresh-receiver"],
];
for (const [backendActivitySequence, nowMs, expectedSignal] of samples) {
const observed = advanceLiveReceiverOpenWatchdog(
openState,
recoveryState,
backendActivitySequence,
nowMs,
);
assert.equal(observed.signal, expectedSignal);
assert.equal(observed.state.lastBackendActivitySequence, backendActivitySequence);
assert.deepEqual(observed.recoveryState, {
attempts: 0,
awaitingRecovery: false,
});
openState = observed.state;
recoveryState = observed.recoveryState;
}
});
test("unchanged opening sequence delegates to bounded receiver restart", () => {
const openState = initialLiveReceiverOpenWatchdogState(486);
const recoveryState = initialLiveReceiverRecoveryState();
const unchanged = advanceLiveReceiverOpenWatchdog(
openState,
recoveryState,
486,
);
assert.equal(unchanged.signal, "restart-receiver");
const restart = requestLiveReceiverRecovery(unchanged.recoveryState);
assert.equal(restart.signal, "retry");
assert.equal(restart.attempt, 1);
});
test("fresh backend progress preserves earlier restart debt until viewer admission", () => {
const openState = initialLiveReceiverOpenWatchdogState(486, 0);
const consumedRestart = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState());
const observed = advanceLiveReceiverOpenWatchdog(
openState,
consumedRestart.state,
600,
3_999,
);
assert.equal(observed.signal, "wait-for-store");
assert.deepEqual(observed.recoveryState, {
attempts: 1,
awaitingRecovery: true,
});
});
test("aged active receiver refresh does not spend or erase restart debt", () => {
const openState = initialLiveReceiverOpenWatchdogState(486, 0);
const consumedRestart = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState());
const observed = advanceLiveReceiverOpenWatchdog(
openState,
consumedRestart.state,
900,
4_000,
);
assert.equal(observed.signal, "refresh-receiver");
assert.deepEqual(observed.recoveryState, consumedRestart.state);
assert.equal(observed.openForMs, 4_000);
});
@@ -0,0 +1,216 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let createLiveViewerDiagnosticLifecycle;
let createAbortFencedBuildVerifier;
let createUiBuildStaleCoordinator;
let liveViewerDiagnosticBody;
let server;
let uiBuildIdFromModuleScripts;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
createAbortFencedBuildVerifier,
createLiveViewerDiagnosticLifecycle,
createUiBuildStaleCoordinator,
liveViewerDiagnosticBody,
uiBuildIdFromModuleScripts,
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
});
after(async () => {
await server?.close();
});
function createFakeScheduler() {
let now = 0;
let nextHandle = 1;
const jobs = new Map();
const schedule = (callback, delay, interval) => {
const handle = nextHandle;
nextHandle += 1;
jobs.set(handle, { callback, due: now + delay, interval });
return handle;
};
const clear = (handle) => jobs.delete(handle);
return {
scheduler: {
setTimeout: (callback, delay) => schedule(callback, delay, null),
clearTimeout: clear,
setInterval: (callback, delay) => schedule(callback, delay, delay),
clearInterval: clear,
},
advance(milliseconds) {
const target = now + milliseconds;
while (true) {
const next = [...jobs.entries()]
.filter(([, job]) => job.due <= target)
.sort((left, right) => left[1].due - right[1].due)[0];
if (!next) break;
const [handle, job] = next;
now = job.due;
if (job.interval === null) jobs.delete(handle);
else job.due += job.interval;
job.callback();
}
now = target;
},
pending: () => jobs.size,
};
}
const lineage = (viewerInstanceId, lifecycleGeneration = 1) => ({
uiBuildId: "/assets/index-abcdefgh.js",
documentInstanceId: "00000000-0000-4000-8000-000000000001",
viewerInstanceId,
lifecycleGeneration,
});
test("mounted viewer admission terminally fences 60 seconds of stale timers", () => {
const clock = createFakeScheduler();
const callbacks = [];
const posts = [];
const lifecycle = createLiveViewerDiagnosticLifecycle({
lineage: lineage("00000000-0000-4000-8000-000000000011"),
scheduler: clock.scheduler,
diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
buildVerifier: () => undefined,
});
lifecycle.armAdmissionTimeout(() => callbacks.push("timeout"), 12_000);
lifecycle.armAdmissionInterval(() => callbacks.push("discovery"), 100);
lifecycle.markAdmitted();
lifecycle.post({ eventCode: "live_receiver_active_store_admitted" });
clock.advance(60_000);
assert.deepEqual(callbacks, []);
assert.equal(clock.pending(), 0);
assert.equal(posts.length, 1);
assert.equal(posts[0].eventLineage.lifecycleGeneration, 1);
});
test("two mounted viewers keep timer and diagnostic lineage isolated", () => {
const clock = createFakeScheduler();
const posts = [];
const first = createLiveViewerDiagnosticLifecycle({
lineage: lineage("00000000-0000-4000-8000-000000000021"),
scheduler: clock.scheduler,
diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
buildVerifier: () => undefined,
});
const second = createLiveViewerDiagnosticLifecycle({
lineage: lineage("00000000-0000-4000-8000-000000000022", 7),
scheduler: clock.scheduler,
diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
buildVerifier: () => undefined,
});
first.armAdmissionTimeout(() => {
first.post({ eventCode: "live_receiver_error" });
}, 12_000);
second.armAdmissionTimeout(() => {
second.post({ eventCode: "live_receiver_error" });
}, 12_000);
first.markAdmitted();
clock.advance(12_000);
assert.equal(posts.length, 1);
assert.equal(
posts[0].eventLineage.viewerInstanceId,
"00000000-0000-4000-8000-000000000022",
);
assert.equal(posts[0].eventLineage.lifecycleGeneration, 7);
});
test("stale-build and unmount fence callbacks before one reload", () => {
const clock = createFakeScheduler();
const order = [];
const posts = [];
const lifecycle = createLiveViewerDiagnosticLifecycle({
lineage: lineage("00000000-0000-4000-8000-000000000031"),
scheduler: clock.scheduler,
diagnosticPoster: (event) => posts.push(event),
buildVerifier: () => undefined,
});
lifecycle.armAdmissionTimeout(() => {
lifecycle.post({ eventCode: "live_receiver_error" });
}, 12_000);
const coordinator = createUiBuildStaleCoordinator({
scheduleReload: (callback, delay) => {
order.push(`scheduled:${delay}`);
clock.scheduler.setTimeout(callback, delay);
},
reload: () => order.push("reload"),
});
coordinator.subscribe(() => {
order.push("local-transports-closed");
lifecycle.dispose();
});
coordinator.report({
loadedUiBuildId: "/assets/index-abcdefgh.js",
expectedUiBuildId: "/assets/index-ijklmnop.js",
});
coordinator.report({
loadedUiBuildId: "/assets/index-abcdefgh.js",
expectedUiBuildId: "/assets/index-qrstuvwx.js",
});
clock.advance(60_000);
lifecycle.post({ eventCode: "live_receiver_error" });
assert.deepEqual(order, ["local-transports-closed", "scheduled:50", "reload"]);
assert.deepEqual(posts, []);
assert.equal(lifecycle.active(), false);
});
test("last unsubscribe fences an already queued build verification callback", () => {
const controller = new AbortController();
const observedSignals = [];
const queuedVerify = createAbortFencedBuildVerifier(
controller.signal,
(signal) => observedSignals.push(signal),
);
queuedVerify();
// stopBuildMonitor aborts the locally captured controller when the last
// mounted viewer unsubscribes. A browser callback already queued before the
// interval/listener removal can still run once, but cannot start a fetch.
controller.abort();
queuedVerify();
assert.deepEqual(observedSignals, [controller.signal]);
assert.equal(observedSignals[0].aborted, true);
});
test("diagnostic body and build id retain exact document/viewer/build lineage", () => {
const eventLineage = lineage("00000000-0000-4000-8000-000000000041", 9);
assert.deepEqual(
liveViewerDiagnosticBody(
{ eventCode: "live_receiver_recovered", streamId: "acquisition-42" },
eventLineage,
),
{
schema_version: "missioncore.live-viewer-diagnostic/v2",
event_code: "live_receiver_recovered",
ui_build_id: "/assets/index-abcdefgh.js",
document_instance_id: "00000000-0000-4000-8000-000000000001",
viewer_instance_id: "00000000-0000-4000-8000-000000000041",
lifecycle_generation: 9,
stream_id: "acquisition-42",
},
);
assert.equal(
uiBuildIdFromModuleScripts(
["https://mission.local/assets/index-dT7dN-y4.js"],
"https://mission.local/park",
),
"/assets/index-dT7dN-y4.js",
);
});
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,9 @@ import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let claimExclusiveLiveViewer;
let createRecordedOpenWatchdog;
let createReentrantViewerDisposer;
let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource;
let resolveRecordedViewerSourceUrl;
@@ -17,7 +19,9 @@ before(async () => {
server: { middlewareMode: true },
});
({
claimExclusiveLiveViewer,
createRecordedOpenWatchdog,
createReentrantViewerDisposer,
recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource,
resolveRecordedViewerSourceUrl,
@@ -118,6 +122,45 @@ test("complete recorded admission clears its watchdog", () => {
assert.deepEqual(cancelled, [23]);
});
test("deferred viewer start cannot reopen after stale unmount", async () => {
let resolveStart;
const start = new Promise((resolve) => {
resolveStart = resolve;
});
let disposed = false;
let cleanupCount = 0;
let closeCount = 0;
let stopCount = 0;
const diagnostics = [];
const disposeViewer = createReentrantViewerDisposer(
() => {
cleanupCount += 1;
},
() => {
closeCount += 1;
stopCount += 1;
},
);
const pendingMount = (async () => {
await start;
if (disposed) {
disposeViewer();
return;
}
diagnostics.push("admitted");
})();
disposed = true;
disposeViewer();
resolveStart();
await pendingMount;
assert.equal(cleanupCount, 1);
assert.equal(closeCount, 2);
assert.equal(stopCount, 2);
assert.deepEqual(diagnostics, []);
});
test("recorded RRD bytes are never split across LogChannel.send_rrd calls", async () => {
const source = await readFile(
new URL("../src/components/RerunViewport.tsx", import.meta.url),
@@ -129,7 +172,7 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
assert.match(
source,
/recordingOpened = true;[\s\S]*clearLiveRecordingOpenTimer\(\);[\s\S]*clearLiveRecordingDiscoveryTimer\(\);/,
/recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
);
assert.match(
source,
@@ -145,12 +188,31 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
);
});
test("one live document owns one native Rerun receiver", async () => {
const releases = [];
const releaseFirstClaim = claimExclusiveLiveViewer(() => releases.push("first"));
const releaseSecondClaim = claimExclusiveLiveViewer(() => releases.push("second"));
assert.deepEqual(releases, ["first"]);
releaseFirstClaim();
assert.deepEqual(releases, ["first"]);
releaseSecondClaim();
const releaseThirdClaim = claimExclusiveLiveViewer(() => releases.push("third"));
assert.deepEqual(releases, ["first"]);
releaseThirdClaim();
});
test("raw replay exercises the same streaming receiver lifecycle as a live scan", async () => {
const source = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
"utf8",
);
assert.match(source, /followLive=\{!recordedReplay && streamActive\}/);
assert.match(
source,
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
);
});
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
@@ -42,6 +42,113 @@ function snapshot(revision, generation, phase = "streaming", sessionId = "device
};
}
function stampedSnapshot({
runtimeStartedAt = "2026-08-06T10:00:00Z",
runtimeStartedMonotonicNs = "1000000",
runtimeId = "runtime-a",
snapshotRevision = 1,
cameraRevision = snapshotRevision,
generation = 1,
} = {}) {
return {
...snapshot(cameraRevision, generation),
snapshot_runtime_started_at_utc: runtimeStartedAt,
snapshot_runtime_started_monotonic_ns: runtimeStartedMonotonicNs,
snapshot_runtime_id: runtimeId,
snapshot_revision: snapshotRevision,
};
}
test("uses the process snapshot revision before camera-local counters", () => {
const current = stampedSnapshot({ snapshotRevision: 8, cameraRevision: 2 });
const stale = stampedSnapshot({ snapshotRevision: 7, cameraRevision: 99 });
const newer = stampedSnapshot({ snapshotRevision: 9, cameraRevision: 1 });
assert.equal(selectMonotonicXgridsState(current, stale), current);
assert.equal(selectMonotonicXgridsState(current, newer), newer);
});
test("accepts a newer runtime and rejects a delayed snapshot from the old runtime", () => {
const oldRuntime = stampedSnapshot({
runtimeStartedAt: "2026-08-06T10:00:00Z",
runtimeStartedMonotonicNs: "1000000",
runtimeId: "runtime-old",
snapshotRevision: 300,
});
const newRuntime = stampedSnapshot({
runtimeStartedAt: "2026-08-06T10:05:00Z",
runtimeStartedMonotonicNs: "2000000",
runtimeId: "runtime-new",
snapshotRevision: 1,
});
const delayedOldRuntime = stampedSnapshot({
runtimeStartedAt: "2026-08-06T10:00:00Z",
runtimeStartedMonotonicNs: "1000000",
runtimeId: "runtime-old",
snapshotRevision: 301,
});
assert.equal(selectMonotonicXgridsState(oldRuntime, newRuntime), newRuntime);
assert.equal(
selectMonotonicXgridsState(newRuntime, delayedOldRuntime),
newRuntime,
);
});
test("orders restarts by monotonic time even when UTC moves backwards", () => {
const oldRuntime = stampedSnapshot({
runtimeStartedAt: "2026-08-06T10:05:00Z",
runtimeStartedMonotonicNs: "2000000",
runtimeId: "runtime-old",
snapshotRevision: 900,
});
const newRuntime = stampedSnapshot({
runtimeStartedAt: "2026-08-06T09:55:00Z",
runtimeStartedMonotonicNs: "3000000",
runtimeId: "runtime-new",
snapshotRevision: 1,
});
assert.equal(selectMonotonicXgridsState(oldRuntime, newRuntime), newRuntime);
assert.equal(selectMonotonicXgridsState(newRuntime, oldRuntime), newRuntime);
});
test("orders two runtimes sharing the same UTC millisecond", () => {
const first = stampedSnapshot({
runtimeStartedMonotonicNs: "4000000",
runtimeId: "runtime-first",
});
const second = stampedSnapshot({
runtimeStartedMonotonicNs: "4000001",
runtimeId: "runtime-second",
});
assert.equal(selectMonotonicXgridsState(first, second), second);
});
test("a malformed monotonic stamp cannot replace valid runtime authority", () => {
const current = stampedSnapshot({
runtimeStartedMonotonicNs: "5000000",
runtimeId: "runtime-current",
});
const malformed = stampedSnapshot({
runtimeStartedAt: "2026-08-06T11:00:00Z",
runtimeStartedMonotonicNs: "not-a-number",
runtimeId: "runtime-malformed",
snapshotRevision: 9999,
});
assert.equal(selectMonotonicXgridsState(current, malformed), current);
});
test("does not let an unstamped legacy response replace stamped authority", () => {
const current = stampedSnapshot({ snapshotRevision: 8 });
const legacy = snapshot(99, 99);
assert.equal(selectMonotonicXgridsState(current, legacy), current);
assert.equal(selectMonotonicXgridsState(legacy, current), current);
});
test("accepts the first camera preview snapshot", () => {
const incoming = snapshot(1, 1);
assert.equal(selectMonotonicXgridsState(null, incoming), incoming);
@@ -13,6 +13,9 @@ let projectObservationLayoutSnapshot;
let saveObservationWorkspaceLayoutProfile;
let WorkspaceLayoutApiError;
let WorkspaceLayoutContractError;
let admitLiveDefaultPresentations;
let automaticLivePresentationIdentity;
let livePresentationCloseFence;
let observationPresentationSourceAfterLayoutApply;
let visibleSourceIdsAfterRecordedCatalogActivation;
@@ -33,6 +36,9 @@ before(async () => {
WorkspaceLayoutContractError,
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
({
admitLiveDefaultPresentations,
automaticLivePresentationIdentity,
livePresentationCloseFence,
observationPresentationSourceAfterLayoutApply,
visibleSourceIdsAfterRecordedCatalogActivation,
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
@@ -213,6 +219,95 @@ test("opening a recorded catalog reveals its sealed cameras beside the point clo
);
});
test("a sequential live acquisition re-arms the same camera without reopening a deliberate close", () => {
const pointCloud = {
id: "k1:sensor.lidar.primary",
sourceId: "sensor.lidar.primary",
modality: "point-cloud",
availability: "streaming",
transport: "rerun-grpc",
previewUrl: "grpc://127.0.0.1:9876/proxy",
delivery: null,
activation: null,
binding: {
deviceId: "k1-a",
deviceSessionId: "device-session-reused",
acquisitionId: "acquisition-a",
},
capabilities: { defaultVisible: true, overlay: false },
};
const camera = (acquisitionId, deliveryId) => ({
id: "k1:sensor.camera.right",
sourceId: "sensor.camera.right",
modality: "video",
availability: "streaming",
transport: "websocket",
previewUrl: null,
delivery: {
id: deliveryId,
kind: "mse-fmp4-websocket",
url: "/camera-preview/reused",
mediaType: 'video/mp4; codecs="avc1.641028"',
},
activation: {
groupId: "k1:device-session-reused:camera.preview.decoder",
maxActive: 1,
selected: true,
controllable: true,
},
binding: {
deviceId: "k1-a",
deviceSessionId: "device-session-reused",
acquisitionId,
},
capabilities: { defaultVisible: true, overlay: true },
});
const firstCamera = camera("acquisition-a", "camera-preview-2");
const first = admitLiveDefaultPresentations(
[pointCloud.id],
[pointCloud, firstCamera],
new Set(),
new Set(),
);
assert.deepEqual(first.visibleIds, [pointCloud.id, firstCamera.id]);
assert.deepEqual(first.admittedIdentities, [
automaticLivePresentationIdentity(firstCamera),
]);
const sameAcquisitionNewDelivery = camera("acquisition-a", "camera-preview-3");
const closedInFirstAcquisition = new Set([
livePresentationCloseFence(firstCamera),
]);
const afterDeliberateClose = admitLiveDefaultPresentations(
[pointCloud.id],
[pointCloud, sameAcquisitionNewDelivery],
new Set(first.admittedIdentities),
closedInFirstAcquisition,
);
assert.deepEqual(afterDeliberateClose.visibleIds, [pointCloud.id]);
assert.deepEqual(afterDeliberateClose.admittedIdentities, []);
const nextAcquisitionSameDelivery = camera("acquisition-b", "camera-preview-3");
assert.notEqual(
automaticLivePresentationIdentity(nextAcquisitionSameDelivery),
automaticLivePresentationIdentity(sameAcquisitionNewDelivery),
);
const second = admitLiveDefaultPresentations(
[pointCloud.id],
[
{ ...pointCloud, binding: { ...pointCloud.binding, acquisitionId: "acquisition-b" } },
nextAcquisitionSameDelivery,
],
new Set(first.admittedIdentities),
closedInFirstAcquisition,
);
assert.deepEqual(second.visibleIds, [pointCloud.id, nextAcquisitionSameDelivery.id]);
assert.deepEqual(second.admittedIdentities, [
automaticLivePresentationIdentity(nextAcquisitionSameDelivery),
]);
});
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
const calls = [];
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());