Move panel temporary state to worker OPFS

This commit is contained in:
2026-09-03 14:03:32 +08:00
parent 0f6691a9fc
commit 2b1e249baf
22 changed files with 688 additions and 42 deletions

View File

@@ -34,7 +34,14 @@ export function capabilities(contextId) {
event: 1,
workspace: 1,
commands: [...COMMANDS],
worker_storage_commands: ["UI_STATE_SAVE", "UI_STATE_LOAD", "UI_STATE_CLEAR"],
streams: ["STATE_SNAPSHOT", "EVENT", "ALARM"],
storage: {
owner: "dedicated-worker",
ui_state: "opfs_temp_json_v1",
power_off_checkpoint: "opfs_temp_json_v1",
authority: "linuxcnc-wasm-remains-authoritative",
},
limits: { ...LIMITS },
exclusions: EXCLUSIONS.map((entry) => ({ ...entry })),
};

View File

@@ -0,0 +1,130 @@
import { fnv1a64 } from "./replay.mjs";
export const OPFS_UI_STATE_SCHEMA = "linuxcnc-ui-temp-json.v1";
export const OPFS_UI_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const ROOT_DIRECTORY = "linuxcnc-web-wasm";
const STATE_DIRECTORY = "ui-state";
const MAX_BYTES = 1024 * 1024;
const SLOT_NAMES = ["state-a.json", "state-b.json"];
const MANIFEST_NAME = "manifest.json";
function validObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function safeName(value) {
return String(value ?? "default").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "default";
}
function finiteTree(value) {
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(finiteTree);
if (validObject(value)) return Object.values(value).every(finiteTree);
return true;
}
function record(contextId, key, generation, state) {
const savedAtMs = Date.now();
const unsigned = { schema: OPFS_UI_STATE_SCHEMA, context_id: contextId, key, generation, saved_at_ms: savedAtMs, state };
return { ...unsigned, state_hash: fnv1a64(state), record_hash: fnv1a64(unsigned) };
}
function validRecord(value, contextId, key) {
if (!validObject(value) || value.schema !== OPFS_UI_STATE_SCHEMA || value.context_id !== contextId || value.key !== key || !Number.isSafeInteger(value.generation) || value.generation < 1 || !Number.isSafeInteger(value.saved_at_ms) || value.saved_at_ms < 0 || Date.now() - value.saved_at_ms > OPFS_UI_STATE_TTL_MS || !validObject(value.state) || !finiteTree(value.state)) return false;
const unsigned = { schema: value.schema, context_id: value.context_id, key: value.key, generation: value.generation, saved_at_ms: value.saved_at_ms, state: value.state };
return value.state_hash === fnv1a64(value.state) && value.record_hash === fnv1a64(unsigned);
}
async function handles(key, create = false, storage = globalThis.navigator?.storage) {
if (!storage || typeof storage.getDirectory !== "function") return { status: "blocked", code: "opfs_unavailable", reason: "storage_get_directory_missing" };
const root = await storage.getDirectory();
const rootDirectory = await root.getDirectoryHandle(ROOT_DIRECTORY, { create });
const directory = await rootDirectory.getDirectoryHandle(STATE_DIRECTORY, { create });
const project = await directory.getDirectoryHandle(safeName(key), { create });
return { status: "verified", rootDirectory, directory, project };
}
function emptyResult(contextId, key) {
return { schema: OPFS_UI_STATE_SCHEMA, status: "empty", storage: "opfs", context_id: contextId, key };
}
async function readFile(project, name) {
try {
const file = await project.getFileHandle(name, { create: false });
const text = await (await file.getFile()).text();
if (new TextEncoder().encode(text).byteLength > MAX_BYTES) return null;
return JSON.parse(text);
} catch {
return null;
}
}
async function writeFile(project, name, value) {
const file = await project.getFileHandle(name, { create: true });
const bytes = new TextEncoder().encode(JSON.stringify(value));
if (bytes.byteLength > MAX_BYTES) return { status: "blocked", code: "opfs_ui_state_quota", bytes: bytes.byteLength };
if (typeof file.createSyncAccessHandle === "function") {
const access = await file.createSyncAccessHandle();
try {
access.truncate(0);
access.write(bytes, { at: 0 });
access.flush();
} finally { access.close(); }
} else if (typeof file.createWritable === "function") {
const writable = await file.createWritable();
try { await writable.write(bytes); await writable.close(); } catch (error) { try { await writable.abort(); } catch {} throw error; }
} else return { status: "blocked", code: "opfs_write_handle_unavailable" };
return { status: "verified", bytes: bytes.byteLength };
}
export async function loadUiState({ contextId, key = "xyzac-trt", storage } = {}) {
const resolved = await handles(key, false, storage).catch((error) => ({ status: "blocked", code: "opfs_open_failed", reason: error?.name ?? "unknown" }));
if (resolved.status === "blocked" && resolved.reason === "NotFoundError") return emptyResult(contextId, key);
if (resolved.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...resolved, context_id: contextId, key };
const candidates = [];
for (const name of SLOT_NAMES) {
const value = await readFile(resolved.project, name);
if (validRecord(value, contextId, key)) candidates.push(value);
}
if (!candidates.length) return { schema: OPFS_UI_STATE_SCHEMA, status: "empty", storage: "opfs", context_id: contextId, key };
candidates.sort((left, right) => right.generation - left.generation);
const selected = candidates[0];
return { schema: OPFS_UI_STATE_SCHEMA, status: "verified", storage: "opfs", context_id: contextId, key, generation: selected.generation, state: selected.state, state_hash: selected.state_hash };
}
export async function saveUiState({ contextId, key = "xyzac-trt", state, storage } = {}) {
if (!validObject(state) || !finiteTree(state)) return { schema: OPFS_UI_STATE_SCHEMA, status: "blocked", code: "opfs_ui_state_invalid", context_id: contextId, key };
const resolved = await handles(key, true, storage).catch((error) => ({ status: "blocked", code: "opfs_open_failed", reason: error?.name ?? "unknown" }));
if (resolved.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...resolved, context_id: contextId, key };
const previous = await loadUiState({ contextId, key, storage });
const generation = Number.isSafeInteger(previous.generation) ? previous.generation + 1 : 1;
const slot = SLOT_NAMES[generation % SLOT_NAMES.length];
const next = record(contextId, key, generation, structuredClone(state));
const written = await writeFile(resolved.project, slot, next);
if (written.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...written, context_id: contextId, key };
const manifest = { schema: OPFS_UI_STATE_SCHEMA, context_id: contextId, key, generation, slot, state_hash: next.state_hash, manifest_hash: fnv1a64({ schema: OPFS_UI_STATE_SCHEMA, context_id: contextId, key, generation, slot, state_hash: next.state_hash }) };
const manifestWrite = await writeFile(resolved.project, MANIFEST_NAME, manifest);
if (manifestWrite.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...manifestWrite, context_id: contextId, key };
return { schema: OPFS_UI_STATE_SCHEMA, status: "verified", storage: "opfs", context_id: contextId, key, generation, slot, state_hash: next.state_hash, bytes: written.bytes };
}
export async function clearUiState({ contextId, key = "xyzac-trt", storage } = {}) {
const resolved = await handles(key, false, storage).catch((error) => ({ status: "blocked", code: "opfs_open_failed", reason: error?.name ?? "unknown" }));
if (resolved.status !== "verified") return { schema: OPFS_UI_STATE_SCHEMA, ...resolved, context_id: contextId, key };
for (const name of [...SLOT_NAMES, MANIFEST_NAME]) { try { await resolved.project.removeEntry(name); } catch {} }
return { schema: OPFS_UI_STATE_SCHEMA, status: "verified", storage: "opfs", context_id: contextId, key, cleared: true };
}
export async function savePowerOffCheckpoint({ contextId, checkpoint, key = "xyzac-trt", storage } = {}) {
return saveUiState({ contextId, key: `${key}-power-off`, state: { kind: "power_off_checkpoint", checkpoint }, storage });
}
export async function loadPowerOffCheckpoint({ contextId, key = "xyzac-trt", storage } = {}) {
const result = await loadUiState({ contextId, key: `${key}-power-off`, storage });
if (result.status !== "verified" || result.state?.kind !== "power_off_checkpoint" || !result.state.checkpoint) return { ...result, checkpoint: null };
return { ...result, checkpoint: result.state.checkpoint };
}
export async function clearPowerOffCheckpoint({ contextId, key = "xyzac-trt", storage } = {}) {
return clearUiState({ contextId, key: `${key}-power-off`, storage });
}

