Initial LinuxCNC WebAssembly workspace snapshot
This commit is contained in:
43
web/worker/capabilities.mjs
Normal file
43
web/worker/capabilities.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import { realExcluded } from "./error-map.mjs";
|
||||
|
||||
export const LIMITS = Object.freeze({
|
||||
max_command_bytes: 65536,
|
||||
max_snapshot_bytes: 1048576,
|
||||
max_tick_step: 1000000,
|
||||
max_event_records: 256,
|
||||
max_workspace_path_bytes: 1024,
|
||||
max_workspace_file_bytes: 64 * 1024 * 1024,
|
||||
});
|
||||
|
||||
export const COMMANDS = Object.freeze([
|
||||
"SESSION_ATTACH", "LOAD_WORKSPACE", "COMMAND", "SIM_STEP", "SNAPSHOT_REQUEST",
|
||||
"PROGRAM_PAUSE", "PROGRAM_RESUME", "CHECKPOINT", "RECOVER", "DESTROY",
|
||||
]);
|
||||
|
||||
export const EXCLUSIONS = Object.freeze([
|
||||
realExcluded("real_hardware", "browser_has_no_direct_machine_control", "digital_twin"),
|
||||
realExcluded("linux_realtime_kernel", "wasm_worker_uses_logical_ticks", "fixed_tick_scheduler"),
|
||||
realExcluded("host_device_access", "browser_sandbox_disallows_arbitrary_devices", "virtual_device_adapter"),
|
||||
]);
|
||||
|
||||
export function capabilities(contextId) {
|
||||
return {
|
||||
context_id: contextId,
|
||||
abi: 1,
|
||||
protocol: 1,
|
||||
snapshot: 1,
|
||||
event: 1,
|
||||
workspace: 1,
|
||||
commands: [...COMMANDS],
|
||||
streams: ["STATE_SNAPSHOT", "EVENT", "ALARM"],
|
||||
limits: { ...LIMITS },
|
||||
exclusions: EXCLUSIONS.map((entry) => ({ ...entry })),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateLimit(name, value) {
|
||||
if (!(name in LIMITS)) return { status: "blocked", code: "capability_unknown_limit", message_key: "capability.unknown_limit", limit: name };
|
||||
if (!Number.isSafeInteger(value) || value < 0) return { status: "blocked", code: "capability_invalid_value", message_key: "capability.invalid_value", limit: name, value };
|
||||
if (value > LIMITS[name]) return { status: "blocked", code: "capability_limit_exceeded", message_key: "capability.limit_exceeded", limit: name, value, maximum: LIMITS[name] };
|
||||
return { status: "accepted", limit: name, value, maximum: LIMITS[name] };
|
||||
}
|
||||
58
web/worker/error-map.mjs
Normal file
58
web/worker/error-map.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
export const ERROR_DEFINITIONS = Object.freeze({
|
||||
0: { code: "ok", message_key: "ok", retryable: false, category: "none" },
|
||||
[-1]: { code: "sim_invalid_argument", message_key: "sim.invalid_argument", retryable: false, category: "core" },
|
||||
[-2]: { code: "sim_not_created", message_key: "sim.not_created", retryable: false, category: "core" },
|
||||
[-3]: { code: "sim_buffer_too_small", message_key: "sim.buffer_too_small", retryable: true, category: "core" },
|
||||
[-4]: { code: "sim_invalid_state", message_key: "sim.invalid_state", retryable: false, category: "core" },
|
||||
[-5]: { code: "sim_unsupported", message_key: "sim.unsupported", retryable: false, category: "capability" },
|
||||
[-100]: { code: "protocol_schema", message_key: "protocol.unsupported_schema", retryable: false, category: "protocol" },
|
||||
[-101]: { code: "protocol_sequence", message_key: "protocol.sequence_gap", retryable: true, category: "protocol" },
|
||||
[-102]: { code: "protocol_state", message_key: "protocol.invalid_state", retryable: false, category: "protocol" },
|
||||
[-103]: { code: "protocol_operation_id", message_key: "protocol.operation_id_required", retryable: false, category: "protocol" },
|
||||
[-104]: { code: "protocol_kind", message_key: "protocol.invalid_kind", retryable: false, category: "protocol" },
|
||||
[-105]: { code: "protocol_argument", message_key: "protocol.invalid_argument", retryable: false, category: "protocol" },
|
||||
[-106]: { code: "protocol_tick", message_key: "protocol.tick_regression", retryable: false, category: "protocol" },
|
||||
[-200]: { code: "event_sequence", message_key: "event.sequence_gap", retryable: true, category: "event" },
|
||||
[-201]: { code: "event_tick", message_key: "event.tick_regression", retryable: false, category: "event" },
|
||||
[-202]: { code: "event_argument", message_key: "event.invalid_argument", retryable: false, category: "event" },
|
||||
[-203]: { code: "event_full", message_key: "event.capacity_exceeded", retryable: true, category: "event" },
|
||||
[-204]: { code: "event_buffer", message_key: "event.buffer_too_small", retryable: true, category: "event" },
|
||||
});
|
||||
|
||||
export function mapCoreError(value, context = {}) {
|
||||
const definition = ERROR_DEFINITIONS[value];
|
||||
if (!definition) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "core_unknown_error",
|
||||
message_key: "core.unknown_error",
|
||||
category: "core",
|
||||
retryable: false,
|
||||
original_code: value,
|
||||
operation_id: context.operation_id ?? null,
|
||||
simulation_tick: context.simulation_tick ?? 0,
|
||||
source: context.source ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: value === 0 ? "ok" : "error",
|
||||
...definition,
|
||||
original_code: value,
|
||||
operation_id: context.operation_id ?? null,
|
||||
simulation_tick: context.simulation_tick ?? 0,
|
||||
source: context.source ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function realExcluded(capability, reason, substitute = null) {
|
||||
return {
|
||||
status: "REAL-EXCLUDED",
|
||||
code: "real_hardware_excluded",
|
||||
message_key: "capability.real_hardware_excluded",
|
||||
category: "capability",
|
||||
capability,
|
||||
reason,
|
||||
substitute,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
125
web/worker/replay.mjs
Normal file
125
web/worker/replay.mjs
Normal file
@@ -0,0 +1,125 @@
|
||||
const EVENT_SCHEMA = "event-log.v1";
|
||||
const SNAPSHOT_SCHEMA = "snapshot.v1";
|
||||
|
||||
export function canonicalJson(value) {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
export function fnv1a64(value) {
|
||||
const bytes = new TextEncoder().encode(typeof value === "string" ? value : canonicalJson(value));
|
||||
let hash = 1469598103934665603n;
|
||||
for (const byte of bytes) {
|
||||
hash ^= BigInt(byte);
|
||||
hash = BigInt.asUintN(64, hash * 1099511628211n);
|
||||
}
|
||||
return `fnv1a64:${hash.toString(16).padStart(16, "0")}`;
|
||||
}
|
||||
|
||||
function requireInteger(value, name) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_${name}`);
|
||||
}
|
||||
|
||||
export class EventLog {
|
||||
constructor(events = []) {
|
||||
this.events = [];
|
||||
for (const event of events) this.append(event.seq, event.tick, event.kind, event.payload, event.payload_hash);
|
||||
}
|
||||
|
||||
append(seq, tick, kind, payload = {}, expectedHash = undefined) {
|
||||
requireInteger(seq, "seq");
|
||||
requireInteger(tick, "tick");
|
||||
if (seq !== this.cursor + 1) throw new Error("event_sequence_gap");
|
||||
if (tick < this.tick) throw new Error("event_tick_regression");
|
||||
if (typeof kind !== "string" || kind.length === 0 || kind.length > 64) throw new Error("event_kind_invalid");
|
||||
const payloadHash = fnv1a64(payload);
|
||||
if (expectedHash !== undefined && expectedHash !== payloadHash) throw new Error("event_payload_hash_mismatch");
|
||||
const event = { seq, tick, kind, payload, payload_hash: payloadHash };
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
get cursor() { return this.events.length === 0 ? 0 : this.events[this.events.length - 1].seq; }
|
||||
get tick() { return this.events.length === 0 ? 0 : this.events[this.events.length - 1].tick; }
|
||||
|
||||
export() {
|
||||
return { schema: EVENT_SCHEMA, cursor: this.cursor, tick: this.tick, events: this.events.map((event) => ({ ...event })) };
|
||||
}
|
||||
|
||||
static import(value) {
|
||||
if (!value || value.schema !== EVENT_SCHEMA || !Array.isArray(value.events)) throw new Error("event_log_schema");
|
||||
const log = new EventLog(value.events);
|
||||
if (log.cursor !== value.cursor || log.tick !== value.tick) throw new Error("event_log_cursor_mismatch");
|
||||
return log;
|
||||
}
|
||||
}
|
||||
|
||||
export class SnapshotStore {
|
||||
constructor(snapshots = []) {
|
||||
this.snapshots = new Map();
|
||||
for (const snapshot of snapshots) this.importSnapshot(snapshot);
|
||||
}
|
||||
|
||||
create(seq, tick, state, baseSnapshotId = null) {
|
||||
requireInteger(seq, "snapshot_seq");
|
||||
requireInteger(tick, "snapshot_tick");
|
||||
if (baseSnapshotId !== null && !this.snapshots.has(baseSnapshotId)) throw new Error("snapshot_base_missing");
|
||||
const stateHash = fnv1a64(state);
|
||||
const snapshot = {
|
||||
schema: SNAPSHOT_SCHEMA,
|
||||
snapshot_id: `snap:${seq}:${stateHash}`,
|
||||
base_snapshot_id: baseSnapshotId,
|
||||
seq,
|
||||
tick,
|
||||
state,
|
||||
state_hash: stateHash,
|
||||
};
|
||||
this.snapshots.set(snapshot.snapshot_id, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
importSnapshot(snapshot) {
|
||||
if (!snapshot || snapshot.schema !== SNAPSHOT_SCHEMA || typeof snapshot.snapshot_id !== "string") throw new Error("snapshot_schema");
|
||||
if (snapshot.base_snapshot_id !== null && !this.snapshots.has(snapshot.base_snapshot_id)) throw new Error("snapshot_base_missing");
|
||||
if (fnv1a64(snapshot.state) !== snapshot.state_hash) throw new Error("snapshot_hash_mismatch");
|
||||
if (snapshot.snapshot_id !== `snap:${snapshot.seq}:${snapshot.state_hash}`) throw new Error("snapshot_id_mismatch");
|
||||
this.snapshots.set(snapshot.snapshot_id, { ...snapshot });
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
latest() {
|
||||
return this.snapshots.size === 0 ? null : [...this.snapshots.values()][this.snapshots.size - 1];
|
||||
}
|
||||
|
||||
export() {
|
||||
return { schema: SNAPSHOT_SCHEMA, snapshots: [...this.snapshots.values()].map((snapshot) => ({ ...snapshot })) };
|
||||
}
|
||||
}
|
||||
|
||||
export class ReplayTimeline {
|
||||
constructor(value = null) {
|
||||
this.events = value ? EventLog.import(value.events) : new EventLog();
|
||||
this.snapshots = value ? new SnapshotStore(value.snapshots.snapshots) : new SnapshotStore();
|
||||
}
|
||||
|
||||
appendEvent(seq, tick, kind, payload = {}) {
|
||||
return this.events.append(seq, tick, kind, payload);
|
||||
}
|
||||
|
||||
checkpoint(seq, tick, state, baseSnapshotId = null) {
|
||||
return this.snapshots.create(seq, tick, state, baseSnapshotId);
|
||||
}
|
||||
|
||||
replay(initialState, apply) {
|
||||
let state = initialState;
|
||||
for (const event of this.events.events) state = apply(state, event);
|
||||
return { state, state_hash: fnv1a64(state), cursor: this.events.cursor, tick: this.events.tick };
|
||||
}
|
||||
|
||||
export() {
|
||||
return { events: this.events.export(), snapshots: this.snapshots.export() };
|
||||
}
|
||||
}
|
||||
|
||||
export { EVENT_SCHEMA, SNAPSHOT_SCHEMA };
|
||||
144
web/worker/sim-session.mjs
Normal file
144
web/worker/sim-session.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
import { ReplayTimeline, fnv1a64 } from "./replay.mjs";
|
||||
import { capabilities, validateLimit } from "./capabilities.mjs";
|
||||
import { mapCoreError } from "./error-map.mjs";
|
||||
|
||||
const PROTOCOL_SCHEMA = 1;
|
||||
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m0-c1";
|
||||
|
||||
function text(value) {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function call(core, name, ...args) {
|
||||
if (args.some((value) => typeof value === "string") && typeof core.ccall === "function") {
|
||||
return core.ccall(name, "number", args.map((value) => typeof value === "string" ? "string" : "number"), args);
|
||||
}
|
||||
const direct = core[`_${name}`] ?? core[name];
|
||||
if (typeof direct === "function") return direct(...args);
|
||||
if (typeof core.cwrap === "function") {
|
||||
const wrapped = core.cwrap(name, "number", args.map((value) => typeof value === "string" ? "string" : "number"));
|
||||
return wrapped(...args);
|
||||
}
|
||||
throw new Error(`missing core export: ${name}`);
|
||||
}
|
||||
|
||||
function appendCoreEvent(core, seq, tick, kind, payload) {
|
||||
if (typeof core.ccall !== "function") return 0;
|
||||
const bytes = new TextEncoder().encode(canonicalPayload(payload));
|
||||
return core.ccall("sim_event_append", "number", ["number", "number", "string", "array", "number"], [seq, tick, kind, bytes, bytes.length]);
|
||||
}
|
||||
|
||||
function canonicalPayload(value) {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalPayload).join(",")}]`;
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalPayload(value[key])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
export class SimSession {
|
||||
constructor(core, { contextId = CONTEXT_ID, sessionId = "" } = {}) {
|
||||
this.core = core;
|
||||
this.contextId = contextId;
|
||||
this.sessionId = sessionId;
|
||||
this.tick = 0;
|
||||
this.created = false;
|
||||
this.timeline = new ReplayTimeline();
|
||||
call(core, "sim_protocol_reset");
|
||||
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
|
||||
}
|
||||
|
||||
envelope(kind, input, payload, tick = this.tick) {
|
||||
return {
|
||||
schema_version: PROTOCOL_SCHEMA,
|
||||
kind,
|
||||
context_id: this.contextId,
|
||||
session_id: this.sessionId,
|
||||
seq: input.seq,
|
||||
...(text(input.operation_id) ? { operation_id: input.operation_id } : {}),
|
||||
simulation_tick: tick,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
error(input, code, messageKey, retryable = false) {
|
||||
return this.envelope("ERROR", input, {
|
||||
status: "error",
|
||||
code,
|
||||
message_key: messageKey,
|
||||
retryable,
|
||||
operation_id: input.operation_id ?? null,
|
||||
simulation_tick: this.tick,
|
||||
});
|
||||
}
|
||||
|
||||
coreError(input, result, source) {
|
||||
const mapped = mapCoreError(result, { operation_id: input.operation_id, simulation_tick: this.tick, source });
|
||||
return this.envelope("ERROR", input, mapped);
|
||||
}
|
||||
|
||||
validateEnvelope(input) {
|
||||
if (!input || typeof input !== "object") return ["protocol_argument", "invalid_envelope"];
|
||||
if (input.schema_version !== PROTOCOL_SCHEMA) return ["protocol_schema", "unsupported_schema"];
|
||||
if (input.context_id !== this.contextId) return ["protocol_context", "context_mismatch"];
|
||||
if (!text(input.kind) || !Number.isSafeInteger(input.seq) || input.seq <= 0) return ["protocol_argument", "invalid_sequence_or_kind"];
|
||||
if (this.sessionId && input.session_id !== this.sessionId) return ["protocol_session", "session_mismatch"];
|
||||
if (!this.sessionId && text(input.session_id)) this.sessionId = input.session_id;
|
||||
return null;
|
||||
}
|
||||
|
||||
accept(input) {
|
||||
const invalid = this.validateEnvelope(input);
|
||||
if (invalid) return this.error(input ?? {}, invalid[0], invalid[1]);
|
||||
const result = call(this.core, "sim_protocol_accept", input.schema_version, input.seq, input.kind, input.operation_id ?? "", input.simulation_tick ?? this.tick);
|
||||
if (result !== 0) return this.coreError(input, result, "sim_protocol_accept");
|
||||
if (Number.isSafeInteger(input.simulation_tick) && input.simulation_tick >= this.tick) this.tick = input.simulation_tick;
|
||||
const eventTick = input.kind === "SIM_STEP" && Number.isSafeInteger(input.payload?.cycles)
|
||||
? this.tick + input.payload.cycles : this.tick;
|
||||
try {
|
||||
this.timeline.appendEvent(input.seq, eventTick, input.kind, input.payload ?? {});
|
||||
} catch (error) {
|
||||
return this.error(input, "event_log", String(error?.message ?? error));
|
||||
}
|
||||
const coreEventResult = appendCoreEvent(this.core, input.seq, eventTick, input.kind, input.payload ?? {});
|
||||
if (coreEventResult !== 0) return this.error(input, "event_log", "core_event_append_failed");
|
||||
|
||||
switch (input.kind) {
|
||||
case "HELLO":
|
||||
return this.envelope("CAPABILITIES", input, capabilities(this.contextId));
|
||||
case "SESSION_ATTACH":
|
||||
if (!this.created) {
|
||||
const createResult = call(this.core, "sim_create", 0, 0);
|
||||
if (createResult !== 0) return this.coreError(input, createResult, "sim_create");
|
||||
this.created = true;
|
||||
}
|
||||
return this.envelope("SESSION_READY", input, { state_hash: fnv1a64({ tick: this.tick }), subscriptions: [] });
|
||||
case "LOAD_WORKSPACE": {
|
||||
const rootPath = input.payload?.root_path;
|
||||
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return this.error(input, "workspace_path", "invalid_workspace_path");
|
||||
const loadResult = call(this.core, "sim_load_workspace", rootPath);
|
||||
return loadResult === 0 ? this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, state_hash: fnv1a64({ tick: this.tick }) }) : this.coreError(input, loadResult, "sim_load_workspace");
|
||||
}
|
||||
case "SIM_STEP": {
|
||||
const cycles = input.payload?.cycles;
|
||||
const limit = validateLimit("max_tick_step", cycles);
|
||||
if (limit.status !== "accepted") return this.envelope("ERROR", input, { ...limit, operation_id: input.operation_id ?? null, simulation_tick: this.tick, retryable: false });
|
||||
const stepResult = call(this.core, "sim_step", cycles);
|
||||
if (stepResult !== 0) return this.coreError(input, stepResult, "sim_step");
|
||||
this.tick += cycles;
|
||||
const protocolTick = call(this.core, "sim_protocol_set_tick", this.tick);
|
||||
if (protocolTick !== 0) return this.error(input, "protocol_tick", "tick_update_failed");
|
||||
const snapshot = this.timeline.checkpoint(input.seq, this.tick, { tick: this.tick, cycles }, this.timeline.snapshots.latest()?.snapshot_id ?? null);
|
||||
return this.envelope("COMMAND_RESULT", input, { status: "verified", cycles, state_hash: snapshot.state_hash, snapshot });
|
||||
}
|
||||
case "SNAPSHOT_REQUEST":
|
||||
return this.envelope("STATE_SNAPSHOT", input, { snapshot_schema: 1, tick: this.tick, state_hash: fnv1a64({ tick: this.tick }), snapshot: this.timeline.snapshots.latest() });
|
||||
case "DESTROY":
|
||||
call(this.core, "sim_destroy");
|
||||
this.created = false;
|
||||
return this.envelope("COMMAND_RESULT", input, { status: "verified", destroyed: true, replay: this.timeline.export() });
|
||||
default:
|
||||
return this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { CONTEXT_ID, PROTOCOL_SCHEMA };
|
||||
20
web/worker/sim-worker.mjs
Normal file
20
web/worker/sim-worker.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { SimSession } from "./sim-session.mjs";
|
||||
|
||||
const core = await createModule();
|
||||
const session = new SimSession(core);
|
||||
|
||||
self.onmessage = (event) => {
|
||||
try {
|
||||
self.postMessage(session.accept(event.data));
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
schema_version: 1,
|
||||
kind: "ERROR",
|
||||
context_id: session.contextId,
|
||||
session_id: session.sessionId,
|
||||
seq: event.data?.seq ?? 0,
|
||||
payload: { status: "error", code: "worker_exception", message_key: String(error?.message ?? error), retryable: false },
|
||||
});
|
||||
}
|
||||
};
|
||||
39
web/worker/test_replay.mjs
Normal file
39
web/worker/test_replay.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventLog, ReplayTimeline, SnapshotStore, fnv1a64 } from "./replay.mjs";
|
||||
|
||||
function buildTimeline() {
|
||||
const timeline = new ReplayTimeline();
|
||||
timeline.appendEvent(1, 0, "SESSION_READY", {});
|
||||
timeline.appendEvent(2, 5, "SIM_STEP", { cycles: 5 });
|
||||
timeline.checkpoint(2, 5, { tick: 5, position: 5 });
|
||||
timeline.appendEvent(3, 9, "SIM_STEP", { cycles: 4 });
|
||||
const base = timeline.snapshots.latest().snapshot_id;
|
||||
timeline.checkpoint(3, 9, { tick: 9, position: 9 }, base);
|
||||
return timeline;
|
||||
}
|
||||
|
||||
const first = buildTimeline();
|
||||
const second = buildTimeline();
|
||||
assert.deepEqual(first.export(), second.export());
|
||||
const replay = first.replay({ tick: 0, position: 0 }, (state, event) => ({
|
||||
tick: event.tick,
|
||||
position: state.position + (event.payload.cycles ?? 0),
|
||||
}));
|
||||
assert.equal(replay.tick, 9);
|
||||
assert.equal(replay.state.position, 9);
|
||||
assert.equal(replay.state_hash, fnv1a64({ tick: 9, position: 9 }));
|
||||
|
||||
assert.throws(() => {
|
||||
const log = new EventLog();
|
||||
log.append(2, 0, "GAP", {});
|
||||
}, /event_sequence_gap/);
|
||||
|
||||
assert.throws(() => {
|
||||
const snapshots = new SnapshotStore();
|
||||
snapshots.create(1, 1, { tick: 1 }, "snap:missing");
|
||||
}, /snapshot_base_missing/);
|
||||
|
||||
const exported = first.export();
|
||||
exported.snapshots.snapshots[0].state_hash = "fnv1a64:0000000000000000";
|
||||
assert.throws(() => new ReplayTimeline(exported), /snapshot_hash_mismatch/);
|
||||
console.log("replay=passed cursor=" + replay.cursor + " tick=" + replay.tick);
|
||||
36
web/worker/test_runtime_mapping.mjs
Normal file
36
web/worker/test_runtime_mapping.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { capabilities, validateLimit } from "./capabilities.mjs";
|
||||
import { ERROR_DEFINITIONS, mapCoreError, realExcluded } from "./error-map.mjs";
|
||||
|
||||
const codes = Object.keys(ERROR_DEFINITIONS).map(Number);
|
||||
assert.equal(codes.length, new Set(codes).size);
|
||||
for (const code of codes) {
|
||||
const mapped = mapCoreError(code, { operation_id: "op", simulation_tick: 7, source: "test" });
|
||||
assert.equal(mapped.original_code, code);
|
||||
assert.equal(mapped.operation_id, "op");
|
||||
assert.equal(mapped.simulation_tick, 7);
|
||||
assert.ok(mapped.message_key.includes("." ) || mapped.message_key === "ok");
|
||||
}
|
||||
const unknown = mapCoreError(-999);
|
||||
assert.equal(unknown.code, "core_unknown_error");
|
||||
assert.equal(unknown.retryable, false);
|
||||
const caps = capabilities("ctx");
|
||||
assert.equal(caps.context_id, "ctx");
|
||||
assert.ok(caps.commands.includes("SIM_STEP"));
|
||||
assert.equal(caps.exclusions.every((item) => item.status === "REAL-EXCLUDED"), true);
|
||||
assert.equal(validateLimit("max_tick_step", 1000000).status, "accepted");
|
||||
assert.equal(validateLimit("max_tick_step", 1000001).code, "capability_limit_exceeded");
|
||||
assert.equal(validateLimit("missing", 1).code, "capability_unknown_limit");
|
||||
assert.equal(realExcluded("pci", "no_device").code, "real_hardware_excluded");
|
||||
|
||||
const report = {
|
||||
schema: "runtime-mapping-report.v1",
|
||||
status: "passed",
|
||||
mapped_error_codes: codes.length,
|
||||
commands: caps.commands.length,
|
||||
limits: Object.keys(caps.limits).length,
|
||||
real_exclusions: caps.exclusions.length,
|
||||
};
|
||||
await writeFile(new URL("../../coverage/runtime-mapping.report.json", import.meta.url), `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(`runtime_mapping=passed errors=${codes.length} exclusions=${caps.exclusions.length}`);
|
||||
36
web/worker/test_sim_session.mjs
Normal file
36
web/worker/test_sim_session.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const calls = [];
|
||||
let lastSeq = 0;
|
||||
const core = {
|
||||
_sim_protocol_reset() { calls.push(["reset"]); },
|
||||
_sim_protocol_accept(...args) {
|
||||
calls.push(["accept", ...args]);
|
||||
if (args[1] !== lastSeq + 1) return -101;
|
||||
lastSeq = args[1];
|
||||
return 0;
|
||||
},
|
||||
_sim_create(...args) { calls.push(["create", ...args]); return 0; },
|
||||
_sim_load_workspace(...args) { calls.push(["load", ...args]); return 0; },
|
||||
_sim_step(...args) { calls.push(["step", ...args]); return 0; },
|
||||
_sim_protocol_set_tick(...args) { calls.push(["set_tick", ...args]); return 0; },
|
||||
_sim_destroy(...args) { calls.push(["destroy", ...args]); },
|
||||
};
|
||||
|
||||
const session = new SimSession(core);
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "s-1" };
|
||||
let response = session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} });
|
||||
assert.equal(response.kind, "CAPABILITIES");
|
||||
response = session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, payload: { project_id: "demo" } });
|
||||
assert.equal(response.kind, "SESSION_READY");
|
||||
response = session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "op-3", payload: { root_path: "/workspace" } });
|
||||
assert.equal(response.payload.status, "verified");
|
||||
response = session.accept({ ...base, kind: "SIM_STEP", seq: 4, operation_id: "op-4", payload: { cycles: 7 } });
|
||||
assert.equal(response.simulation_tick, 7);
|
||||
response = session.accept({ ...base, kind: "SNAPSHOT_REQUEST", seq: 5, payload: {} });
|
||||
assert.equal(response.payload.tick, 7);
|
||||
response = session.accept({ ...base, kind: "SIM_STEP", seq: 7, operation_id: "op-7", payload: { cycles: 1 } });
|
||||
assert.equal(response.kind, "ERROR");
|
||||
assert.equal(response.payload.code, "protocol_sequence");
|
||||
console.log("sim_session=passed calls=" + calls.length);
|
||||
20
web/worker/test_wasm_session.mjs
Normal file
20
web/worker/test_wasm_session.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||
|
||||
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
|
||||
const core = await createModule({ wasmBinary });
|
||||
const session = new SimSession(core, { sessionId: "s-real" });
|
||||
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: "s-real" };
|
||||
|
||||
assert.equal(session.accept({ ...base, kind: "HELLO", seq: 1, payload: {} }).kind, "CAPABILITIES");
|
||||
assert.equal(session.accept({ ...base, kind: "SESSION_ATTACH", seq: 2, operation_id: "op-2", payload: {} }).kind, "SESSION_READY");
|
||||
assert.equal(session.accept({ ...base, kind: "LOAD_WORKSPACE", seq: 3, operation_id: "op-3", payload: { root_path: "/workspace" } }).payload.status, "verified");
|
||||
const stepped = session.accept({ ...base, kind: "SIM_STEP", seq: 4, operation_id: "op-4", payload: { cycles: 11 } });
|
||||
assert.equal(stepped.simulation_tick, 11);
|
||||
assert.equal(core._sim_protocol_last_seq(), 4);
|
||||
assert.equal(core._sim_protocol_tick(), 11);
|
||||
assert.equal(core._sim_event_count(), 4);
|
||||
assert.equal(session.accept({ ...base, kind: "SNAPSHOT_REQUEST", seq: 6, payload: {} }).payload.code, "protocol_sequence");
|
||||
console.log("wasm_session=passed tick=" + stepped.simulation_tick);
|
||||
140
web/workspace/journal.mjs
Normal file
140
web/workspace/journal.mjs
Normal file
@@ -0,0 +1,140 @@
|
||||
import { canonicalJson, fnv1a64 } from "../worker/replay.mjs";
|
||||
|
||||
export const JOURNAL_SCHEMA = "workspace-journal.v1";
|
||||
|
||||
export class MemoryStorage {
|
||||
constructor(initial = {}) {
|
||||
this.records = new Map(Object.entries(initial));
|
||||
}
|
||||
|
||||
get(key) { return this.records.get(key); }
|
||||
set(key, value) { this.records.set(key, value); }
|
||||
entries() { return [...this.records.entries()].sort(([a], [b]) => a.localeCompare(b)); }
|
||||
}
|
||||
|
||||
function pathOf(value) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.startsWith("/") || value.includes("\\")) throw new Error("workspace_path_invalid");
|
||||
const parts = value.split("/").filter(Boolean);
|
||||
if (parts.some((part) => part === "." || part === "..")) throw new Error("workspace_path_invalid");
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function dataHash(data) {
|
||||
if (typeof data !== "string") throw new Error("workspace_data_invalid");
|
||||
return fnv1a64(data);
|
||||
}
|
||||
|
||||
export class WorkspaceJournal {
|
||||
constructor(storage = new MemoryStorage(), { projectId = "default", schemaVersion = 1 } = {}) {
|
||||
this.storage = storage;
|
||||
this.projectId = projectId;
|
||||
this.schemaVersion = schemaVersion;
|
||||
}
|
||||
|
||||
records() {
|
||||
return this.storage.entries().map(([, value]) => typeof value === "string" ? JSON.parse(value) : structuredClone(value));
|
||||
}
|
||||
|
||||
nextSequence() {
|
||||
return this.records().reduce((max, record) => Math.max(max, record.commit_seq ?? 0), 0) + 1;
|
||||
}
|
||||
|
||||
prepare(operationId, changes) {
|
||||
if (typeof operationId !== "string" || operationId.length === 0) throw new Error("operation_id_invalid");
|
||||
if (!Array.isArray(changes) || changes.length === 0) throw new Error("workspace_changes_empty");
|
||||
const existing = this.records().find((record) => record.operation_id === operationId && record.kind === "commit");
|
||||
if (existing) throw new Error("operation_already_committed");
|
||||
const normalized = changes.map((change) => {
|
||||
const path = pathOf(change.path);
|
||||
const data = change.data;
|
||||
return { path, data, data_hash: dataHash(data) };
|
||||
}).sort((left, right) => left.path.localeCompare(right.path));
|
||||
const commitSeq = this.nextSequence();
|
||||
const record = {
|
||||
schema: JOURNAL_SCHEMA,
|
||||
schema_version: this.schemaVersion,
|
||||
project_id: this.projectId,
|
||||
kind: "prepare",
|
||||
commit_seq: commitSeq,
|
||||
operation_id: operationId,
|
||||
changes: normalized,
|
||||
content_hash: fnv1a64({ commit_seq: commitSeq, operation_id: operationId, changes: normalized }),
|
||||
};
|
||||
this.storage.set(`journal/${String(commitSeq).padStart(12, "0")}.prepare`, canonicalJson(record));
|
||||
return record;
|
||||
}
|
||||
|
||||
commit(operationId) {
|
||||
const records = this.records();
|
||||
const prepare = records.find((record) => record.kind === "prepare" && record.operation_id === operationId);
|
||||
if (!prepare) throw new Error("prepare_missing");
|
||||
if (records.some((record) => record.kind === "commit" && record.operation_id === operationId)) throw new Error("operation_already_committed");
|
||||
const expectedHash = fnv1a64({ commit_seq: prepare.commit_seq, operation_id: prepare.operation_id, changes: prepare.changes });
|
||||
if (prepare.content_hash !== expectedHash) throw new Error("prepare_hash_mismatch");
|
||||
const record = {
|
||||
schema: JOURNAL_SCHEMA,
|
||||
schema_version: this.schemaVersion,
|
||||
project_id: this.projectId,
|
||||
kind: "commit",
|
||||
commit_seq: prepare.commit_seq,
|
||||
operation_id: operationId,
|
||||
prepare_hash: prepare.content_hash,
|
||||
};
|
||||
this.storage.set(`journal/${String(prepare.commit_seq).padStart(12, "0")}.commit`, canonicalJson(record));
|
||||
return record;
|
||||
}
|
||||
|
||||
abort(operationId) {
|
||||
const prepare = this.records().find((record) => record.kind === "prepare" && record.operation_id === operationId);
|
||||
if (!prepare) throw new Error("prepare_missing");
|
||||
const record = { schema: JOURNAL_SCHEMA, schema_version: this.schemaVersion, project_id: this.projectId, kind: "abort", commit_seq: prepare.commit_seq, operation_id: operationId };
|
||||
this.storage.set(`journal/${String(prepare.commit_seq).padStart(12, "0")}.abort`, canonicalJson(record));
|
||||
return record;
|
||||
}
|
||||
|
||||
recover() {
|
||||
const records = this.records();
|
||||
const incompatible = records.filter((record) => record.schema !== JOURNAL_SCHEMA || record.schema_version !== this.schemaVersion || record.project_id !== this.projectId);
|
||||
if (incompatible.length > 0) {
|
||||
return {
|
||||
schema: "workspace-recovery.v1",
|
||||
project_id: this.projectId,
|
||||
status: "blocked",
|
||||
files: {},
|
||||
applied: [],
|
||||
half_commits: [],
|
||||
blocked: incompatible.map((record) => ({ commit_seq: record.commit_seq ?? null, reason: "schema_or_project_mismatch" })),
|
||||
state_hash: fnv1a64({}),
|
||||
};
|
||||
}
|
||||
const prepares = new Map(records.filter((record) => record.kind === "prepare").map((record) => [record.commit_seq, record]));
|
||||
const commits = records.filter((record) => record.kind === "commit").sort((left, right) => left.commit_seq - right.commit_seq);
|
||||
const aborts = new Set(records.filter((record) => record.kind === "abort").map((record) => record.commit_seq));
|
||||
const files = new Map();
|
||||
const applied = [];
|
||||
const blocked = [];
|
||||
for (const commit of commits) {
|
||||
const prepare = prepares.get(commit.commit_seq);
|
||||
if (!prepare || aborts.has(commit.commit_seq)) { blocked.push({ commit_seq: commit.commit_seq, reason: "prepare_missing_or_aborted" }); continue; }
|
||||
const expectedPrepareHash = fnv1a64({ commit_seq: prepare.commit_seq, operation_id: prepare.operation_id, changes: prepare.changes });
|
||||
if (prepare.content_hash !== expectedPrepareHash || commit.prepare_hash !== expectedPrepareHash) { blocked.push({ commit_seq: commit.commit_seq, reason: "hash_mismatch" }); continue; }
|
||||
for (const change of prepare.changes) {
|
||||
if (dataHash(change.data) !== change.data_hash) { blocked.push({ commit_seq: commit.commit_seq, reason: "data_hash_mismatch", path: change.path }); continue; }
|
||||
files.set(change.path, change.data);
|
||||
}
|
||||
applied.push({ commit_seq: commit.commit_seq, operation_id: commit.operation_id, content_hash: expectedPrepareHash });
|
||||
}
|
||||
return {
|
||||
schema: "workspace-recovery.v1",
|
||||
project_id: this.projectId,
|
||||
status: blocked.length === 0 ? "recovered" : "blocked",
|
||||
files: Object.fromEntries([...files.entries()].sort(([a], [b]) => a.localeCompare(b))),
|
||||
applied,
|
||||
half_commits: [...prepares.values()].filter((prepare) => !commits.some((commit) => commit.commit_seq === prepare.commit_seq) && !aborts.has(prepare.commit_seq)).map((prepare) => prepare.commit_seq),
|
||||
blocked,
|
||||
state_hash: fnv1a64(Object.fromEntries([...files.entries()].sort(([a], [b]) => a.localeCompare(b)))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { canonicalJson, dataHash, pathOf };
|
||||
6
web/workspace/recovery.mjs
Normal file
6
web/workspace/recovery.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
import { WorkspaceJournal } from "./journal.mjs";
|
||||
|
||||
export function recoverWorkspace(storage, options = {}) {
|
||||
const journal = new WorkspaceJournal(storage, options);
|
||||
return journal.recover();
|
||||
}
|
||||
46
web/workspace/test_recovery.mjs
Normal file
46
web/workspace/test_recovery.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { MemoryStorage, WorkspaceJournal, canonicalJson } from "./journal.mjs";
|
||||
import { recoverWorkspace } from "./recovery.mjs";
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
const journal = new WorkspaceJournal(storage, { projectId: "demo" });
|
||||
const prepared = journal.prepare("op-1", [{ path: "config/main.ini", data: "[EMC]\n" }, { path: "programs/main.ngc", data: "G0 X1\n" }]);
|
||||
journal.commit("op-1");
|
||||
let recovered = recoverWorkspace(storage, { projectId: "demo" });
|
||||
assert.equal(recovered.status, "recovered");
|
||||
assert.deepEqual(recovered.files, { "config/main.ini": "[EMC]\n", "programs/main.ngc": "G0 X1\n" });
|
||||
assert.equal(recovered.applied[0].commit_seq, prepared.commit_seq);
|
||||
|
||||
const halfStorage = new MemoryStorage();
|
||||
const halfJournal = new WorkspaceJournal(halfStorage, { projectId: "half" });
|
||||
halfJournal.prepare("op-half", [{ path: "config/half.ini", data: "half" }]);
|
||||
recovered = recoverWorkspace(halfStorage, { projectId: "half" });
|
||||
assert.equal(recovered.status, "recovered");
|
||||
assert.deepEqual(recovered.files, {});
|
||||
assert.deepEqual(recovered.half_commits, [1]);
|
||||
|
||||
assert.throws(() => journal.prepare("op-path", [{ path: "../escape", data: "x" }]), /workspace_path_invalid/);
|
||||
assert.throws(() => journal.prepare("op-data", [{ path: "config/bad", data: 1 }]), /workspace_data_invalid/);
|
||||
assert.throws(() => journal.commit("missing"), /prepare_missing/);
|
||||
|
||||
const corrupted = new MemoryStorage(Object.fromEntries(storage.entries()));
|
||||
const commitKey = [...corrupted.records.keys()].find((key) => key.endsWith(".commit"));
|
||||
const commit = JSON.parse(corrupted.get(commitKey));
|
||||
commit.prepare_hash = "fnv1a64:0000000000000000";
|
||||
corrupted.set(commitKey, canonicalJson(commit));
|
||||
recovered = recoverWorkspace(corrupted, { projectId: "demo" });
|
||||
assert.equal(recovered.status, "blocked");
|
||||
assert.equal(recovered.blocked[0].reason, "hash_mismatch");
|
||||
|
||||
const firstBytes = canonicalJson([...storage.entries()]);
|
||||
const secondBytes = canonicalJson([...storage.entries()]);
|
||||
assert.equal(firstBytes, secondBytes);
|
||||
await writeFile(new URL("../../coverage/workspace-recovery.report.json", import.meta.url), `${JSON.stringify({ schema: "workspace-recovery-report.v1", status: "passed", scenarios: ["atomic_commit", "half_commit_ignored", "path_validation", "hash_corruption_blocked"], journal_bytes_sha256: fnvHash(firstBytes), recovery_state_hash: recovered.state_hash }, null, 2)}\n`);
|
||||
console.log("workspace_recovery=passed commit=" + prepared.commit_seq);
|
||||
|
||||
function fnvHash(value) {
|
||||
let hash = 1469598103934665603n;
|
||||
for (const byte of new TextEncoder().encode(value)) hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 1099511628211n);
|
||||
return `fnv1a64:${hash.toString(16).padStart(16, "0")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user