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:
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user