View File

@@ -0,0 +1,64 @@
import {
clearPowerOffCheckpoint,
clearUiState,
loadPowerOffCheckpoint,
loadUiState,
savePowerOffCheckpoint,
saveUiState,
} from "./opfs-ui-state.mjs";
const UI_COMMANDS = new Set(["UI_STATE_SAVE", "UI_STATE_LOAD", "UI_STATE_CLEAR"]);
function object(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function resultEnvelope(input, payload) {
return {
schema_version: 1,
kind: "UI_STATE_RESULT",
context_id: input.context_id,
session_id: input.session_id,
seq: input.seq,
operation_id: input.operation_id ?? null,
payload: { ...payload, command: input.kind, operation_id: input.operation_id ?? null },
};
}
export function isUiStateCommand(input) {
return UI_COMMANDS.has(input?.kind);
}
export async function handleWorkerMessage(session, input) {
if (isUiStateCommand(input)) {
// UI cache is a Worker storage service, not a LinuxCNC command. It never
// consumes the WASM protocol sequence or changes task/motion authority.
const payload = object(input.payload);
const options = { contextId: input.context_id, key: payload.key ?? "xyzac-trt" };
const result = input.kind === "UI_STATE_SAVE"
? await saveUiState({ ...options, state: payload.state })
: input.kind === "UI_STATE_LOAD"
? await loadUiState(options)
: await clearUiState(options);
return resultEnvelope(input, result);
}
let request = input;
if (input?.kind === "LOAD_WORKSPACE" && !object(input.payload).recovery_checkpoint) {
const persisted = await loadPowerOffCheckpoint({ contextId: input.context_id, key: "xyzac-trt" });
if (persisted.status === "verified" && persisted.checkpoint) request = { ...input, payload: { ...object(input.payload), recovery_checkpoint: persisted.checkpoint } };
}
const response = session.accept(request);
const payload = object(response?.payload);
if (input?.kind === "POWER" && response?.kind === "COMMAND_RESULT" && payload.status === "verified") {
if (payload.active === false && payload.recovery_checkpoint) {
const persisted = await savePowerOffCheckpoint({ contextId: input.context_id, checkpoint: payload.recovery_checkpoint, key: "xyzac-trt" });
payload.recovery_persistence = persisted;
} else if (payload.active === true) {
payload.recovery_persistence = await clearPowerOffCheckpoint({ contextId: input.context_id, key: "xyzac-trt" });
}
}
return response;
}
export { UI_COMMANDS };

View File

@@ -1,12 +1,14 @@
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { XyzacSession } from "./xyzac-session.mjs";
import { handleWorkerMessage } from "./opfs-worker-bridge.mjs";
const core = await createModule();
const session = new XyzacSession(core, { planner: "linuxcnc-tp" });
self.onmessage = (event) => {
let serial = Promise.resolve();
self.onmessage = (event) => { serial = serial.then(async () => {
try {
self.postMessage(session.accept(event.data));
self.postMessage(await handleWorkerMessage(session, event.data));
} catch (error) {
self.postMessage({
schema_version: 1,
@@ -17,4 +19,5 @@ self.onmessage = (event) => {
payload: { status: "error", code: "worker_exception", message_key: String(error?.message ?? error), retryable: false },
});
}
});
};

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { clearUiState, loadPowerOffCheckpoint, loadUiState, savePowerOffCheckpoint, saveUiState } from "./opfs-ui-state.mjs";
class FakeAccess {
constructor(file) { this.file = file; }
truncate(size) { this.file.bytes = this.file.bytes.slice(0, size); }
write(bytes, { at = 0 } = {}) { const next = new Uint8Array(Math.max(this.file.bytes.length, at + bytes.byteLength)); next.set(this.file.bytes); next.set(bytes, at); this.file.bytes = next; return bytes.byteLength; }
flush() {}
close() {}
}
class FakeFile {
constructor() { this.bytes = new Uint8Array(); }
async getFile() { const bytes = this.bytes; return { text: async () => new TextDecoder().decode(bytes) }; }
async createSyncAccessHandle() { return new FakeAccess(this); }
}
class FakeDirectory {
constructor() { this.directories = new Map(); this.files = new Map(); }
async getDirectoryHandle(name, { create = false } = {}) {
if (!this.directories.has(name)) {
if (!create) { const error = new Error("not found"); error.name = "NotFoundError"; throw error; }
this.directories.set(name, new FakeDirectory());
}
return this.directories.get(name);
}
async getFileHandle(name, { create = false } = {}) {
if (!this.files.has(name)) {
if (!create) { const error = new Error("not found"); error.name = "NotFoundError"; throw error; }
this.files.set(name, new FakeFile());
}
return this.files.get(name);
}
async removeEntry(name) { if (!this.files.delete(name) && !this.directories.delete(name)) { const error = new Error("not found"); error.name = "NotFoundError"; throw error; } }
}
class FakeStorage {
constructor() { this.root = new FakeDirectory(); }
async getDirectory() { return this.root; }
}
const storage = new FakeStorage();
const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
const first = await saveUiState({ storage, contextId, key: "xyzac-trt", state: { view: "p", zoom: 1, program: { path: "demo.ngc", source: "G21\nM2\n" } } });
assert.equal(first.status, "verified");
assert.equal(first.storage, "opfs");
assert.equal((await loadUiState({ storage, contextId, key: "xyzac-trt" })).state.view, "p");
const second = await saveUiState({ storage, contextId, key: "xyzac-trt", state: { view: "z", zoom: 1.2 } });
assert.equal(second.generation, 2);
assert.equal((await loadUiState({ storage, contextId, key: "xyzac-trt" })).state.view, "z");
const root = storage.root.directories.get("linuxcnc-web-wasm");
const project = root.directories.get("ui-state").directories.get("xyzac-trt");
project.files.get("state-a.json").bytes = new TextEncoder().encode("{corrupt");
const fallback = await loadUiState({ storage, contextId, key: "xyzac-trt" });
assert.equal(fallback.status, "verified");
assert.equal(fallback.state.view, "p");
const checkpoint = { schema: "xyzac-power-off-checkpoint.v1", simulation_tick: 4, state: { program: { source_text: "G21\nM2\n" } }, state_hash: "test" };
assert.equal((await savePowerOffCheckpoint({ storage, contextId, checkpoint })).status, "verified");
assert.deepEqual((await loadPowerOffCheckpoint({ storage, contextId })).checkpoint, checkpoint);
assert.equal((await clearUiState({ storage, contextId, key: "xyzac-trt" })).status, "verified");
assert.equal((await loadUiState({ storage, contextId, key: "xyzac-trt" })).status, "empty");
console.log(JSON.stringify({ schema: "opfs-ui-state.v1", status: "verified", storage: "dedicated_worker_fake_opfs", fallback_after_corruption: true, power_off_checkpoint: true }));

View File

@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { handleWorkerMessage } from "./opfs-worker-bridge.mjs";
import { loadUiState } from "./opfs-ui-state.mjs";
class File {
constructor() { this.bytes = new Uint8Array(); }
async getFile() { return { text: async () => new TextDecoder().decode(this.bytes) }; }
async createSyncAccessHandle() {
const file = this;
return {
truncate(size) { file.bytes = file.bytes.slice(0, size); },
write(bytes, { at = 0 } = {}) { const next = new Uint8Array(Math.max(file.bytes.length, at + bytes.byteLength)); next.set(file.bytes); next.set(bytes, at); file.bytes = next; return bytes.byteLength; },
flush() {}, close() {},
};
}
}
class Directory {
constructor() { this.files = new Map(); this.directories = new Map(); }
async getDirectoryHandle(name, { create = false } = {}) { if (!this.directories.has(name)) { if (!create) throw Object.assign(new Error("missing"), { name: "NotFoundError" }); this.directories.set(name, new Directory()); } return this.directories.get(name); }
async getFileHandle(name, { create = false } = {}) { if (!this.files.has(name)) { if (!create) throw Object.assign(new Error("missing"), { name: "NotFoundError" }); this.files.set(name, new File()); } return this.files.get(name); }
async removeEntry(name) { this.files.delete(name); this.directories.delete(name); }
}
const storage = { root: new Directory(), async getDirectory() { return this.root; } };
const previousNavigator = globalThis.navigator;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { storage } });
const contextId = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
const base = { schema_version: 1, context_id: contextId, session_id: "bridge-test" };
let accepted = 0;
let lastAccepted = null;
const session = { accept(input) { accepted += 1; lastAccepted = input; return { schema_version: 1, kind: "COMMAND_RESULT", context_id: input.context_id, session_id: input.session_id, seq: input.seq, payload: { status: "verified", command: input.kind, active: input.payload?.active, recovery_checkpoint: input.payload?.checkpoint ?? undefined } }; } };
const saved = await handleWorkerMessage(session, { ...base, kind: "UI_STATE_SAVE", seq: 2000000001, operation_id: "ui-save", payload: { key: "xyzac-trt", state: { view: "p", zoom: 1 } } });
assert.equal(saved.kind, "UI_STATE_RESULT");
assert.equal(saved.payload.storage, "opfs");
assert.equal(accepted, 0);
const loaded = await handleWorkerMessage(session, { ...base, kind: "UI_STATE_LOAD", seq: 2000000002, operation_id: "ui-load", payload: { key: "xyzac-trt" } });
assert.equal(loaded.payload.state.view, "p");
assert.equal(accepted, 0);
const checkpoint = { schema: "xyzac-power-off-checkpoint.v1", simulation_tick: 3, state: { program: { source_text: "G21\nM2\n" } }, state_hash: "test" };
const powerOff = await handleWorkerMessage(session, { ...base, kind: "POWER", seq: 1, operation_id: "power-off", payload: { active: false, checkpoint } });
assert.equal(powerOff.payload.recovery_persistence.storage, "opfs");
await handleWorkerMessage(session, { ...base, kind: "LOAD_WORKSPACE", seq: 2, operation_id: "load", payload: { root_path: "/workspace" } });
assert.deepEqual(lastAccepted.payload.recovery_checkpoint, checkpoint);
assert.equal(accepted, 2);
assert.equal((await loadUiState({ contextId, key: "xyzac-trt-power-off" })).status, "verified");
if (previousNavigator === undefined) delete globalThis.navigator;
else Object.defineProperty(globalThis, "navigator", { configurable: true, value: previousNavigator });
console.log(JSON.stringify({ schema: "opfs-worker-bridge.v1", status: "verified", ui_commands_do_not_consume_wasm_sequence: true, poweroff_checkpoint_roundtrip: true }));

View File

@@ -67,6 +67,8 @@ assert.equal(sent.length, 2);
assert.deepEqual(controller.exportTrace().map((item) => item.phase), ["pending", "result", "pending", "result"]);
const appSource = await readFile(new URL("../react/src/App.tsx", import.meta.url), "utf8");
const workerClientSource = await readFile(new URL("../react/src/worker-client.ts", import.meta.url), "utf8");
const opfsBridgeSource = await readFile(new URL("./opfs-worker-bridge.mjs", import.meta.url), "utf8");
const ironLaw = await readFile(new URL("../../docs/linuxcnc-axis-web-iron-law.md", import.meta.url), "utf8");
for (const source of [ironLaw, appSource]) assert.ok(source.includes("linuxcnc"), "axis_source_authority");
for (const symbol of ["manual_ok()", "PROGRAM_RUN/PAUSE/RESUME/ABORT", "JOG_START/JOG_STOP", "MDI_EXECUTE", "COOLANT_COMMAND", "blocked_by_abi"]) assert.ok(ironLaw.includes(symbol), `iron_law_${symbol}`);
@@ -77,8 +79,10 @@ const tpSource = await readFile(new URL("./wasm-linuxcnc-tp.mjs", import.meta.ur
const combined = `${appSource}\n${panelSource}\n${contractSource}`;
for (const symbol of ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacLiveParameters", "XyzacDiagnostics", "Spindle", "Feed actual", "TP velocity", "Live parameters", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "switchkinsXyzac", "Operation trace", "Alarms / recovery"]) assert.ok(combined.includes(symbol), symbol);
for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "reopenDefaults", "gates.reset.enabled", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol);
for (const symbol of ["loadUiState", "saveUiState", "UI_STATE_SAVE", "UI_STATE_LOAD"]) assert.ok(`${appSource}\n${workerClientSource}\n${opfsBridgeSource}`.includes(symbol), symbol);
for (const forbidden of [/defaultValue=\"50\"/, /defaultValue=\"100\"/, /setBlockDelete/, /setOptionalStop/, /state\.status === \"running\" \? \"READING\"/]) assert.equal(forbidden.test(appSource), false, String(forbidden));
for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /\bOPFS\b/i, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden));
for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /localStorage/, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden));
assert.equal(workerClientSource.includes("localStorage"), false);
assert.ok(appSource.includes("SELECTED_XYZAC_CASE"));
assert.equal(appSource.includes("3axis-tutorial"), false);
assert.ok(sessionSource.includes("this.wasmMotion.jog("));