fix(k1): stabilize repeated acquisition and live viewer recovery
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import {
|
||||
advanceLiveReceiverWatchdog,
|
||||
initialLiveReceiverRecoveryState,
|
||||
initialLiveReceiverWatchdogState,
|
||||
LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
requestLiveReceiverRecovery,
|
||||
} from "../core/observation/liveReceiverWatchdog";
|
||||
import { postLiveViewerDiagnostic } from "../core/observation/liveViewerDiagnostics";
|
||||
import type { LiveViewerFailureStage } from "../core/observation/liveViewerDiagnostics";
|
||||
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||
@@ -66,6 +75,8 @@ export interface RerunViewportProps {
|
||||
sourceUrl: string;
|
||||
recordedArtifact?: RecordedRrdArtifactDescriptor | null;
|
||||
followLive?: boolean;
|
||||
liveActivitySequence?: number | null;
|
||||
liveStreamId?: string | null;
|
||||
autoplayWhenReady?: boolean;
|
||||
presentationGate?: RecordedAdmissionPhase;
|
||||
expectedTimelineStartSeconds?: number;
|
||||
@@ -796,6 +807,8 @@ export function RerunViewport({
|
||||
sourceUrl,
|
||||
recordedArtifact = null,
|
||||
followLive = false,
|
||||
liveActivitySequence = null,
|
||||
liveStreamId = null,
|
||||
autoplayWhenReady = false,
|
||||
presentationGate = "ready",
|
||||
expectedTimelineStartSeconds,
|
||||
@@ -824,6 +837,11 @@ export function RerunViewport({
|
||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [recordingBufferProgress, setRecordingBufferProgress] = useState<number | null>(null);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
const liveActivitySequenceRef = useRef<number | null>(liveActivitySequence);
|
||||
liveActivitySequenceRef.current = liveActivitySequence;
|
||||
const liveStreamIdRef = useRef<string | null>(liveStreamId);
|
||||
liveStreamIdRef.current = liveStreamId;
|
||||
const liveRecoveryRef = useRef(initialLiveReceiverRecoveryState());
|
||||
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
@@ -849,6 +867,10 @@ export function RerunViewport({
|
||||
recordedArtifact !== null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||
}, [followLive, liveStreamId, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedSource = sourceUrl.trim();
|
||||
const host = hostRef.current;
|
||||
@@ -889,6 +911,7 @@ export function RerunViewport({
|
||||
let disposed = false;
|
||||
let disposeViewer: (() => void) | undefined;
|
||||
let recordingOpenTimer: number | undefined;
|
||||
let liveRecordingDiscoveryTimer: number | undefined;
|
||||
let recordedOpenWatchdog: {
|
||||
arm: () => void;
|
||||
clear: () => void;
|
||||
@@ -898,6 +921,8 @@ export function RerunViewport({
|
||||
let recordingOpened = false;
|
||||
let recordingOpenTimedOut = false;
|
||||
let viewerStartResolved = false;
|
||||
let latestLiveRangeMaxNs: number | null = null;
|
||||
let liveWatchdog = initialLiveReceiverWatchdogState();
|
||||
// The backend serves this exact digest-bound URL or rejects it with 412.
|
||||
// Presentation still waits for Rerun to decode the complete declared range.
|
||||
const artifactVerified = true;
|
||||
@@ -925,9 +950,16 @@ export function RerunViewport({
|
||||
recordingOpenTimer = undefined;
|
||||
}
|
||||
};
|
||||
const clearLiveRecordingDiscoveryTimer = () => {
|
||||
if (liveRecordingDiscoveryTimer !== undefined) {
|
||||
window.clearInterval(liveRecordingDiscoveryTimer);
|
||||
liveRecordingDiscoveryTimer = undefined;
|
||||
}
|
||||
};
|
||||
const clearRecordingTimers = () => {
|
||||
clearRecordedAdmissionWatchdog();
|
||||
clearLiveRecordingOpenTimer();
|
||||
clearLiveRecordingDiscoveryTimer();
|
||||
};
|
||||
const clearPlaybackRangeTimer = () => {
|
||||
if (playbackRangeTimer === undefined) return;
|
||||
@@ -950,11 +982,118 @@ export function RerunViewport({
|
||||
requestFrame: (callback) => window.requestAnimationFrame(callback),
|
||||
cancelFrame: (handle) => window.cancelAnimationFrame(handle),
|
||||
});
|
||||
const reportError = (message: string) => {
|
||||
const reportError = (message: string, failureStage?: LiveViewerFailureStage) => {
|
||||
if (disposed) return;
|
||||
if (followLive) {
|
||||
postLiveViewerDiagnostic({
|
||||
eventCode: "live_receiver_error",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts || null,
|
||||
});
|
||||
}
|
||||
setStatus("error");
|
||||
onStatusChange?.("error", message);
|
||||
};
|
||||
const requestLiveRecovery = (
|
||||
failureStage: LiveViewerFailureStage,
|
||||
stalledForMs = 0,
|
||||
emitErrorEvent = true,
|
||||
) => {
|
||||
if (!followLive || disposed) return false;
|
||||
if (emitErrorEvent) {
|
||||
postLiveViewerDiagnostic({
|
||||
eventCode: "live_receiver_error",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
||||
stalledForMs: Math.round(stalledForMs) || null,
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts || null,
|
||||
});
|
||||
}
|
||||
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current);
|
||||
liveRecoveryRef.current = recovery.state;
|
||||
if (recovery.signal === "exhausted") {
|
||||
postLiveViewerDiagnostic({
|
||||
eventCode: "live_receiver_recovery_exhausted",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
||||
stalledForMs: Math.round(stalledForMs) || null,
|
||||
recoveryAttempt: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
});
|
||||
setStatus("error");
|
||||
onStatusChange?.(
|
||||
"error",
|
||||
"Живой поток продолжает поступать, но визуализатор не восстановился после трёх переподключений.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
postLiveViewerDiagnostic({
|
||||
eventCode: "live_receiver_restart_requested",
|
||||
failureStage,
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
||||
stalledForMs: Math.round(stalledForMs) || null,
|
||||
recoveryAttempt: recovery.attempt,
|
||||
});
|
||||
setStatus("loading");
|
||||
onStatusChange?.(
|
||||
"loading",
|
||||
"Живой визуализатор переподключается к продолжающемуся потоку.",
|
||||
);
|
||||
disposeViewer?.();
|
||||
setRetryNonce((nonce) => nonce + 1);
|
||||
return true;
|
||||
};
|
||||
const observeLiveReceiver = (viewerRangeMaxNs: number | null) => {
|
||||
if (!followLive || disposed) return;
|
||||
latestLiveRangeMaxNs = viewerRangeMaxNs;
|
||||
const receiverOpenedAfterRecovery =
|
||||
liveRecoveryRef.current.awaitingRecovery &&
|
||||
liveWatchdog.lastViewerRangeMaxNs === null &&
|
||||
viewerRangeMaxNs !== null;
|
||||
const observed = advanceLiveReceiverWatchdog(liveWatchdog, {
|
||||
nowMs: Date.now(),
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs,
|
||||
});
|
||||
liveWatchdog = observed.state;
|
||||
if (
|
||||
(receiverOpenedAfterRecovery || observed.signal === "receiver-advanced") &&
|
||||
liveRecoveryRef.current.awaitingRecovery
|
||||
) {
|
||||
postLiveViewerDiagnostic({
|
||||
eventCode: "live_receiver_recovered",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs,
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts,
|
||||
});
|
||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||
return;
|
||||
}
|
||||
if (observed.signal !== "stalled") return;
|
||||
|
||||
postLiveViewerDiagnostic({
|
||||
eventCode: "live_receiver_stalled",
|
||||
failureStage: "receiver-stalled",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs,
|
||||
stalledForMs: Math.round(observed.stalledForMs),
|
||||
recoveryAttempt: Math.min(
|
||||
liveRecoveryRef.current.attempts + 1,
|
||||
LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
),
|
||||
});
|
||||
requestLiveRecovery("receiver-stalled", observed.stalledForMs, false);
|
||||
};
|
||||
|
||||
host.replaceChildren();
|
||||
appliedPointColorKeyRef.current = null;
|
||||
@@ -1016,7 +1155,7 @@ export function RerunViewport({
|
||||
viewer.stop();
|
||||
} catch {
|
||||
// A partially initialized WASM handle can already be gone after a
|
||||
// startup failure. The host still has to be cleared below.
|
||||
// startup failure.
|
||||
}
|
||||
host.replaceChildren();
|
||||
};
|
||||
@@ -1034,14 +1173,20 @@ export function RerunViewport({
|
||||
});
|
||||
}
|
||||
|
||||
unsubscribers.push(
|
||||
viewer.on("recording_open", (event) => {
|
||||
const admitRecording = (event: {
|
||||
application_id: string;
|
||||
recording_id: string;
|
||||
}) => {
|
||||
if (
|
||||
disposed ||
|
||||
recordingOpened ||
|
||||
(isRecordedSource && event.application_id !== "nodedc_mission_core_recorded")
|
||||
) return;
|
||||
recordingOpened = true;
|
||||
if (!isRecordedSource) {
|
||||
clearLiveRecordingOpenTimer();
|
||||
clearLiveRecordingDiscoveryTimer();
|
||||
}
|
||||
if (
|
||||
recordedBlueprintUrl &&
|
||||
event.application_id === "nodedc_mission_core_recorded" &&
|
||||
@@ -1060,7 +1205,6 @@ export function RerunViewport({
|
||||
setPerceptionChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
}
|
||||
if (!isRecordedSource) clearLiveRecordingOpenTimer();
|
||||
// Durable Mission Core recordings use a zero-based session clock.
|
||||
// Keeping wall-clock capture_time as a secondary RRD timeline is
|
||||
// useful for audit, but the operator scrubber must remain a small,
|
||||
@@ -1144,6 +1288,7 @@ export function RerunViewport({
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
observeLiveReceiver(rangeNs?.max ?? null);
|
||||
const recordedBuffer = recordedPlaybackBufferState(
|
||||
rangeNs,
|
||||
expectedTimelineEndSeconds,
|
||||
@@ -1225,8 +1370,31 @@ export function RerunViewport({
|
||||
clearPlaybackRangeTimer();
|
||||
publishBufferedState();
|
||||
playbackRangeTimer = window.setInterval(publishBufferedState, 500);
|
||||
}),
|
||||
};
|
||||
unsubscribers.push(
|
||||
viewer.on("recording_open", admitRecording),
|
||||
);
|
||||
const discoverActiveLiveRecording = () => {
|
||||
if (isRecordedSource || disposed || recordingOpened || !viewer.ready) return;
|
||||
try {
|
||||
const recordingId = viewer.get_active_recording_id();
|
||||
if (!recordingId) return;
|
||||
// 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({
|
||||
eventCode: "live_receiver_active_store_admitted",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
});
|
||||
admitRecording({
|
||||
application_id: "nodedc_mission_core_spatial",
|
||||
recording_id: recordingId,
|
||||
});
|
||||
} catch {
|
||||
// The native receiver is still opening; the bounded timer retries.
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribers.push(
|
||||
viewer.on("time_update", (event) => {
|
||||
@@ -1316,25 +1484,34 @@ export function RerunViewport({
|
||||
setPerceptionChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
|
||||
if (!recordingOpened && !isRecordedSource) {
|
||||
recordingOpenTimer = window.setTimeout(() => {
|
||||
recordingOpenTimedOut = true;
|
||||
disposeViewer?.();
|
||||
reportError("Визуализатор запущен, но поток записи не открылся.");
|
||||
}, 12_000);
|
||||
if (!isRecordedSource) {
|
||||
discoverActiveLiveRecording();
|
||||
if (!recordingOpened) {
|
||||
liveRecordingDiscoveryTimer = window.setInterval(
|
||||
discoverActiveLiveRecording,
|
||||
100,
|
||||
);
|
||||
recordingOpenTimer = window.setTimeout(() => {
|
||||
recordingOpenTimedOut = true;
|
||||
requestLiveRecovery("recording-open-timeout");
|
||||
}, 12_000);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
disposeViewer();
|
||||
if (!recordingOpenTimedOut && !disposed) {
|
||||
reportError(isRecordedSource
|
||||
const message = isRecordedSource
|
||||
? "Запись не прошла атомарную проверку целостности."
|
||||
: "Не удалось запустить встроенный визуализатор.");
|
||||
: "Не удалось запустить встроенный визуализатор.";
|
||||
if (!isRecordedSource && requestLiveRecovery("viewer-start")) return;
|
||||
reportError(message);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
disposeViewer?.();
|
||||
if (requestLiveRecovery("module-load")) return;
|
||||
reportError("Не удалось загрузить модуль визуализатора.");
|
||||
});
|
||||
|
||||
@@ -1355,6 +1532,7 @@ export function RerunViewport({
|
||||
expectedTimelineStartSeconds,
|
||||
followLive,
|
||||
initialPlaybackStartSeconds,
|
||||
liveStreamId,
|
||||
onPlaybackChange,
|
||||
onPlaybackControllerChange,
|
||||
onSelectionChange,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
export interface LiveReceiverWatchdogState {
|
||||
lastBackendActivitySequence: number | null;
|
||||
lastViewerRangeMaxNs: number | null;
|
||||
staticRangeBackendAdvances: number;
|
||||
stalledSinceMs: number | null;
|
||||
stallReported: boolean;
|
||||
}
|
||||
|
||||
export type LiveReceiverWatchdogSignal = "none" | "receiver-advanced" | "stalled";
|
||||
|
||||
export interface LiveReceiverWatchdogResult {
|
||||
state: LiveReceiverWatchdogState;
|
||||
signal: LiveReceiverWatchdogSignal;
|
||||
stalledForMs: number;
|
||||
}
|
||||
|
||||
export interface LiveReceiverRecoveryState {
|
||||
attempts: number;
|
||||
awaitingRecovery: boolean;
|
||||
}
|
||||
|
||||
export type LiveReceiverRecoverySignal = "retry" | "exhausted";
|
||||
|
||||
export interface LiveReceiverRecoveryResult {
|
||||
state: LiveReceiverRecoveryState;
|
||||
signal: LiveReceiverRecoverySignal;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
export const LIVE_RECEIVER_STALL_THRESHOLD_MS = 5_000;
|
||||
export const LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS = 3;
|
||||
|
||||
export function initialLiveReceiverWatchdogState(): LiveReceiverWatchdogState {
|
||||
return {
|
||||
lastBackendActivitySequence: null,
|
||||
lastViewerRangeMaxNs: null,
|
||||
staticRangeBackendAdvances: 0,
|
||||
stalledSinceMs: null,
|
||||
stallReported: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function initialLiveReceiverRecoveryState(): LiveReceiverRecoveryState {
|
||||
return {
|
||||
attempts: 0,
|
||||
awaitingRecovery: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function requestLiveReceiverRecovery(
|
||||
current: LiveReceiverRecoveryState,
|
||||
maxAttempts = LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
): LiveReceiverRecoveryResult {
|
||||
if (current.attempts >= maxAttempts) {
|
||||
return {
|
||||
state: {
|
||||
attempts: current.attempts,
|
||||
awaitingRecovery: false,
|
||||
},
|
||||
signal: "exhausted",
|
||||
attempt: current.attempts,
|
||||
};
|
||||
}
|
||||
const attempt = current.attempts + 1;
|
||||
return {
|
||||
state: {
|
||||
attempts: attempt,
|
||||
awaitingRecovery: true,
|
||||
},
|
||||
signal: "retry",
|
||||
attempt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a disposable receiver stall only when the backend frame counter keeps
|
||||
* advancing while Rerun's visible time range does not. Scanner commands are
|
||||
* deliberately absent from this state machine.
|
||||
*/
|
||||
export function advanceLiveReceiverWatchdog(
|
||||
current: LiveReceiverWatchdogState,
|
||||
sample: {
|
||||
nowMs: number;
|
||||
backendActivitySequence: number | null;
|
||||
viewerRangeMaxNs: number | null;
|
||||
},
|
||||
thresholdMs = LIVE_RECEIVER_STALL_THRESHOLD_MS,
|
||||
): LiveReceiverWatchdogResult {
|
||||
const backendSequence = Number.isSafeInteger(sample.backendActivitySequence) &&
|
||||
(sample.backendActivitySequence ?? -1) >= 0
|
||||
? sample.backendActivitySequence
|
||||
: null;
|
||||
const viewerRange = Number.isFinite(sample.viewerRangeMaxNs) &&
|
||||
(sample.viewerRangeMaxNs ?? -1) >= 0
|
||||
? sample.viewerRangeMaxNs
|
||||
: null;
|
||||
const state = { ...current };
|
||||
|
||||
if (state.lastBackendActivitySequence === null && state.lastViewerRangeMaxNs === null) {
|
||||
state.lastBackendActivitySequence = backendSequence;
|
||||
state.lastViewerRangeMaxNs = viewerRange;
|
||||
return { state, signal: "none", stalledForMs: 0 };
|
||||
}
|
||||
|
||||
const backendAdvanced = backendSequence !== null &&
|
||||
(state.lastBackendActivitySequence === null ||
|
||||
backendSequence > state.lastBackendActivitySequence);
|
||||
const receiverAdvanced = viewerRange !== null &&
|
||||
(state.lastViewerRangeMaxNs === null || viewerRange > state.lastViewerRangeMaxNs);
|
||||
|
||||
if (backendSequence !== null) {
|
||||
state.lastBackendActivitySequence = Math.max(
|
||||
backendSequence,
|
||||
state.lastBackendActivitySequence ?? 0,
|
||||
);
|
||||
}
|
||||
if (viewerRange !== null) {
|
||||
state.lastViewerRangeMaxNs = Math.max(viewerRange, state.lastViewerRangeMaxNs ?? 0);
|
||||
}
|
||||
|
||||
if (receiverAdvanced) {
|
||||
const hadStaticBackendProgress = state.stalledSinceMs !== null;
|
||||
state.staticRangeBackendAdvances = 0;
|
||||
state.stalledSinceMs = null;
|
||||
state.stallReported = false;
|
||||
return {
|
||||
state,
|
||||
signal: hadStaticBackendProgress ? "receiver-advanced" : "none",
|
||||
stalledForMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (backendAdvanced) {
|
||||
state.staticRangeBackendAdvances += 1;
|
||||
if (state.staticRangeBackendAdvances >= 2 && state.stalledSinceMs === null) {
|
||||
state.stalledSinceMs = sample.nowMs;
|
||||
}
|
||||
}
|
||||
const stalledForMs = state.stalledSinceMs === null
|
||||
? 0
|
||||
: Math.max(0, sample.nowMs - state.stalledSinceMs);
|
||||
if (
|
||||
!state.stallReported &&
|
||||
state.staticRangeBackendAdvances >= 2 &&
|
||||
stalledForMs >= thresholdMs
|
||||
) {
|
||||
state.stallReported = true;
|
||||
return { state, signal: "stalled", stalledForMs };
|
||||
}
|
||||
return { state, signal: "none", stalledForMs };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export type LiveViewerDiagnosticEventCode =
|
||||
| "live_receiver_stalled"
|
||||
| "live_receiver_restart_requested"
|
||||
| "live_receiver_recovered"
|
||||
| "live_receiver_recovery_exhausted"
|
||||
| "live_receiver_active_store_admitted"
|
||||
| "live_receiver_error";
|
||||
|
||||
export type LiveViewerFailureStage =
|
||||
| "recording-open-timeout"
|
||||
| "viewer-start"
|
||||
| "module-load"
|
||||
| "receiver-stalled";
|
||||
|
||||
export interface LiveViewerDiagnostic {
|
||||
eventCode: LiveViewerDiagnosticEventCode;
|
||||
failureStage?: LiveViewerFailureStage | null;
|
||||
streamId?: string | null;
|
||||
backendActivitySequence?: number | null;
|
||||
viewerRangeMaxNs?: number | null;
|
||||
stalledForMs?: number | null;
|
||||
recoveryAttempt?: number | null;
|
||||
}
|
||||
|
||||
const SAFE_STREAM_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function safeInteger(value: number | null | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function postLiveViewerDiagnostic(event: LiveViewerDiagnostic): void {
|
||||
const streamId = event.streamId?.trim();
|
||||
const body = {
|
||||
schema_version: "missioncore.live-viewer-diagnostic/v1",
|
||||
event_code: event.eventCode,
|
||||
...(event.failureStage ? { failure_stage: event.failureStage } : {}),
|
||||
...(streamId && SAFE_STREAM_ID.test(streamId) ? { stream_id: streamId } : {}),
|
||||
...(safeInteger(event.backendActivitySequence) === undefined
|
||||
? {}
|
||||
: { backend_activity_sequence: safeInteger(event.backendActivitySequence) }),
|
||||
...(safeInteger(event.viewerRangeMaxNs) === undefined
|
||||
? {}
|
||||
: { viewer_range_max_ns: safeInteger(event.viewerRangeMaxNs) }),
|
||||
...(safeInteger(event.stalledForMs) === undefined
|
||||
? {}
|
||||
: { stalled_for_ms: safeInteger(event.stalledForMs) }),
|
||||
...(safeInteger(event.recoveryAttempt) === undefined
|
||||
? {}
|
||||
: { recovery_attempt: safeInteger(event.recoveryAttempt) }),
|
||||
};
|
||||
void fetch("/api/v1/viewer/live-diagnostics", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
keepalive: true,
|
||||
}).catch(() => {
|
||||
// Diagnostics must never interfere with the live receiver recovery path.
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export interface ViewerSettings {
|
||||
}
|
||||
|
||||
export interface StreamMetrics {
|
||||
publishedFrameCount?: number | null;
|
||||
latencyMs?: number | null;
|
||||
frameRateHz?: number | null;
|
||||
pointCount?: number | null;
|
||||
|
||||
@@ -499,7 +499,8 @@ function SpatialWorkspace({
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
recordedArtifact={recordedSource ? recordedReplay : null}
|
||||
followLive={!recordedSource && state?.sourceMode === "live"}
|
||||
followLive={!recordedReplay && streamActive}
|
||||
liveActivitySequence={metrics?.publishedFrameCount} liveStreamId={state?.spatialSource?.id}
|
||||
autoplayWhenReady={recordedSource}
|
||||
presentationGate={recordedSessionGate}
|
||||
expectedTimelineStartSeconds={recordedSource
|
||||
|
||||
@@ -439,6 +439,11 @@ test("live source is confirmed only by an acquiring acquisition", () => {
|
||||
assert.equal(lifecycle.sourceStatusLabel(waiting), "Ожидание реальных данных");
|
||||
assert.equal(lifecycle.isConfirmedLiveState(acquiring), true);
|
||||
assert.equal(lifecycle.confirmedRuntimeSourceMode(acquiring), "live");
|
||||
assert.equal(lifecycle.liveStartPlan(acquiring), "already-running");
|
||||
assert.equal(
|
||||
lifecycle.controlSessionEntryPlan("scanning", false, false),
|
||||
"continue",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepared acquisition resumes without another prepare and remains recoverable", () => {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let advanceLiveReceiverWatchdog;
|
||||
let initialLiveReceiverWatchdogState;
|
||||
let initialLiveReceiverRecoveryState;
|
||||
let requestLiveReceiverRecovery;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
advanceLiveReceiverWatchdog,
|
||||
initialLiveReceiverRecoveryState,
|
||||
initialLiveReceiverWatchdogState,
|
||||
requestLiveReceiverRecovery,
|
||||
} = await server.ssrLoadModule("/src/core/observation/liveReceiverWatchdog.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("watchdog ignores healthy live progress", () => {
|
||||
let state = initialLiveReceiverWatchdogState();
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const observed = advanceLiveReceiverWatchdog(state, {
|
||||
nowMs: index * 500,
|
||||
backendActivitySequence: index * 2,
|
||||
viewerRangeMaxNs: index * 1_000_000_000,
|
||||
});
|
||||
state = observed.state;
|
||||
assert.notEqual(observed.signal, "stalled");
|
||||
}
|
||||
});
|
||||
|
||||
test("watchdog detects a frozen receiver while backend publication advances", () => {
|
||||
let state = initialLiveReceiverWatchdogState();
|
||||
let signal = "none";
|
||||
let stalledForMs = 0;
|
||||
for (let index = 0; index <= 13; index += 1) {
|
||||
const observed = advanceLiveReceiverWatchdog(state, {
|
||||
nowMs: index * 500,
|
||||
backendActivitySequence: 100 + index,
|
||||
viewerRangeMaxNs: 42_000_000_000,
|
||||
});
|
||||
state = observed.state;
|
||||
signal = observed.signal === "stalled" ? observed.signal : signal;
|
||||
stalledForMs = Math.max(stalledForMs, observed.stalledForMs);
|
||||
}
|
||||
assert.equal(signal, "stalled");
|
||||
assert.ok(stalledForMs >= 5_000);
|
||||
});
|
||||
|
||||
test("one backend increment at stream finalization does not create a false stall", () => {
|
||||
let state = initialLiveReceiverWatchdogState();
|
||||
state = advanceLiveReceiverWatchdog(state, {
|
||||
nowMs: 0,
|
||||
backendActivitySequence: 10,
|
||||
viewerRangeMaxNs: 20_000_000_000,
|
||||
}).state;
|
||||
state = advanceLiveReceiverWatchdog(state, {
|
||||
nowMs: 500,
|
||||
backendActivitySequence: 11,
|
||||
viewerRangeMaxNs: 20_000_000_000,
|
||||
}).state;
|
||||
const observed = advanceLiveReceiverWatchdog(state, {
|
||||
nowMs: 10_000,
|
||||
backendActivitySequence: 11,
|
||||
viewerRangeMaxNs: 20_000_000_000,
|
||||
});
|
||||
assert.equal(observed.signal, "none");
|
||||
});
|
||||
|
||||
test("startup failures request only three bounded viewer restarts", () => {
|
||||
let state = initialLiveReceiverRecoveryState();
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const recovery = requestLiveReceiverRecovery(state);
|
||||
assert.equal(recovery.signal, "retry");
|
||||
assert.equal(recovery.attempt, attempt);
|
||||
assert.equal(recovery.state.awaitingRecovery, true);
|
||||
state = recovery.state;
|
||||
}
|
||||
const exhausted = requestLiveReceiverRecovery(state);
|
||||
assert.equal(exhausted.signal, "exhausted");
|
||||
assert.equal(exhausted.attempt, 3);
|
||||
assert.equal(exhausted.state.awaitingRecovery, false);
|
||||
});
|
||||
@@ -129,7 +129,11 @@ 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]*if \(!isRecordedSource\) clearLiveRecordingOpenTimer\(\);/,
|
||||
/recordingOpened = true;[\s\S]*clearLiveRecordingOpenTimer\(\);[\s\S]*clearLiveRecordingDiscoveryTimer\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/viewer\.get_active_recording_id\(\)[\s\S]*eventCode: "live_receiver_active_store_admitted"[\s\S]*admitRecording\(\{[\s\S]*application_id: "nodedc_mission_core_spatial"/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -141,6 +145,14 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
);
|
||||
});
|
||||
|
||||
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\}/);
|
||||
});
|
||||
|
||||
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
||||
const css = await readFile(new URL("../src/styles/spatial.css", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
|
||||
Reference in New Issue
Block a user