diff --git a/README.md b/README.md index 6414e34..6e9f84c 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ - AXIS 主界面完全对标的源程序功能清单、菜单/工具栏/状态门控、严格差异流程、任务分解和 M0-M7 验收门槛见 [`LinuxCNC AXIS 完全对标规范与执行计划`](docs/linuxcnc-axis-complete-parity-spec.md)。该文档以锁定的 `linuxcnc/share/axis/tcl/axis.tcl`、Gremlin/GLCanon、原始 bitmap 和 `configs/sim/axis` 为唯一 source oracle。 - Web 控制面板操作必须遵守 [`LinuxCNC AXIS Web 铁律`](docs/linuxcnc-axis-web-iron-law.md):AXIS 源码和 LinuxCNC WASM 是唯一操作权威,React/JavaScript/Three.js 不得成为第二套运动或状态实现。 - 每个控制按钮的前置、互斥、后置状态及数据流见 [`LinuxCNC AXIS Web/WASM 详细技术流程图`](docs/linuxcnc-axis-web-wasm-detailed-flowcharts.md)。 +- 页面临时状态、程序草稿和断电 checkpoint 的 Worker-only OPFS 双槽位设计见 [`AXIS Web/WASM OPFS 页面临时缓存设计`](docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md)。 ## 当前发布范围 diff --git a/coverage/xyzac-panel-button-parity.report.json b/coverage/xyzac-panel-button-parity.report.json index 8f4638a..403dc59 100644 --- a/coverage/xyzac-panel-button-parity.report.json +++ b/coverage/xyzac-panel-button-parity.report.json @@ -160,6 +160,35 @@ "screenshot_sha256": "dbf6d196cec3fe765a64d5a1d194b7ebb5681a30555f238e084c9ad6deac6032", "status_detail": "WASM startup, file/package load, POWER, HOME, RESET, switchkins, views, clear plot, MANUAL/MDI, XYZAC JOG, spindle/brake, coolant, tool/probe, overrides, MDI, RUN/PAUSE/RESUME/ABORT/STEP, startup sequence, editor Load/Reopen and ABI-disabled gates all verified" }, + "opfs_ui_cache": { + "schema": "linuxcnc-ui-temp-json.v1", + "storage_owner": "dedicated-worker", + "cnc_authority": "linuxcnc-wasm-only", + "commands": ["UI_STATE_SAVE", "UI_STATE_LOAD", "UI_STATE_CLEAR"], + "features": ["double_slot", "generation", "saved_at_ttl", "state_hash", "record_hash", "corruption_fallback", "size_limit"], + "power_off_checkpoint": "opfs_temp_json_v1", + "tests": { + "state": "node web/worker/test_opfs_ui_state.mjs", + "bridge": "node web/worker/test_opfs_worker_bridge.mjs", + "status": "verified" + }, + "browser_oracle": "pending_real_quota_crash_reload_offline_matrix", + "published_browser_probe": { + "command": "node scripts/test_xyzac_opfs_browser.mjs", + "release": "20260903-linuxcnc-wasm-opfs-r30", + "status": "verified", + "view_before": "z", + "view_after_reload": "z", + "right_tab_before": "DRO", + "right_tab_after_reload": "DRO", + "selected_axis_after_reload": "A", + "opfs_files": ["linuxcnc-web-wasm/ui-state/xyzac-trt/state-b.json", "linuxcnc-web-wasm/ui-state/xyzac-trt/manifest.json"], + "local_storage_keys_before": 0, + "local_storage_keys_after": 0, + "browser_errors": 0, + "screenshot": "/tmp/xyzac-opfs-browser.png" + } + }, "release_status": "STOP_RELEASE", "scope_gaps": [ "override_limits_command", diff --git a/doc-rtcp/xyzac-panel-button-parity-test-report.md b/doc-rtcp/xyzac-panel-button-parity-test-report.md index 6a0d2d3..2ad7b85 100644 --- a/doc-rtcp/xyzac-panel-button-parity-test-report.md +++ b/doc-rtcp/xyzac-panel-button-parity-test-report.md @@ -348,3 +348,20 @@ PLAYWRIGHT_MODULE=... node scripts/test_xyzac_panel_buttons_browser.mjs 审计覆盖:WASM startup、Open G-code file、`3D_Chips.ngc`、Load case、POWER ON、HOME ALL、RESET、IDENTITY/TCP:XYZAC/USERK、视图和 Clear plot、MANUAL/MDI、XYZAC 五轴 JOG、主轴正反转/停止/刹车、Mist/Flood、Tool/Probe、Jog/Feed/Spindle/Max velocity overrides、MDI、Run/Pause/Resume/Abort/Step、Run Startup Sequence、编辑器 Load/Reopen 和 ABI-disabled 控件。发布版本 `20260902-linuxcnc-wasm-panel-audit-r27` 最终结果为 20 组 verified、83 个按钮、37 个按 LinuxCNC gate 禁用、`browser_errors=0`;WASM worker authority 标记为 `linuxcnc-wasm-worker` / `wasm_task_canonical`,截图 `/tmp/xyzac-panel-buttons-browser.png`,SHA-256 `dbf6d196cec3fe765a64d5a1d194b7ebb5681a30555f238e084c9ad6deac6032`。 源代码对标结论:AXIS 的可执行操作均通过对应 WASM command/result 和后续 snapshot;`Override Limits`、Block delete/Optional pause、完整 Tool Touch Off/G10 交互以及桌面 tool editor/Quit 仍明确记录为 `blocked_by_abi` 或浏览器宿主边界,不能用 JavaScript 模拟。整体 `release_status` 继续保持 `STOP_RELEASE`,因为本报告不替代完整 TP 数值 oracle。 + +## OPFS 页面临时缓存 + +页面上下文和断电 checkpoint 由 Dedicated Worker 的 OPFS 临时 JSON 服务保存,React 不直接访问 OPFS 或 `localStorage`。`UI_STATE_SAVE/LOAD/CLEAR` 是 Worker storage service,不进入 LinuxCNC WASM CNC protocol sequence;缓存中的程序文本恢复时必须再次经过 WASM `PROGRAM_LOAD`。 + +```text +node web/worker/test_opfs_ui_state.mjs +node web/worker/test_opfs_worker_bridge.mjs +status: verified +double_slot / generation / TTL / hash / corruption fallback: verified +power_off_checkpoint OPFS round-trip: verified +UI cache does not consume WASM sequence: verified +``` + +详细字段、文件布局、原子写入和恢复边界见 [`AXIS Web/WASM OPFS 页面临时缓存设计`](../docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md)。 + +发布版本 `20260903-linuxcnc-wasm-opfs-r30` 的真实 Chromium OPFS 往返:改变 Preview 视角为 `z`、切换 DRO、选择 A 轴,等待 1.8 秒防抖保存后刷新页面;刷新后 `view=z`、DRO 仍激活、A 轴仍选中,OPFS 中实际存在 `state-b.json` 和 `manifest.json`,两次 `localStorage` key 数均为 `0`,`browser_errors=0`。截图 `/tmp/xyzac-opfs-browser.png`。 diff --git a/docs/linuxcnc-axis-web-iron-law.md b/docs/linuxcnc-axis-web-iron-law.md index 9589107..ce1503a 100644 --- a/docs/linuxcnc-axis-web-iron-law.md +++ b/docs/linuxcnc-axis-web-iron-law.md @@ -27,6 +27,8 @@ - 禁止用本地按钮状态覆盖 WASM 的 task、IO、HAL、tool、probe、home/limit 或 planner 状态。 - 禁止把按钮“可点击”、静态截图或单次页面加载当作实现证据;必须有 worker/WASM command result 和后续 snapshot。 - 不具备对应 LinuxCNC/WASM ABI 的功能必须保持禁用,并明确记录 `blocked_by_abi`,不得用 JS 模拟补齐。 +- 页面临时上下文和断电 checkpoint 可以使用 Worker-only OPFS 临时 JSON 保存,但 OPFS 只负责持久化,不得成为 CNC 业务 authority;恢复时必须重新交给 LinuxCNC WASM 校验和执行。 +- React 不得直接访问 OPFS、`localStorage` 或 WASM 指针;所有页面缓存读写必须通过 Worker 消息完成,并带 schema、context、generation 和 hash。 ## 验收证据 diff --git a/docs/linuxcnc-axis-web-wasm-detailed-flowcharts.md b/docs/linuxcnc-axis-web-wasm-detailed-flowcharts.md index a466f02..7b49b2a 100644 --- a/docs/linuxcnc-axis-web-wasm-detailed-flowcharts.md +++ b/docs/linuxcnc-axis-web-wasm-detailed-flowcharts.md @@ -270,3 +270,5 @@ flowchart LR ~~~ 当前浏览器审计要求:WASM authority 标记正确、browser_errors=0、所有按钮有 command result 或明确 gate/ABI 原因;面板 verified 不等于完整 TP oracle verified。 + +页面临时上下文使用 Worker-only OPFS 双槽位 JSON;它不进入 CNC protocol sequence,也不保存运动 authority。断电 checkpoint 由 Worker 写入 OPFS,下一次 LOAD_WORKSPACE 时再提交给 LinuxCNC WASM 校验和恢复。 diff --git a/docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md b/docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md new file mode 100644 index 0000000..a23de4a --- /dev/null +++ b/docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md @@ -0,0 +1,148 @@ +# AXIS Web/WASM OPFS 页面临时缓存设计 + +## 目标 + +页面刷新或 Worker 重启后,恢复用户需要的页面上下文,同时不把页面缓存误认为 LinuxCNC 运行时权威。所有 G-code、Task、TP、运动学、轴值、HAL、IO、工具、探针和错误状态仍由 LinuxCNC WASM checkpoint/recovery 恢复;OPFS 临时 JSON 只保存页面上下文和可重新提交给 WASM 的程序草稿。 + +## 分层 + +~~~mermaid +flowchart LR + UI[React 页面状态
页签 / 相机 / 图层 / 编辑器] -->|UI_STATE_SAVE| WC[ReactWorkerClient] + WC -->|Worker message| W[Dedicated Worker] + W --> OPFS[OPFS 双槽位临时 JSON] + OPFS -->|UI_STATE_LOAD| W + W --> WC + WC --> UI + P[POWER OFF response] --> W + W --> CP[WASM power-off checkpoint] + CP --> OPFS + OPFS -->|LOAD_WORKSPACE| W + W --> WASM[LinuxCNC WASM restore] + WASM --> R[Task/TP/pose/IO snapshot] + R --> UI +~~~ + +## 缓存范围 + +允许写入 UI 临时 JSON 的字段: + +- selectedAxis、leftTab、rightTab; +- view、zoom、rotateMode; +- coordType、displayType、metric; +- preview layers; +- casePath、program.path、program.source; +- cache schema version、context_id、generation、saved_at_ms、state_hash。 + +禁止由 UI JSON 直接恢复并驱动运动的字段: + +- task state/mode/program status; +- TP queue、active segment、velocity、acceleration、jerk; +- XYZAC pose、J0..J4 joints、RTCP/FK/IK state; +- HAL、spindle、coolant、tool、probe、home/limit、fault; +- operation ledger、event cursor 和已提交副作用。 + +这些字段必须来自 WASM checkpoint;缓存的 program.source 重新恢复时也必须再次发送 PROGRAM_LOAD,由 WASM interpreter 解析。 + +## 文件布局 + +```text +OPFS root/ + linuxcnc-web-wasm/ + ui-state/ + xyzac-trt/ + state-a.json + state-b.json + manifest.json + xyzac-trt-power-off/ + state-a.json + state-b.json + manifest.json +``` + +`state-a.json` 和 `state-b.json` 轮换写入。写新槽位成功并 flush 后才更新 manifest;任一写入中断时,旧槽位仍可读取。读取时验证两个槽位,选择 generation 最大且 hash 正确的记录。 + +## JSON 记录 + +```json +{ + "schema": "linuxcnc-ui-temp-json.v1", + "context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", + "key": "xyzac-trt", + "generation": 12, + "saved_at_ms": 1770000000000, + "state": { + "version": 1, + "leftTab": "manual", + "rightTab": "preview", + "view": "p", + "zoom": 1, + "program": { "path": "browser/demo.ngc", "source": "..." } + }, + "state_hash": "fnv1a64:...", + "record_hash": "fnv1a64:..." +} +``` + +约束:单记录最大 1 MiB,程序缓存最大 900 KiB,缓存 TTL 为 7 天;超限、过期、JSON 损坏、schema/context/hash 不匹配时返回 empty/blocked,不能回退到旧 UI 假状态。 + +## 生命周期 + +~~~mermaid +sequenceDiagram + participant A as App.tsx + participant C as WorkerClient + participant W as Worker + participant O as OPFS + participant X as WASM + A->>C: ensureSession + C->>W: HELLO / SESSION_ATTACH + A->>C: UI_STATE_LOAD + C->>W: custom Worker cache request + W->>O: scan slots + verify hash/TTL + O-->>W: newest valid UI state + W-->>C: UI_STATE_RESULT + C-->>A: restore tabs/camera/editor draft + A->>W: LOAD_WORKSPACE + W->>O: load power-off checkpoint if present + W->>X: restore WASM task/TP/program/IO + X-->>W: authoritative snapshot + W-->>A: state snapshot + A->>C: debounced UI_STATE_SAVE + C->>W: save state JSON + W->>O: write inactive slot, flush, update manifest +~~~ + +高频 SIM_STEP 不写 OPFS。页面状态变化使用 500ms 防抖;程序编辑器输入连续变化只保存最后一个稳定版本。POWER OFF 的 WASM checkpoint 由 Worker 在收到成功回执后写入专用 key;下一次 LOAD_WORKSPACE 由 Worker 自动读取并交给 WASM restore。 + +## 安全和一致性 + +1. React 不访问 navigator.storage、WASM 指针或 OPFS 文件。 +2. Worker 是 OPFS 唯一写者;所有消息串行处理,避免控制命令和缓存写入竞争。 +3. UI_STATE_RESULT 不进入 CNC state reducer,不改变 protocol sequence、task tick 或运动状态。 +4. UI cache 的 program.source 只能作为输入草稿;加载和预览仍必须由 WASM PROGRAM_LOAD 生成。 +5. OPFS 不可用时,页面显示可诊断的 blocked/empty 状态,不使用 localStorage 或静默内存替代。 +6. 清理只删除 UI 临时槽位;不会删除 WASM checkpoint、事件日志或程序运行状态。 + +## 代码和测试 + +| 能力 | 文件 | +| --- | --- | +| 双槽位 JSON、TTL、hash、OPFS API | web/worker/opfs-ui-state.mjs | +| Worker-only 路由、断电 checkpoint | web/worker/opfs-worker-bridge.mjs | +| React UI 状态读写调用 | web/react/src/worker-client.ts、web/react/src/App.tsx | +| 浏览器 Worker 入口 | web/react/src/sim-worker-entry.ts、web/worker/sim-worker.mjs | +| 单元回归 | web/worker/test_opfs_ui_state.mjs | +| 原生 OPFS 能力探针 | web/workspace/opfs-runtime.mjs、web/app/browser-opfs-probe.html | + +验证命令: + +```text +node web/worker/test_opfs_ui_state.mjs +npm run typecheck --prefix web/react +npm run build --prefix web/react +``` + +真实浏览器还需验证 OPFS quota、Worker reload、坏槽位、过期记录和离线启动;这些属于独立的浏览器恢复 oracle,不等同于 UI cache 单元测试通过。 + +已完成的线上浏览器样例:`scripts/test_xyzac_opfs_browser.mjs` 在 `20260903-linuxcnc-wasm-opfs-r30` 上验证视角、DRO 页签、A 轴选择刷新后恢复;实际 OPFS 文件为 `state-b.json`/`manifest.json`,`localStorage` 为空,浏览器错误为 0。quota、崩溃和离线 oracle 仍独立保留为 pending。 diff --git a/scripts/test_xyzac_opfs_browser.mjs b/scripts/test_xyzac_opfs_browser.mjs new file mode 100644 index 0000000..d7b23a5 --- /dev/null +++ b/scripts/test_xyzac_opfs_browser.mjs @@ -0,0 +1,39 @@ +import { pathToFileURL } from "node:url"; + +const modulePath = process.env.PLAYWRIGHT_MODULE; +const url = process.env.XYZAC_PANEL_URL ?? "https://49.232.250.35:58010/"; +if (!modulePath) { console.log(JSON.stringify({ schema: "xyzac-opfs-browser.v1", status: "blocked", code: "playwright_unavailable" })); process.exit(0); } +const imported = await import(pathToFileURL(modulePath).href); +const playwright = imported.default ?? imported; +const userDataDir = process.env.XYZAC_OPFS_PROFILE ?? "/tmp/xyzac-opfs-browser-profile"; +const context = await playwright.chromium.launchPersistentContext(userDataDir, { headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", ignoreHTTPSErrors: true, viewport: { width: 1440, height: 1000 }, args: ["--ignore-certificate-errors", "--disable-gpu"] }); +const page = context.pages()[0] ?? await context.newPage(); +const errors = []; +page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); +page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); }); +const main = page.locator("main.axis-app"); +const waitReady = async () => { for (let index = 0; index < 300; index += 1) { if (await main.count() && await main.getAttribute("data-axis-state-source") === "linuxcnc-wasm") return; await page.waitForTimeout(250); } throw new Error("wasm_startup_timeout"); }; +const opfsFiles = async () => page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + const names = []; + const walk = async (directory, prefix) => { + for await (const [name, handle] of directory.entries()) { names.push(`${prefix}/${name}`); if (handle.kind === "directory") await walk(handle, `${prefix}/${name}`); } + }; + await walk(root, ""); + return names.sort(); +}); +await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); +await waitReady(); +await main.locator('button[aria-label="Top view"]').click(); +await main.getByRole("tab", { name: "DRO", exact: true }).click(); +await main.locator('input[value="a"]').check(); +await page.waitForTimeout(1800); +const before = await main.evaluate((element) => ({ view: element.dataset.axisView, zoom: element.dataset.axisZoom, cache: element.dataset.axisUiCache, tab: element.querySelector('#axis-tab-dro')?.getAttribute("aria-selected") === "true" ? "DRO" : "Manual", localStorageKeys: Object.keys(localStorage) })); +const files = await opfsFiles(); +await page.reload({ waitUntil: "domcontentloaded", timeout: 30000 }); +await waitReady(); +await page.waitForTimeout(500); +const after = await main.evaluate((element) => ({ view: element.dataset.axisView, zoom: element.dataset.axisZoom, cache: element.dataset.axisUiCache, dro: element.querySelector('#axis-tab-dro')?.getAttribute("aria-selected"), axisA: element.querySelector('input[value="a"]')?.checked, localStorageKeys: Object.keys(localStorage) })); +await page.screenshot({ path: "/tmp/xyzac-opfs-browser.png", fullPage: true }); +console.log(JSON.stringify({ schema: "xyzac-opfs-browser.v1", status: errors.length === 0 && before.cache === "opfs" && after.cache === "opfs" && before.view === after.view && before.tab === "DRO" && after.dro === "true" && after.axisA === true && before.localStorageKeys.length === 0 && after.localStorageKeys.length === 0 && files.some((file) => file.includes("state-")) ? "verified" : "failed", url, before, after, opfs_files: files, browser_errors: errors, screenshot: "/tmp/xyzac-opfs-browser.png" })); +await context.close(); diff --git a/web/app/worker-client.mjs b/web/app/worker-client.mjs index e9b5897..786bca2 100644 --- a/web/app/worker-client.mjs +++ b/web/app/worker-client.mjs @@ -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; diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index 043dec6..3788157 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -186,9 +186,58 @@ export function App() { const [operationTrace, setOperationTrace] = useState([]); const feedOverridePending = useRef(null); const feedOverrideTimer = useRef | null>(null); + const uiCacheHydrated = useRef(false); + const uiCacheSaveTimer = useRef | 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 | 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 : null; + const cached = cachedPayload?.status === "verified" && cachedPayload.state && typeof cachedPayload.state === "object" ? cachedPayload.state as Record : null; + const cachedProgram = cached?.program && typeof cached.program === "object" ? cached.program as Record : 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; + setLayers((current) => { + const next = { ...current }; + for (const key of Object.keys(current) as Array) 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 = {}, 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
+ 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
AXIS AXISLinuxCNC Web/WASM
{state.status.toUpperCase()}{taskState}
{ setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); setPlotCleared(false); }} /> execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /> setZoom((value) => Math.min(2, value + .2))} /> setZoom((value) => Math.max(.5, value - .2))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} />
diff --git a/web/react/src/legacy.d.ts b/web/react/src/legacy.d.ts index c3f35f8..82b3bbb 100644 --- a/web/react/src/legacy.d.ts +++ b/web/react/src/legacy.d.ts @@ -4,6 +4,7 @@ declare module "*.mjs" { export function toWorkerConfiguration(bundle: unknown): Record; export class SimSession { contextId: string; sessionId: string; constructor(core: unknown); accept(message: unknown): unknown; } export class XyzacSession extends SimSession { authority(): Record; } + export function handleWorkerMessage(session: unknown, input: unknown): Promise; export class AxisOperationController { constructor(client: { send(kind: string, payload?: unknown, operationId?: string): Promise }); execute(kind: string, payload?: Record, options?: { state?: unknown; action?: string | null; gate?: boolean }): Promise<{ operationId: string; response: unknown; gate: { enabled: boolean; reason: string } }>; diff --git a/web/react/src/protocol.ts b/web/react/src/protocol.ts index 19ffc1a..1a9823c 100644 --- a/web/react/src/protocol.ts +++ b/web/react/src/protocol.ts @@ -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 = { schema_version: typeof PROTOCOL_SCHEMA; diff --git a/web/react/src/sim-worker-entry.ts b/web/react/src/sim-worker-entry.ts index a6fad16..6d8201d 100644 --- a/web/react/src/sim-worker-entry.ts +++ b/web/react/src/sim-worker-entry.ts @@ -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" }); diff --git a/web/react/src/store.ts b/web/react/src/store.ts index 6b70922..7822394 100644 --- a/web/react/src/store.ts +++ b/web/react/src/store.ts @@ -202,10 +202,11 @@ function projection(result: Record, message: MessageEnvelope, p ...(planner?.active ? [planner.active] : []), ].filter((segment): segment is NonNullable & { id: number; start: Record; end: Record } => 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() : 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, end: segment.end as Record, 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, end: segment.end as Record, 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 }); diff --git a/web/react/src/test_store_visual_clear.mjs b/web/react/src/test_store_visual_clear.mjs new file mode 100644 index 0000000..d733dcc --- /dev/null +++ b/web/react/src/test_store_visual_clear.mjs @@ -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 })); diff --git a/web/react/src/worker-client.ts b/web/react/src/worker-client.ts index 493f51e..8646672 100644 --- a/web/react/src/worker-client.ts +++ b/web/react/src/worker-client.ts @@ -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(); private pending = new Map void; reject: (error: Error) => void }>(); private readyPromise: Promise | null = null; @@ -32,10 +32,8 @@ export class ReactWorkerClient { this.sessionId = sessionId; this.worker.onmessage = (event: MessageEvent) => { 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 { - 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): Promise { + 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, key = "xyzac-trt"): Promise { return this.sendUiState("UI_STATE_SAVE", { key, state }); } + loadUiState(key = "xyzac-trt"): Promise { return this.sendUiState("UI_STATE_LOAD", { key }); } + clearUiState(key = "xyzac-trt"): Promise { return this.sendUiState("UI_STATE_CLEAR", { key }); } + hello(): Promise { return this.send("HELLO", { supported_schema_versions: [PROTOCOL_SCHEMA], requested_features: ["snapshot", "trajectory"] }); } attach(): Promise { return this.send("SESSION_ATTACH", {}, "react-session-attach"); } ensureSession(): Promise { diff --git a/web/worker/capabilities.mjs b/web/worker/capabilities.mjs index 4a408e5..0279cfe 100644 --- a/web/worker/capabilities.mjs +++ b/web/worker/capabilities.mjs @@ -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 })), }; diff --git a/web/worker/opfs-ui-state.mjs b/web/worker/opfs-ui-state.mjs new file mode 100644 index 0000000..939ec7b --- /dev/null +++ b/web/worker/opfs-ui-state.mjs @@ -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 }); +} diff --git a/web/worker/opfs-worker-bridge.mjs b/web/worker/opfs-worker-bridge.mjs new file mode 100644 index 0000000..35d0ec0 --- /dev/null +++ b/web/worker/opfs-worker-bridge.mjs @@ -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 }; diff --git a/web/worker/sim-worker.mjs b/web/worker/sim-worker.mjs index f729dc2..bab8a00 100644 --- a/web/worker/sim-worker.mjs +++ b/web/worker/sim-worker.mjs @@ -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 }, }); } +}); }; diff --git a/web/worker/test_opfs_ui_state.mjs b/web/worker/test_opfs_ui_state.mjs new file mode 100644 index 0000000..1a565f4 --- /dev/null +++ b/web/worker/test_opfs_ui_state.mjs @@ -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 })); diff --git a/web/worker/test_opfs_worker_bridge.mjs b/web/worker/test_opfs_worker_bridge.mjs new file mode 100644 index 0000000..e1a7f93 --- /dev/null +++ b/web/worker/test_opfs_worker_bridge.mjs @@ -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 })); diff --git a/web/worker/test_xyzac_protocol_react.mjs b/web/worker/test_xyzac_protocol_react.mjs index afd466d..91ed5fd 100644 --- a/web/worker/test_xyzac_protocol_react.mjs +++ b/web/worker/test_xyzac_protocol_react.mjs @@ -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("));