145 lines
11 KiB
JavaScript
145 lines
11 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
|
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
|
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
|
import { MACHINE_DEFINITIONS } from "../workspace/machine-definitions.mjs";
|
|
import { fnv1a64 } from "./replay.mjs";
|
|
|
|
const root = new URL("../../", import.meta.url);
|
|
const wasmBinary = await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root));
|
|
const fetchImpl = async (url) => {
|
|
try { const text = await readFile(new URL(String(url).replace(/^\//, ""), root), "utf8"); return { ok: true, text: async () => text }; }
|
|
catch { return { ok: false }; }
|
|
};
|
|
async function open(machine) {
|
|
const bundle = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
|
|
const subroutine = machine.subroutines[0];
|
|
const subPath = machine.entrypoint.replace(/[^/]+$/, `remap_subs/${subroutine}.ngc`);
|
|
bundle.files[subPath] = `O<${subroutine}> sub\n#31=[#31+#1]\nG1 X#31 Y4 Z2 ${machine.coordinates[3]}8 C12 F500\nO<${subroutine}> endsub\n`;
|
|
const core = await createModule({ wasmBinary });
|
|
// Give each module a different heap layout; native restore must rebind pointers.
|
|
core._malloc(4096 + rows.length * 128);
|
|
const session = new SimSession(core, { sessionId: `exact-${machine.id}`, planner: "linuxcnc-tp" });
|
|
let seq = 0;
|
|
const send = (kind, payload = {}, operationId = null) => {
|
|
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: operationId ?? `exact-${seq}`, payload });
|
|
assert.notEqual(result.kind, "ERROR", `${kind}: ${result.payload?.message_key ?? result.payload?.code ?? JSON.stringify({ preview: result.payload?.preview?.code, line: result.payload?.preview?.line, program_error: result.payload?.program?.last_error })}`);
|
|
return result;
|
|
};
|
|
send("HELLO"); send("SESSION_ATTACH"); send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
|
send("POWER", { active: true }); send("HOME", { all: true });
|
|
for (let i = 0; i < 500 && !session.wasmHomeLimit.read().state.homed; i++) send("SIM_STEP", { cycles: 1 });
|
|
assert.ok(session.wasmHomeLimit.read().state.homed);
|
|
return { session, core, send, adoptSequence: (value) => { seq = value; } };
|
|
}
|
|
const rows = [];
|
|
for (const machine of MACHINE_DEFINITIONS) {
|
|
const original = await open(machine);
|
|
const idle = original.send("CHECKPOINT").payload.checkpoint;
|
|
const idleRestored = await open(machine);
|
|
assert.equal(idleRestored.session.restoreCheckpoint(idle).status, "recovered");
|
|
assert.equal(fnv1a64(idleRestored.session.workspacePackageState()), fnv1a64(original.session.workspacePackageState()), "idle machine without a loaded program");
|
|
const rotary = machine.coordinates[3];
|
|
const source = `G21 G90 G64\nM428\n#31=3\nG1 X6 Y0 Z2 ${rotary}5 C8 F600\nG3 X0 Y6 I-6 J0 ${rotary}10 C16\nO<${machine.subroutines[0]}> call [3]\nM429\nG1 X2 Y3 Z4 F600\nM2\n`;
|
|
original.send("PROGRAM_LOAD", { source, path: machine.program.replace(/[^/]+$/, "checkpoint-exact.ngc") }); original.send("PROGRAM_RUN");
|
|
const seen = new Set(); let samples = 0;
|
|
for (let index = 0; index < 500; index++) {
|
|
original.send("SIM_STEP", { cycles: 5, response: "motion_delta" });
|
|
const current = original.session.wasmMotion.read().state;
|
|
const program = original.session.xyzacProgram.snapshot();
|
|
const phase = program.call_depth > 0 ? "subroutine" : current.motion_type === 3 ? "arc" : current.queue_depth > 0 ? "line" : program.program_status === "completed" ? "completed" : "barrier";
|
|
if (!seen.has(phase)) {
|
|
seen.add(phase);
|
|
const checkpoint = original.send("CHECKPOINT").payload.checkpoint;
|
|
assert.ok(checkpoint?.state.program.native_state);
|
|
assert.ok(!JSON.stringify(checkpoint.state.ledger).includes('"checkpoint":'), "durable ledger must not recursively contain earlier checkpoints");
|
|
const restarted = await open(machine);
|
|
restarted.session.workspaceFiles[machine.entrypoint.replace(/[^/]+$/, `remap_subs/${machine.subroutines[0]}.ngc`)] = "unrelated new subroutine contents";
|
|
const result = restarted.session.restoreCheckpoint(checkpoint);
|
|
assert.equal(result.status, "recovered", JSON.stringify(result.blocked));
|
|
restarted.adoptSequence(checkpoint.event_cursor);
|
|
assert.equal(fnv1a64(restarted.session.workspacePackageState()), fnv1a64(original.session.workspacePackageState()), `${machine.id} ${phase}: initial state`);
|
|
for (let step = 0; step < 12; step++) {
|
|
original.send("SIM_STEP", { cycles: 1, response: "motion_delta" }); restarted.send("SIM_STEP", { cycles: 1, response: "motion_delta" });
|
|
const expected = original.session.workspacePackageState(), actual = restarted.session.workspacePackageState();
|
|
assert.equal(fnv1a64(actual), fnv1a64(expected), `${machine.id} ${phase}: divergent tick ${original.session.tick}`);
|
|
samples++;
|
|
}
|
|
rows.push({ machine: machine.id, phase, checkpoint_tick: checkpoint.simulation_tick, continuation_tick: original.session.tick, state_hash: fnv1a64(original.session.workspacePackageState()), samples: 12, first_divergent_tick: null });
|
|
if (phase === "subroutine") {
|
|
original.send("PROGRAM_PAUSE");
|
|
const paused = original.send("CHECKPOINT").payload.checkpoint;
|
|
const resumed = await open(machine);
|
|
assert.equal(resumed.session.restoreCheckpoint(paused).status, "recovered");
|
|
resumed.adoptSequence(paused.event_cursor);
|
|
const transported = await open(machine);
|
|
assert.equal(transported.send("RECOVER", { checkpoint: paused }).payload.status, "recovered");
|
|
original.send("SIM_STEP", { cycles: 2 }); resumed.send("SIM_STEP", { cycles: 2 });
|
|
assert.equal(fnv1a64(resumed.session.workspacePackageState()), fnv1a64(original.session.workspacePackageState()), "paused state must stay unchanged");
|
|
original.send("PROGRAM_RESUME"); resumed.send("PROGRAM_RESUME"); transported.send("PROGRAM_RESUME");
|
|
for (let step = 0; step < 12; step++) {
|
|
original.send("SIM_STEP", { cycles: 1, response: "motion_delta" }); resumed.send("SIM_STEP", { cycles: 1, response: "motion_delta" });
|
|
transported.send("SIM_STEP", { cycles: 1, response: "motion_delta" });
|
|
assert.equal(fnv1a64(resumed.session.workspacePackageState()), fnv1a64(original.session.workspacePackageState()), `paused resume divergent tick ${original.session.tick}`);
|
|
assert.deepEqual(transported.session.wasmMotion.read(), original.session.wasmMotion.read(), "new transport must continue the same physical trajectory");
|
|
}
|
|
rows.push({ machine: machine.id, phase: "paused-resumed", checkpoint_tick: paused.simulation_tick, continuation_tick: original.session.tick, state_hash: fnv1a64(original.session.workspacePackageState()), samples: 12, new_transport_recovery: true, first_divergent_tick: null });
|
|
}
|
|
}
|
|
if (original.session.xyzacProgram.snapshot().program_status === "completed" && original.session.wasmMotion.read().state.queue_depth === 0) break;
|
|
}
|
|
assert.equal(original.session.xyzacProgram.snapshot().program_status, "completed");
|
|
assert.ok(seen.has("line") && seen.has("arc"), [...seen].join(","));
|
|
assert.ok(samples >= 24);
|
|
const trace = original.session.exportTrace();
|
|
const imported = await open(machine);
|
|
assert.equal(imported.session.importTrace(trace).status, "verified");
|
|
assert.equal(fnv1a64(imported.session.workspacePackageState()), fnv1a64(original.session.workspacePackageState()), "native trace import");
|
|
const checkpoint = original.send("CHECKPOINT").payload.checkpoint;
|
|
const invalid = structuredClone(checkpoint);
|
|
invalid.state.wasm_motion.native_state.blocks[0][1] = "invalid";
|
|
invalid.state_hash = fnv1a64(invalid.state);
|
|
invalid.checkpoint_hash = fnv1a64(Object.fromEntries(Object.entries(invalid).filter(([key]) => key !== "checkpoint_hash")));
|
|
const unchanged = fnv1a64(original.session.workspacePackageState());
|
|
assert.equal(original.session.restoreCheckpoint(invalid).status, "blocked");
|
|
assert.equal(fnv1a64(original.session.workspacePackageState()), unchanged, "corrupt native blob rejected before mutation");
|
|
const foreignBuild = structuredClone(checkpoint);
|
|
foreignBuild.state.wasm_motion.native_state.build_id = "0".repeat(64);
|
|
foreignBuild.state_hash = fnv1a64(foreignBuild.state);
|
|
foreignBuild.checkpoint_hash = fnv1a64(Object.fromEntries(Object.entries(foreignBuild).filter(([key]) => key !== "checkpoint_hash")));
|
|
const rejectedBuild = original.session.restoreCheckpoint(foreignBuild);
|
|
assert.equal(rejectedBuild.status, "blocked");
|
|
assert.match(rejectedBuild.blocked[0].reason, /checkpoint_build_mismatch/);
|
|
assert.equal(fnv1a64(original.session.workspacePackageState()), unchanged);
|
|
const transported = await open(machine);
|
|
const recovery = transported.send("RECOVER", { checkpoint });
|
|
assert.equal(recovery.payload.status, "recovered", JSON.stringify(recovery.payload));
|
|
const next = transported.send("SIM_STEP", { cycles: 1 });
|
|
assert.equal(next.payload.program.program_status, "completed", "transport sequence continues after recovery");
|
|
assert.deepEqual(next.payload.wasm_motion.state.position, original.session.wasmMotion.read().state.position);
|
|
rows.push({ machine: machine.id, phase: "trace-and-transport", state_hash: fnv1a64(original.session.workspacePackageState()), first_divergent_tick: null });
|
|
transported.send("SET_MODE", { mode: "MANUAL" });
|
|
transported.send("PROGRAM_LOAD", { source: "G21 G90\nM2\n", path: "jog-after-recovery.ngc" });
|
|
transported.send("JOG_START", { axis: "x", direction: 1, increment: .1 });
|
|
let jogSamples = 0;
|
|
while (transported.session.wasmMotion.read().state.queue_depth > 0 && jogSamples < 100) {
|
|
const delta = transported.send("SIM_STEP", { cycles: 1, response: "motion_delta" });
|
|
assert.deepEqual(delta.payload.motion_sample.joints, transported.session.wasmMotion.read().state.joint_commanded, "identity motion delta must use the current planner joints");
|
|
jogSamples++;
|
|
}
|
|
assert.equal(transported.session.wasmMotion.read().state.queue_depth, 0);
|
|
rows.push({ machine: machine.id, phase: "post-recovery-jog", samples: jogSamples, first_divergent_tick: null });
|
|
const beforeRewind = transported.session.wasmMotion.read();
|
|
const rewind = transported.send("RECOVER", { checkpoint: idle });
|
|
assert.equal(rewind.payload.status, "blocked");
|
|
assert.equal(rewind.payload.code, "runtime_checkpoint_requires_new_session");
|
|
assert.deepEqual(transported.session.wasmMotion.read(), beforeRewind);
|
|
}
|
|
const report = { report_id: "trt-checkpoint-exact-v1", status: "verified", safeguards: { corrupt_native_rejected: true, foreign_build_rejected: true, frozen_subroutines: true, heap_relocation_checked: true, receipts_nonrecursive: true, backward_transport_restore_rejected: true }, rows, state_hash: fnv1a64(rows) };
|
|
const output = process.env.TRT_EVIDENCE_DIR ? pathToFileURL(`${process.env.TRT_EVIDENCE_DIR.replace(/\/$/, "")}/`) : new URL("coverage/recovery/", root);
|
|
await mkdir(output, { recursive: true });
|
|
await writeFile(new URL("exact-checkpoint.report.json", output), JSON.stringify(report, null, 2) + "\n");
|
|
console.log(JSON.stringify(report));
|