Move panel temporary state to worker OPFS
This commit is contained in:
@@ -1,32 +1,11 @@
|
||||
export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
||||
export const PROTOCOL_SCHEMA = 1;
|
||||
const POWER_OFF_CHECKPOINT_KEY = "linuxcnc-web-wasm:xyzac-power-off-checkpoint";
|
||||
let CLIENT_INSTANCE = 0;
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function persistPowerOffCheckpoint(message) {
|
||||
const payload = isObject(message?.payload) ? message.payload : {};
|
||||
if (message.kind !== "COMMAND_RESULT" || payload.status !== "verified" || payload.command !== "POWER" || payload.active !== false || !isObject(payload.recovery_checkpoint)) return;
|
||||
try { globalThis.localStorage?.setItem(POWER_OFF_CHECKPOINT_KEY, JSON.stringify(payload.recovery_checkpoint)); } catch { /* checkpoint remains visible in the response */ }
|
||||
}
|
||||
|
||||
function attachPowerOffCheckpoint(payload) {
|
||||
if (!isObject(payload)) return payload;
|
||||
try {
|
||||
const raw = globalThis.localStorage?.getItem(POWER_OFF_CHECKPOINT_KEY);
|
||||
return raw ? { ...payload, recovery_checkpoint: JSON.parse(raw) } : payload;
|
||||
} catch { return payload; }
|
||||
}
|
||||
|
||||
function clearPowerOffCheckpoint(message) {
|
||||
const payload = isObject(message?.payload) ? message.payload : {};
|
||||
if (message.kind !== "COMMAND_RESULT" || payload.status !== "verified" || payload.command !== "POWER" || payload.active !== true) return;
|
||||
try { globalThis.localStorage?.removeItem(POWER_OFF_CHECKPOINT_KEY); } catch { /* cleanup is best effort */ }
|
||||
}
|
||||
|
||||
function commandError(message) {
|
||||
const payload = isObject(message?.payload) ? message.payload : {};
|
||||
const reason = payload.reason ?? payload.gate_reason ?? payload.message_key ?? payload.code ?? "worker_error";
|
||||
@@ -64,9 +43,6 @@ export class WorkerClient {
|
||||
}
|
||||
|
||||
#receive(message) {
|
||||
const persistence = persistPowerOffCheckpoint(message);
|
||||
if (persistence && isObject(message?.payload)) message.payload.recovery_persistence = persistence;
|
||||
clearPowerOffCheckpoint(message);
|
||||
this.#emit(message);
|
||||
if (!isObject(message)) return;
|
||||
const operationId = message.operation_id ?? message.payload?.operation_id;
|
||||
@@ -101,7 +77,7 @@ export class WorkerClient {
|
||||
seq,
|
||||
kind,
|
||||
...(operationId ? { operation_id: operationId } : {}),
|
||||
payload: kind === "LOAD_WORKSPACE" ? attachPowerOffCheckpoint(payload) : payload,
|
||||
payload,
|
||||
};
|
||||
let resolvePromise;
|
||||
let rejectPromise;
|
||||
|
||||
@@ -186,9 +186,58 @@ export function App() {
|
||||
const [operationTrace, setOperationTrace] = useState<XyzacOperationTrace[]>([]);
|
||||
const feedOverridePending = useRef<number | null>(null);
|
||||
const feedOverrideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const uiCacheHydrated = useRef(false);
|
||||
const uiCacheSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const UI_CACHE_KEY = "xyzac-trt";
|
||||
const [previewProgress, setPreviewProgress] = useState<{ percent: number; phase: "reading" | "wasm" | "ready" | "error"; detail: string } | null>(null);
|
||||
const previewProgressTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().then(async () => { const bundle = await loadSimulationCase({ path: DEFAULT_CASE, baseUrl: "." }); if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`); const source = bundle.files?.[PROGRAM_PATH]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); const normalizedSource = normalizeProgramSource(source); setProgramText(normalizedSource); setProgramPath(PROGRAM_PATH); const configuration = toWorkerConfiguration(bundle); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }); return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); client.close(); }; }, [client, operationController]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message)));
|
||||
client.ensureSession().then(async () => {
|
||||
const cachedResponse = await client.loadUiState(UI_CACHE_KEY).catch(() => null);
|
||||
const cachedPayload = cachedResponse?.payload && typeof cachedResponse.payload === "object" ? cachedResponse.payload as Record<string, unknown> : null;
|
||||
const cached = cachedPayload?.status === "verified" && cachedPayload.state && typeof cachedPayload.state === "object" ? cachedPayload.state as Record<string, unknown> : null;
|
||||
const cachedProgram = cached?.program && typeof cached.program === "object" ? cached.program as Record<string, unknown> : null;
|
||||
if (cached) {
|
||||
if (cached.casePath === DEFAULT_CASE) setCasePath(DEFAULT_CASE);
|
||||
if (["x", "y", "z", "a", "c"].includes(String(cached.selectedAxis))) setSelectedAxis(String(cached.selectedAxis));
|
||||
if (["manual", "mdi", "diagnostics"].includes(String(cached.leftTab))) setLeftTab(cached.leftTab as "manual" | "mdi" | "diagnostics");
|
||||
if (["preview", "dro"].includes(String(cached.rightTab))) setRightTab(cached.rightTab as "preview" | "dro");
|
||||
if (["z", "z2", "x", "y", "y2", "p"].includes(String(cached.view))) setView(cached.view as "z" | "z2" | "x" | "y" | "y2" | "p");
|
||||
if (typeof cached.zoom === "number" && Number.isFinite(cached.zoom)) setZoom(Math.max(.5, Math.min(2, cached.zoom)));
|
||||
if (typeof cached.rotateMode === "boolean") setRotateMode(cached.rotateMode);
|
||||
if (["machine", "relative"].includes(String(cached.coordType))) setCoordType(cached.coordType as "machine" | "relative");
|
||||
if (["actual", "commanded"].includes(String(cached.displayType))) setDisplayType(cached.displayType as "actual" | "commanded");
|
||||
if (typeof cached.metric === "boolean") setMetric(cached.metric);
|
||||
if (cached.layers && typeof cached.layers === "object") {
|
||||
const savedLayers = cached.layers as Record<string, unknown>;
|
||||
setLayers((current) => {
|
||||
const next = { ...current };
|
||||
for (const key of Object.keys(current) as Array<keyof typeof current>) if (typeof savedLayers[key] === "boolean") next[key] = savedLayers[key] as boolean;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
const bundle = await loadSimulationCase({ path: DEFAULT_CASE, baseUrl: "." });
|
||||
if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`);
|
||||
const source = bundle.files?.[PROGRAM_PATH];
|
||||
if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing");
|
||||
const normalizedSource = normalizeProgramSource(source);
|
||||
setProgramTextState(normalizedSource); setProgramPath(PROGRAM_PATH);
|
||||
const configuration = toWorkerConfiguration(bundle);
|
||||
await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false });
|
||||
await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false });
|
||||
if (typeof cachedProgram?.source === "string" && cachedProgram.source.trim() && cachedProgram.source.length <= 900000 && typeof cachedProgram.path === "string" && cachedProgram.path.length <= 512 && (cachedProgram.source !== normalizedSource || cachedProgram.path !== PROGRAM_PATH)) {
|
||||
const cachedSource = normalizeProgramSource(cachedProgram.source);
|
||||
setProgramTextState(cachedSource); setProgramPath(cachedProgram.path);
|
||||
await operationController.execute("PROGRAM_LOAD", { source: cachedSource, path: cachedProgram.path }, { state: {}, gate: false });
|
||||
}
|
||||
uiCacheHydrated.current = true;
|
||||
setOperationTrace(operationController.exportTrace());
|
||||
setCaseStatus(`${DEFAULT_CASE} + ${cachedProgram?.path ?? PROGRAM_PATH} loaded`);
|
||||
}).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); });
|
||||
return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); };
|
||||
}, [client, operationController]);
|
||||
const axes = state.snapshot?.motion?.axes ?? [];
|
||||
const taskRecord = state.task ?? {};
|
||||
const axisState = { task: taskRecord, program: state.program ?? {}, motion: state.snapshot?.motion ?? {}, devices: state.devices ?? {}, xyzac: state.snapshot?.xyzac ?? {}, gates: state.gates };
|
||||
@@ -493,7 +542,31 @@ export function App() {
|
||||
const allHomed = homedAxes.length >= 5 && homedAxes.slice(0, 5).every(Boolean);
|
||||
const xyzacCommand = (kind: string, payload: Record<string, unknown> = {}, action?: string) => { void dispatch(kind, payload, true, action ?? null).then(() => { if (kind === "VISUAL_CLEAR") setPlotCleared(true); }).catch(() => undefined); };
|
||||
const position = xyzacView.pose ? `X${Number(xyzacView.pose.x).toFixed(3)} Y${Number(xyzacView.pose.y).toFixed(3)} Z${Number(xyzacView.pose.z).toFixed(3)} A${Number(xyzacView.pose.a).toFixed(3)} C${Number(xyzacView.pose.c).toFixed(3)}` : "WASM state pending";
|
||||
return <main className="axis-app" data-axis-feature="AXIS-UI-01" data-axis-authority="linuxcnc-wasm-worker" data-axis-state-source={wasmSnapshotReady ? "linuxcnc-wasm" : "pending"} data-axis-motion-authority={String(state.snapshot?.motion_authority ?? "pending")} data-axis-task-authority={String(state.snapshot?.task_authority ?? "pending")} data-axis-task-mode={String(taskRecord.mode ?? "pending")} data-axis-tp-queue-depth={String((wasmMotion as { queue_depth?: number }).queue_depth ?? "")} data-axis-tp-velocity={String((wasmMotion as { velocity?: number }).velocity ?? "")} data-axis-tp-active-id={String((wasmMotion as { active_id?: number }).active_id ?? "")} data-axis-current-line={String(visibleCurrentLine ?? "")} data-axis-run-enabled={String(gates.run.enabled)} data-axis-homed={JSON.stringify(state.snapshot?.motion?.homed_axes ?? state.snapshot?.motion?.homed ?? null)} data-axis-program-status={String(state.program?.program_status ?? "idle")}>
|
||||
useEffect(() => {
|
||||
if (!uiCacheHydrated.current || !wasmSnapshotReady) return;
|
||||
if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current);
|
||||
const cacheState = {
|
||||
version: 1,
|
||||
selectedAxis,
|
||||
leftTab,
|
||||
rightTab,
|
||||
view,
|
||||
zoom,
|
||||
rotateMode,
|
||||
coordType,
|
||||
displayType,
|
||||
metric,
|
||||
layers,
|
||||
casePath,
|
||||
program: { path: programPath, source: programText },
|
||||
};
|
||||
uiCacheSaveTimer.current = setTimeout(() => {
|
||||
uiCacheSaveTimer.current = null;
|
||||
void client.saveUiState(cacheState, UI_CACHE_KEY).catch(() => undefined);
|
||||
}, 500);
|
||||
return () => { if (uiCacheSaveTimer.current) { clearTimeout(uiCacheSaveTimer.current); uiCacheSaveTimer.current = null; } };
|
||||
}, [client, wasmSnapshotReady, selectedAxis, leftTab, rightTab, view, zoom, rotateMode, coordType, displayType, metric, layers, casePath, programPath, programText]);
|
||||
return <main className="axis-app" data-axis-feature="AXIS-UI-01" data-axis-authority="linuxcnc-wasm-worker" data-axis-state-source={wasmSnapshotReady ? "linuxcnc-wasm" : "pending"} data-axis-motion-authority={String(state.snapshot?.motion_authority ?? "pending")} data-axis-task-authority={String(state.snapshot?.task_authority ?? "pending")} data-axis-task-mode={String(taskRecord.mode ?? "pending")} data-axis-ui-cache={uiCacheHydrated.current ? "opfs" : "pending"} data-axis-view={view} data-axis-zoom={String(zoom)} data-axis-tp-queue-depth={String((wasmMotion as { queue_depth?: number }).queue_depth ?? "")} data-axis-tp-velocity={String((wasmMotion as { velocity?: number }).velocity ?? "")} data-axis-tp-active-id={String((wasmMotion as { active_id?: number }).active_id ?? "")} data-axis-current-line={String(visibleCurrentLine ?? "")} data-axis-run-enabled={String(gates.run.enabled)} data-axis-homed={JSON.stringify(state.snapshot?.motion?.homed_axes ?? state.snapshot?.motion?.homed ?? null)} data-axis-program-status={String(state.program?.program_status ?? "idle")}>
|
||||
<header className="axis-window-header" data-axis-feature="AXIS-SHELL-01"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="AXIS" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`} data-axis-feature="AXIS-SHELL-01" role="status">{state.status.toUpperCase()}</span><span className="axis-state-badge" data-axis-state={taskState} aria-label={`Machine ${machineState}, operation ${operationState}`}>{taskState}</span></header>
|
||||
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={() => { setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); }} disabled={programRunning || programLoading}>Reload editor</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={toggleEstop}>{isEstop ? "Cancel Emergency Stop" : "Emergency Stop"}</MenuAction><MenuAction shortcut="F2" onClick={powerOn} disabled={!gates.power.enabled}>{machineOn ? "Power off" : "Power on"}</MenuAction><MenuAction onClick={resetMachine} disabled={!gates.reset.enabled}>Reset machine</MenuAction><MenuAction shortcut="R" onClick={runProgram} disabled={!gates.run.enabled || programLoading}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")} disabled={!gates.step.enabled || programLoading}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")} disabled={!gates.pause.enabled || programLoading}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")} disabled={!gates.resume.enabled || programLoading}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram} disabled={!gates.abort.enabled}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={enterManualMode} disabled={programRunning || programLoading}>Manual Control</MenuAction><MenuAction onClick={enterMdiMode} disabled={programRunning || programLoading}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
|
||||
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={isEstop} /><IconButton feature="AXIS-MACHINE-02" label={machineOn ? "Power off [F2]" : "Power on [F2]"} asset="tool_power.gif" disabled={!gates.power.enabled} onClick={powerOn} /><IconButton feature="AXIS-MACHINE-03" label="Reset machine" asset="tool_reload.gif" disabled={!gates.reset.enabled} onClick={resetMachine} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" disabled={programRunning || programLoading} onClick={openProgramFile} /><IconButton feature="AXIS-FILE-02" label="Load exact XYZAC TRT case" asset="tool_verify.gif" disabled={!gates.load.enabled} onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen editor defaults" asset="tool_reload.gif" onClick={() => { setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); setPlotCleared(false); }} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!gates.run.enabled} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!gates.step.enabled} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label={programPaused ? "Resume execution [S]" : "Pause execution [P]"} asset={programPaused ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={programPaused ? !gates.resume.enabled : !gates.pause.enabled} onClick={() => execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!gates.abort.enabled} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" disabled /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" disabled /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section>
|
||||
|
||||
1
web/react/src/legacy.d.ts
vendored
1
web/react/src/legacy.d.ts
vendored
@@ -4,6 +4,7 @@ declare module "*.mjs" {
|
||||
export function toWorkerConfiguration(bundle: unknown): Record<string, unknown>;
|
||||
export class SimSession { contextId: string; sessionId: string; constructor(core: unknown); accept(message: unknown): unknown; }
|
||||
export class XyzacSession extends SimSession { authority(): Record<string, unknown>; }
|
||||
export function handleWorkerMessage(session: unknown, input: unknown): Promise<unknown>;
|
||||
export class AxisOperationController {
|
||||
constructor(client: { send(kind: string, payload?: unknown, operationId?: string): Promise<unknown> });
|
||||
execute(kind: string, payload?: Record<string, unknown>, options?: { state?: unknown; action?: string | null; gate?: boolean }): Promise<{ operationId: string; response: unknown; gate: { enabled: boolean; reason: string } }>;
|
||||
|
||||
@@ -4,7 +4,7 @@ export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1" as const;
|
||||
export type MessageKind =
|
||||
| "HELLO" | "CAPABILITIES" | "SESSION_ATTACH" | "SESSION_READY"
|
||||
| "LOAD_WORKSPACE" | "COMMAND_RESULT" | "STATE_SNAPSHOT" | "EVENT"
|
||||
| "ALARM" | "RECOVERY_STATUS" | "ERROR";
|
||||
| "ALARM" | "RECOVERY_STATUS" | "ERROR" | "UI_STATE_RESULT";
|
||||
|
||||
export type MessageEnvelope<TPayload = unknown> = {
|
||||
schema_version: typeof PROTOCOL_SCHEMA;
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import createModule from "../../../build-wasm/linuxcnc-sim-core.js";
|
||||
import { XyzacSession as SimSession } from "../../worker/xyzac-session.mjs";
|
||||
import { handleWorkerMessage } from "../../worker/opfs-worker-bridge.mjs";
|
||||
|
||||
// The boot event is a transport diagnostic; it carries no simulation state.
|
||||
self.postMessage({ schema_version: 1, kind: "WORKER_BOOT", context_id: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: "boot", seq: 0, payload: { status: "booting", runtime: "react-vite-worker" } });
|
||||
let session: SimSession | null = null;
|
||||
const queue: MessageEvent[] = [];
|
||||
const handle = (event: MessageEvent) => {
|
||||
let serial = Promise.resolve();
|
||||
const process = async (event: MessageEvent) => {
|
||||
try {
|
||||
if (!session) { queue.push(event); return; }
|
||||
self.postMessage(session.accept(event.data));
|
||||
self.postMessage(await handleWorkerMessage(session, event.data));
|
||||
} catch (error) {
|
||||
self.postMessage({ schema_version: 1, kind: "ERROR", context_id: session?.contextId ?? "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", session_id: session?.sessionId ?? "boot", seq: event.data?.seq ?? 0, payload: { status: "error", code: "worker_exception", message_key: error instanceof Error ? error.message : String(error), retryable: false } });
|
||||
}
|
||||
};
|
||||
const handle = (event: MessageEvent) => { serial = serial.then(() => process(event)); };
|
||||
self.onmessage = handle;
|
||||
createModule().then((core) => {
|
||||
session = new (SimSession as any)(core, { planner: "linuxcnc-tp" });
|
||||
|
||||
@@ -202,10 +202,11 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
|
||||
...(planner?.active ? [planner.active] : []),
|
||||
].filter((segment): segment is NonNullable<typeof segment> & { id: number; start: Record<string, number>; end: Record<string, number> } => Number.isSafeInteger(segment.id) && Boolean(segment.start) && Boolean(segment.end));
|
||||
const command = String(result.command ?? "");
|
||||
const resetPath = command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART";
|
||||
const visualClear = command === "VISUAL_CLEAR";
|
||||
const resetPath = visualClear || command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART";
|
||||
const known = resetPath ? new Set<number>() : new Set(previous.toolpath.map((segment) => segment.id));
|
||||
const nextToolpath = resetPath
|
||||
? previewSegments.map((segment, index) => ({ ...segment, id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record<string, number>, end: segment.end as Record<string, number>, active: false }))
|
||||
? visualClear ? [] : previewSegments.map((segment, index) => ({ ...segment, id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record<string, number>, end: segment.end as Record<string, number>, active: false }))
|
||||
: previous.toolpath.map((segment) => ({ ...segment, start: { ...segment.start }, end: { ...segment.end } }));
|
||||
for (const segment of plannerSegments) {
|
||||
if (!known.has(segment.id)) nextToolpath.push({ id: segment.id, source_line: segment.source_line, start: segment.start, end: segment.end, active: segment.id === planner?.active?.id });
|
||||
|
||||
22
web/react/src/test_store_visual_clear.mjs
Normal file
22
web/react/src/test_store_visual_clear.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { reduceFrontendState, INITIAL_STATE } from "./store.ts";
|
||||
|
||||
const base = {
|
||||
...INITIAL_STATE,
|
||||
snapshot: {
|
||||
schema: "axis-panel-snapshot.v1",
|
||||
simulation_tick: 1,
|
||||
state_hash: "fnv1a64:base",
|
||||
task: {}, motion: {}, wasm_motion: {}, gates: {},
|
||||
visual: { schema: "axis-visual.v1", pose: { x: 0 }, joints: [0, 0, 0, 0, 0], toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }], source: "wasm_canonical_motion" },
|
||||
authoritative: {}, motion_authority: "wasm", task_authority: "wasm_task_canonical",
|
||||
},
|
||||
toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }],
|
||||
};
|
||||
const result = reduceFrontendState(base, {
|
||||
schema_version: 1, kind: "COMMAND_RESULT", context_id: base.contextId, session_id: base.sessionId, seq: 1,
|
||||
payload: { status: "verified", command: "VISUAL_CLEAR", visual_clear: true, panel_snapshot: undefined },
|
||||
});
|
||||
assert.deepEqual(result.toolpath, []);
|
||||
assert.deepEqual(result.snapshot?.visual?.toolpath, []);
|
||||
console.log(JSON.stringify({ schema: "store-visual-clear.v1", status: "verified", stale_toolpath_removed: true }));
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CONTEXT_ID, PROTOCOL_SCHEMA, isEnvelope, type MessageEnvelope } from "./protocol";
|
||||
import { attachPersistedPowerOffCheckpoint, clearPersistedPowerOffCheckpoint, persistPowerOffCheckpoint } from "./power-off-checkpoint-storage";
|
||||
|
||||
type Listener = (message: MessageEnvelope) => void;
|
||||
|
||||
@@ -20,6 +19,7 @@ export class ReactWorkerClient {
|
||||
private readonly contextId: string;
|
||||
private readonly sessionId: string;
|
||||
private sequence = 0;
|
||||
private uiSequence = 2000000000;
|
||||
private listeners = new Set<Listener>();
|
||||
private pending = new Map<number, { resolve: (message: MessageEnvelope) => void; reject: (error: Error) => void }>();
|
||||
private readyPromise: Promise<void> | null = null;
|
||||
@@ -32,10 +32,8 @@ export class ReactWorkerClient {
|
||||
this.sessionId = sessionId;
|
||||
this.worker.onmessage = (event: MessageEvent<unknown>) => {
|
||||
if (!isEnvelope(event.data)) return;
|
||||
const persistence = persistPowerOffCheckpoint(event.data);
|
||||
if (persistence && event.data.payload && typeof event.data.payload === "object") Object.assign(event.data.payload, { recovery_persistence: persistence });
|
||||
clearPersistedPowerOffCheckpoint(event.data);
|
||||
for (const listener of this.listeners) listener(event.data);
|
||||
const uiStateResponse = event.data.kind === "UI_STATE_RESULT";
|
||||
if (!uiStateResponse) for (const listener of this.listeners) listener(event.data);
|
||||
const pending = this.pending.get(event.data.seq);
|
||||
if (pending) {
|
||||
this.pending.delete(event.data.seq);
|
||||
@@ -61,11 +59,20 @@ export class ReactWorkerClient {
|
||||
subscribe(listener: Listener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
||||
|
||||
send(kind: string, payload: unknown = {}, operationId?: string): Promise<MessageEnvelope> {
|
||||
const nextPayload = kind === "LOAD_WORKSPACE" ? attachPersistedPowerOffCheckpoint(payload) : payload;
|
||||
const message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq: ++this.sequence, ...(operationId ? { operation_id: operationId } : {}), payload: nextPayload };
|
||||
const message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq: ++this.sequence, ...(operationId ? { operation_id: operationId } : {}), payload };
|
||||
return new Promise((resolve, reject) => { this.pending.set(message.seq, { resolve, reject }); this.worker.postMessage(message); });
|
||||
}
|
||||
|
||||
private sendUiState(kind: "UI_STATE_SAVE" | "UI_STATE_LOAD" | "UI_STATE_CLEAR", payload: Record<string, unknown>): Promise<MessageEnvelope> {
|
||||
const seq = ++this.uiSequence;
|
||||
const message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq, operation_id: `ui-cache-${seq}`, payload };
|
||||
return new Promise((resolve, reject) => { this.pending.set(seq, { resolve, reject }); this.worker.postMessage(message); });
|
||||
}
|
||||
|
||||
saveUiState(state: Record<string, unknown>, key = "xyzac-trt"): Promise<MessageEnvelope> { return this.sendUiState("UI_STATE_SAVE", { key, state }); }
|
||||
loadUiState(key = "xyzac-trt"): Promise<MessageEnvelope> { return this.sendUiState("UI_STATE_LOAD", { key }); }
|
||||
clearUiState(key = "xyzac-trt"): Promise<MessageEnvelope> { return this.sendUiState("UI_STATE_CLEAR", { key }); }
|
||||
|
||||
hello(): Promise<MessageEnvelope> { return this.send("HELLO", { supported_schema_versions: [PROTOCOL_SCHEMA], requested_features: ["snapshot", "trajectory"] }); }
|
||||
attach(): Promise<MessageEnvelope> { return this.send("SESSION_ATTACH", {}, "react-session-attach"); }
|
||||
ensureSession(): Promise<void> {
|
||||
|
||||
@@ -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 })),
|
||||
};
|
||||
|
||||
130
web/worker/opfs-ui-state.mjs
Normal file
130
web/worker/opfs-ui-state.mjs
Normal 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 });
|
||||
}
|
||||
64
web/worker/opfs-worker-bridge.mjs
Normal file
64
web/worker/opfs-worker-bridge.mjs
Normal 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 };
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
65
web/worker/test_opfs_ui_state.mjs
Normal file
65
web/worker/test_opfs_ui_state.mjs
Normal 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 }));
|
||||
52
web/worker/test_opfs_worker_bridge.mjs
Normal file
52
web/worker/test_opfs_worker_bridge.mjs
Normal 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 }));
|
||||
@@ -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("));
|
||||
|
||||
Reference in New Issue
Block a user