fix(k1): harden live handoff and camera recovery
This commit is contained in:
@@ -20,6 +20,7 @@ let operatorIntentGeneration;
|
||||
let configuration;
|
||||
let compatibility;
|
||||
let networkProvisionFailureMessage;
|
||||
let awaitNetworkProvisionSettlementAfterLostResponse;
|
||||
let discoveryScanFailureMessage;
|
||||
let connectionVerificationFailureMessage;
|
||||
let operationById;
|
||||
@@ -99,6 +100,7 @@ before(async () => {
|
||||
));
|
||||
({
|
||||
networkProvisionFailureMessage,
|
||||
awaitNetworkProvisionSettlementAfterLostResponse,
|
||||
discoveryScanFailureMessage,
|
||||
connectionVerificationFailureMessage,
|
||||
operationById,
|
||||
@@ -1043,6 +1045,56 @@ test("K1 API uses bounded no-retry timeout classes per operation family", async
|
||||
assert.doesNotMatch(apiSource, /automaticRetry\s*=\s*true/);
|
||||
});
|
||||
|
||||
test("lost Quick response follows the admitted Apply by journal reads only", async () => {
|
||||
const idempotencyKey = "network-provision:quick-lost-response";
|
||||
let nowMs = 1_000;
|
||||
let reads = 0;
|
||||
const accepted = [];
|
||||
const operation = {
|
||||
operation_id: "op-quick-lost-response",
|
||||
action: "network.provision",
|
||||
status: "running",
|
||||
idempotency_key: idempotencyKey,
|
||||
deadline_at: new Date(5_000).toISOString(),
|
||||
};
|
||||
const initialState = {
|
||||
snapshot_runtime_id: "runtime-quick-lost-response",
|
||||
operations: [operation],
|
||||
};
|
||||
const terminalState = {
|
||||
...initialState,
|
||||
operations: [{
|
||||
...operation,
|
||||
status: "succeeded",
|
||||
result: { phase: "network_applied" },
|
||||
}],
|
||||
};
|
||||
|
||||
const settled = await awaitNetworkProvisionSettlementAfterLostResponse(
|
||||
initialState,
|
||||
idempotencyKey,
|
||||
async () => {
|
||||
reads += 1;
|
||||
if (reads === 1) {
|
||||
throw new ApiError("transient localhost handoff", 0, true);
|
||||
}
|
||||
return terminalState;
|
||||
},
|
||||
(state) => accepted.push(state),
|
||||
() => {},
|
||||
{
|
||||
now: () => nowMs,
|
||||
wait: async (delayMs) => {
|
||||
nowMs += delayMs;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(reads, 2);
|
||||
assert.deepEqual(accepted, [terminalState]);
|
||||
assert.equal(settled.operations[0].status, "succeeded");
|
||||
});
|
||||
|
||||
test("one explicit K1 operator action supplies the backend physical acceptance", () => {
|
||||
assert.deepEqual(physicalCommandConfirmation.operatorActionPhysicalAcceptance(), {
|
||||
operator_present: true,
|
||||
|
||||
@@ -72,6 +72,7 @@ let transportRefEquivalenceKey;
|
||||
let shouldRevealProvisioningNetworkStep;
|
||||
let retiredPhysicalReopenAuthority;
|
||||
let SnapshotRuntimeActionArbiter;
|
||||
let runtimeActionResponseAlreadyAccepted;
|
||||
let connectionActionAuthoritySnapshot;
|
||||
let exactAppliedNetworkIntentCompleted;
|
||||
let selectMonotonicXgridsState;
|
||||
@@ -106,6 +107,7 @@ before(async () => {
|
||||
SnapshotRuntimeActionArbiter,
|
||||
connectionActionAuthoritySnapshot,
|
||||
exactAppliedNetworkIntentCompleted,
|
||||
runtimeActionResponseAlreadyAccepted,
|
||||
} = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/useXgridsK1Runtime.ts",
|
||||
));
|
||||
@@ -2026,6 +2028,27 @@ test("K1 runtime replacement retires A and isolates a new B action from late set
|
||||
assert.equal(pendingAction, null);
|
||||
});
|
||||
|
||||
test("successful action response may be superseded only by the same runtime revision", () => {
|
||||
const response = {
|
||||
snapshot_runtime_id: "runtime-a",
|
||||
snapshot_revision: 41,
|
||||
};
|
||||
|
||||
assert.equal(runtimeActionResponseAlreadyAccepted(response, {
|
||||
snapshot_runtime_id: "runtime-a",
|
||||
snapshot_revision: 42,
|
||||
}), true);
|
||||
assert.equal(runtimeActionResponseAlreadyAccepted(response, {
|
||||
snapshot_runtime_id: "runtime-a",
|
||||
snapshot_revision: 40,
|
||||
}), false);
|
||||
assert.equal(runtimeActionResponseAlreadyAccepted(response, {
|
||||
snapshot_runtime_id: "runtime-b",
|
||||
snapshot_revision: 99,
|
||||
}), false);
|
||||
assert.equal(runtimeActionResponseAlreadyAccepted(response, null), false);
|
||||
});
|
||||
|
||||
test("explicit scenario reset B supersedes pending callback A in one runtime", () => {
|
||||
const arbiter = new SnapshotRuntimeActionArbiter();
|
||||
const actionA = arbiter.begin(12);
|
||||
|
||||
@@ -273,6 +273,21 @@ test("opening receiver preserves partial store replay while backend publication
|
||||
}
|
||||
});
|
||||
|
||||
test("new receiver grants its first confirmed backend publication a full open window", () => {
|
||||
const openState = initialLiveReceiverOpenWatchdogState(null, 0);
|
||||
const recoveryState = initialLiveReceiverRecoveryState();
|
||||
const firstObservedPublication = advanceLiveReceiverOpenWatchdog(
|
||||
openState,
|
||||
recoveryState,
|
||||
1,
|
||||
4_000,
|
||||
);
|
||||
|
||||
assert.equal(firstObservedPublication.signal, "wait-for-store");
|
||||
assert.equal(firstObservedPublication.state.lastBackendActivitySequence, 1);
|
||||
assert.deepEqual(firstObservedPublication.recoveryState, recoveryState);
|
||||
});
|
||||
|
||||
test("unchanged opening sequence delegates to bounded receiver restart", () => {
|
||||
const openState = initialLiveReceiverOpenWatchdogState(486);
|
||||
const recoveryState = initialLiveReceiverRecoveryState();
|
||||
|
||||
@@ -9,6 +9,7 @@ let createUiBuildStaleCoordinator;
|
||||
let liveViewerDiagnosticBody;
|
||||
let server;
|
||||
let uiBuildIdFromModuleScripts;
|
||||
let verifyLiveViewerClientBuild;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -22,6 +23,7 @@ before(async () => {
|
||||
createUiBuildStaleCoordinator,
|
||||
liveViewerDiagnosticBody,
|
||||
uiBuildIdFromModuleScripts,
|
||||
verifyLiveViewerClientBuild,
|
||||
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
|
||||
});
|
||||
|
||||
@@ -129,9 +131,9 @@ test("two mounted viewers keep timer and diagnostic lineage isolated", () => {
|
||||
assert.equal(posts[0].eventLineage.lifecycleGeneration, 7);
|
||||
});
|
||||
|
||||
test("stale-build and unmount fence callbacks before one reload", () => {
|
||||
test("stale build drift neither fences transports nor reloads the document", () => {
|
||||
const clock = createFakeScheduler();
|
||||
const order = [];
|
||||
const sideEffects = [];
|
||||
const posts = [];
|
||||
const lifecycle = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: lineage("00000000-0000-4000-8000-000000000031"),
|
||||
@@ -142,15 +144,9 @@ test("stale-build and unmount fence callbacks before one reload", () => {
|
||||
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"),
|
||||
});
|
||||
const coordinator = createUiBuildStaleCoordinator();
|
||||
coordinator.subscribe(() => {
|
||||
order.push("local-transports-closed");
|
||||
sideEffects.push("local-transports-closed");
|
||||
lifecycle.dispose();
|
||||
});
|
||||
|
||||
@@ -165,9 +161,47 @@ test("stale-build and unmount fence callbacks before one reload", () => {
|
||||
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);
|
||||
assert.deepEqual(sideEffects, []);
|
||||
assert.deepEqual(posts, [
|
||||
{ eventCode: "live_receiver_error" },
|
||||
{ eventCode: "live_receiver_error" },
|
||||
]);
|
||||
assert.equal(lifecycle.active(), true);
|
||||
assert.equal(coordinator.stale(), true);
|
||||
});
|
||||
|
||||
test("build drift is reported once with the exact loaded build", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.live-viewer-client-contract/v1",
|
||||
status: "ready",
|
||||
ui_build_id: "/assets/index-ijklmnop.js",
|
||||
diagnostic_schema_version: "missioncore.live-viewer-diagnostic/v2",
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "X-MissionCore-UI-Build": "/assets/index-ijklmnop.js" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const eventLineage = lineage("00000000-0000-4000-8000-000000000032");
|
||||
verifyLiveViewerClientBuild(eventLineage);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
verifyLiveViewerClientBuild(eventLineage);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "/api/v1/viewer/client-contract");
|
||||
assert.equal(
|
||||
calls[0].options.headers["X-MissionCore-UI-Build"],
|
||||
"/assets/index-abcdefgh.js",
|
||||
);
|
||||
});
|
||||
|
||||
test("last unsubscribe fences an already queued build verification callback", () => {
|
||||
@@ -213,4 +247,41 @@ test("diagnostic body and build id retain exact document/viewer/build lineage",
|
||||
),
|
||||
"/assets/index-dT7dN-y4.js",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
liveViewerDiagnosticBody(
|
||||
{
|
||||
eventCode: "live_camera_transport_restart_requested",
|
||||
failureStage: "camera-queue-capacity",
|
||||
streamId: "camera-preview-2",
|
||||
cameraQueueBytes: 12_000_000,
|
||||
cameraQueueSegments: 96,
|
||||
cameraRetryCount: 3,
|
||||
websocketCloseCode: 4_008,
|
||||
transportEpoch: 7,
|
||||
cameraAppendErrorName: "InvalidStateError",
|
||||
cameraMediaSourceState: "open",
|
||||
cameraVideoErrorCode: 3,
|
||||
},
|
||||
eventLineage,
|
||||
),
|
||||
{
|
||||
schema_version: "missioncore.live-viewer-diagnostic/v2",
|
||||
event_code: "live_camera_transport_restart_requested",
|
||||
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,
|
||||
failure_stage: "camera-queue-capacity",
|
||||
stream_id: "camera-preview-2",
|
||||
camera_queue_bytes: 12_000_000,
|
||||
camera_queue_segments: 96,
|
||||
camera_retry_count: 3,
|
||||
websocket_close_code: 4_008,
|
||||
transport_epoch: 7,
|
||||
camera_append_error_name: "InvalidStateError",
|
||||
camera_media_source_state: "open",
|
||||
camera_video_error_code: 3,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ let cameraTransportRecoveryIsCurrent;
|
||||
let cameraTransportCanOpen;
|
||||
let cameraPendingQueueCanAccept;
|
||||
let cameraTransportCloseRecoveryMessage;
|
||||
let cameraPlaybackRecoveryFailureStage;
|
||||
let createCameraStartupWatchdog;
|
||||
let cameraStartupWatchdogRecoveryMessage;
|
||||
let CAMERA_FIRST_MEDIA_TIMEOUT_MS;
|
||||
@@ -69,6 +70,7 @@ before(async () => {
|
||||
cameraTransportCanOpen,
|
||||
cameraPendingQueueCanAccept,
|
||||
cameraTransportCloseRecoveryMessage,
|
||||
cameraPlaybackRecoveryFailureStage,
|
||||
createCameraStartupWatchdog,
|
||||
cameraStartupWatchdogRecoveryMessage,
|
||||
CAMERA_FIRST_MEDIA_TIMEOUT_MS,
|
||||
@@ -1297,6 +1299,25 @@ test("server slow-reader close is an automatic browser-only camera recovery", ()
|
||||
assert.match(cameraTransportCloseRecoveryMessage(1_011), /восстанавливаем/);
|
||||
});
|
||||
|
||||
test("camera transport recovery events keep exact diagnostic causes", () => {
|
||||
assert.equal(
|
||||
cameraPlaybackRecoveryFailureStage({ type: "heartbeat" }),
|
||||
"camera-heartbeat-reopen",
|
||||
);
|
||||
assert.equal(
|
||||
cameraPlaybackRecoveryFailureStage({ type: "document-visible" }),
|
||||
"camera-visibility-reopen",
|
||||
);
|
||||
assert.equal(
|
||||
cameraPlaybackRecoveryFailureStage({ type: "network-online" }),
|
||||
"camera-network-online-reopen",
|
||||
);
|
||||
assert.equal(
|
||||
cameraPlaybackRecoveryFailureStage({ type: "page-restore", persisted: true }),
|
||||
"camera-page-restore-reopen",
|
||||
);
|
||||
});
|
||||
|
||||
test("a laptop sleep gap reopens only the exact authoritative live camera transport", () => {
|
||||
const source = xgridsK1ObservationSources(
|
||||
cameraStreamingState("sensor.camera.right"),
|
||||
|
||||
@@ -237,6 +237,14 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
source,
|
||||
/if \(readyToRender && !readyPublished\)[\s\S]*eventCode: "live_receiver_active_store_admitted"[\s\S]*diagnosticLifecycle\.markAdmitted\(\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/liveOpenWatchdog = initialLiveReceiverOpenWatchdogState\(null, Date\.now\(\)\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/initialLiveReceiverOpenWatchdogState\(\s*liveActivitySequenceRef\.current/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\], \[followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl\]\);/,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let viewerSettingsTargetIdentity;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ viewerSettingsTargetIdentity } = await server.ssrLoadModule(
|
||||
"/src/core/observation/viewerSettingsTarget.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function runtimeState({ acquisitionId, sessionId = "session-a", deviceId = "device-a" }) {
|
||||
return {
|
||||
phase: "connected",
|
||||
sourceMode: "idle",
|
||||
activeDevice: {
|
||||
pluginId: "xgrids-k1",
|
||||
modelId: "lixel-k1",
|
||||
instanceId: "instance-a",
|
||||
displayName: "K1",
|
||||
},
|
||||
deviceSession: { sessionId, deviceId },
|
||||
acquisition: acquisitionId
|
||||
? {
|
||||
acquisitionId,
|
||||
deviceId,
|
||||
deviceSessionId: sessionId,
|
||||
compatibilityProfileId: "profile-a",
|
||||
controlMode: "live",
|
||||
state: "streaming",
|
||||
stateRevision: 1,
|
||||
operatorInstructions: [],
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
test("new acquisition on the same control session keeps the viewer settings target stable", () => {
|
||||
const beforeStart = viewerSettingsTargetIdentity(runtimeState({}));
|
||||
const firstRun = viewerSettingsTargetIdentity(runtimeState({ acquisitionId: "acquisition-a" }));
|
||||
const secondRun = viewerSettingsTargetIdentity(runtimeState({ acquisitionId: "acquisition-b" }));
|
||||
|
||||
assert.equal(firstRun, beforeStart);
|
||||
assert.equal(secondRun, beforeStart);
|
||||
});
|
||||
|
||||
test("temporary loss of control authority does not reapply settings within one device session", () => {
|
||||
const authoritative = runtimeState({ acquisitionId: "acquisition-a" });
|
||||
const authorityUnavailable = {
|
||||
...authoritative,
|
||||
activeDevice: null,
|
||||
phase: "starting",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
viewerSettingsTargetIdentity(authorityUnavailable),
|
||||
viewerSettingsTargetIdentity(authoritative),
|
||||
);
|
||||
});
|
||||
|
||||
test("a new control session or device produces a new viewer settings target", () => {
|
||||
const original = viewerSettingsTargetIdentity(runtimeState({ acquisitionId: "acquisition-a" }));
|
||||
|
||||
assert.notEqual(
|
||||
viewerSettingsTargetIdentity(runtimeState({ sessionId: "session-b" })),
|
||||
original,
|
||||
);
|
||||
assert.notEqual(
|
||||
viewerSettingsTargetIdentity(runtimeState({ deviceId: "device-b" })),
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unconfigured runtime uses the local target", () => {
|
||||
assert.equal(viewerSettingsTargetIdentity(null), "local-runtime");
|
||||
});
|
||||
Reference in New Issue
Block a user