From f6e545018e62ff2f462506cc3e86a9d9e177b8b3 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Sat, 29 Aug 2026 04:52:44 +0800 Subject: [PATCH] Optimize XYZAC motion updates and persist power-off recovery --- coverage/xyzac-trt-recovery.report.json | 6 +- coverage/xyzac-trt-tool-preview.report.json | 14 +- ...xyzac-trt-wasm-authority-audit.report.json | 76 ++++--- ...zac-trt-wasm-only-release-gate.report.json | 4 +- .../xyzac-trt-web-wasm-parity.report.json | 10 +- web/app/worker-client.mjs | 41 +++- web/react/src/App.tsx | 4 +- web/react/src/power-off-checkpoint-storage.ts | 40 ++++ web/react/src/protocol.ts | 3 + web/react/src/store.ts | 38 +++- web/react/src/worker-client.ts | 21 +- web/worker/sim-session.mjs | 194 +++++++++++++++++- web/worker/test_xyzac_runtime_followups.mjs | 62 ++++++ web/worker/wasm-motion-queue.mjs | 8 +- web/worker/wasm-program-runtime.mjs | 15 +- web/worker/wasm-xyzac-device-io.mjs | 8 +- web/worker/wasm-xyzac-io.mjs | 9 +- 17 files changed, 479 insertions(+), 74 deletions(-) create mode 100644 web/react/src/power-off-checkpoint-storage.ts create mode 100644 web/worker/test_xyzac_runtime_followups.mjs diff --git a/coverage/xyzac-trt-recovery.report.json b/coverage/xyzac-trt-recovery.report.json index 91b7b70..c545ac5 100644 --- a/coverage/xyzac-trt-recovery.report.json +++ b/coverage/xyzac-trt-recovery.report.json @@ -65,15 +65,15 @@ }, { "path": "web/worker/sim-session.mjs", - "sha256": "4f674200bfcca9af6bc2c83017329355b88307d0749ebb5c9d0fc8eaecaa2349" + "sha256": "a676086169205f16e25c142cb97e7cfac493ea58852cec93b799797f09637845" }, { "path": "web/worker/wasm-program-runtime.mjs", - "sha256": "60da7b953ac4ee2659f2b05c6bb93b58c8d0c2c4b56363f6a93e44aab7aebb02" + "sha256": "6bc403380cfecdb4a05abf0918681b7d64acc97fb948ce7f4f73f554d51df6c4" } ], "release_status": "STOP_RELEASE", - "report_sha256": "51beb4f92ebaa633816bdbdd1bea3bc0bd918488bb7173c4082683f06f3ed88d", + "report_sha256": "3792c52132374430266cd4671a7001c0f5665cbd594ccd64cd2cc78ec39e50f5", "required_checkpoint_fields": [ "context_id", "simulation_tick", diff --git a/coverage/xyzac-trt-tool-preview.report.json b/coverage/xyzac-trt-tool-preview.report.json index 9c6a476..5cad964 100644 --- a/coverage/xyzac-trt-tool-preview.report.json +++ b/coverage/xyzac-trt-tool-preview.report.json @@ -74,7 +74,7 @@ }, { "path": "web/worker/sim-session.mjs", - "sha256": "4f674200bfcca9af6bc2c83017329355b88307d0749ebb5c9d0fc8eaecaa2349" + "sha256": "a676086169205f16e25c142cb97e7cfac493ea58852cec93b799797f09637845" }, { "path": "web/worker/test_xyzac_exact_preview.mjs", @@ -82,19 +82,19 @@ }, { "path": "web/worker/wasm-program-runtime.mjs", - "sha256": "60da7b953ac4ee2659f2b05c6bb93b58c8d0c2c4b56363f6a93e44aab7aebb02" + "sha256": "6bc403380cfecdb4a05abf0918681b7d64acc97fb948ce7f4f73f554d51df6c4" }, { "path": "web/worker/wasm-motion-queue.mjs", - "sha256": "85b53bf7a31b9b1f1e7e76dad4bf29aabb36defcf6344d34bbb192c157a80764" + "sha256": "ddc215a111e3966357fde967a770a5ba0233b2ddf9d55e663a6c04cdd83b716a" }, { "path": "web/worker/wasm-xyzac-io.mjs", - "sha256": "692dfb4c869130fd4a0ee78a500a54817e326991f2a5ac23a91059136d41eabd" + "sha256": "07c9bc5b365d5bdbb5e23e319e71b807986f1b603b1533508b0004e2c3929265" }, { "path": "web/worker/wasm-xyzac-device-io.mjs", - "sha256": "1d813d59048afb6854b6fd7783dc86f65bf37ccc46f0d4f592b56cbe044211a5" + "sha256": "6963277d64bf6d7780bb8014c4491c90c4961db9345cb8066b3933957dbda17d" }, { "path": "web/worker/test_xyzac_machine_sequence.mjs", @@ -102,7 +102,7 @@ }, { "path": "web/react/src/App.tsx", - "sha256": "2c9aaa252c734a3885d41364cf87f969bf9b58780137e8240906f5be0745b22c" + "sha256": "25ccd89e22f43464e23c5920da96b780b0ca4fdb9632f5b1d84a48c37e894f44" }, { "path": "web/react/src/styles.css", @@ -383,7 +383,7 @@ } }, "release_status": "STOP_RELEASE", - "report_sha256": "d7447aab0398ac572599ecd7b50f24bf29a1751c0f1723f89eac9dadd1308302", + "report_sha256": "e5823f9c3ddba1217a808c401fb23a8b2cdb93a4242633245c8fc0407aa69911", "schema": "xyzac-trt-tool-preview-report.v1", "status": "verified", "task_id": "RTCP-10-TOOL-PREVIEW", diff --git a/coverage/xyzac-trt-wasm-authority-audit.report.json b/coverage/xyzac-trt-wasm-authority-audit.report.json index b35e3ec..19cef37 100644 --- a/coverage/xyzac-trt-wasm-authority-audit.report.json +++ b/coverage/xyzac-trt-wasm-authority-audit.report.json @@ -128,10 +128,10 @@ "source_slice": "AXIS-XYZAC-PROGRAM-MDI", "sources": [ { - "lines": "122-846", + "lines": "122-1000", "path": "web/worker/sim-session.mjs", "role": "session-authority-routing", - "sha256": "4f674200bfcca9af6bc2c83017329355b88307d0749ebb5c9d0fc8eaecaa2349", + "sha256": "a676086169205f16e25c142cb97e7cfac493ea58852cec93b799797f09637845", "status": "resolved", "symbol": "new ProgramRunner" }, @@ -161,10 +161,10 @@ "symbol": "export class MotionState" }, { - "lines": "122-846", + "lines": "122-1000", "path": "web/worker/sim-session.mjs", "role": "session-authority-routing", - "sha256": "4f674200bfcca9af6bc2c83017329355b88307d0749ebb5c9d0fc8eaecaa2349", + "sha256": "a676086169205f16e25c142cb97e7cfac493ea58852cec93b799797f09637845", "status": "resolved", "symbol": "new ProgramRunner" } @@ -186,10 +186,10 @@ "symbol": "export class AuthoritativeRuntime" }, { - "lines": "122-846", + "lines": "122-1000", "path": "web/worker/sim-session.mjs", "role": "session-authority-routing", - "sha256": "4f674200bfcca9af6bc2c83017329355b88307d0749ebb5c9d0fc8eaecaa2349", + "sha256": "a676086169205f16e25c142cb97e7cfac493ea58852cec93b799797f09637845", "status": "resolved", "symbol": "new ProgramRunner" } @@ -205,7 +205,7 @@ "recovery": "Chromium SIGKILL/profile-reopen, corruption detection and Firefox OPFS round-trip are verified" }, "release_status": "STOP_RELEASE", - "report_sha256": "a294fdb56b21d9a8bddca753229d41b045e3b7887a1cf2f5e255c32e2da1fabd", + "report_sha256": "0995970fa99ce6ac0736710bebd35782e4cf4ab60600a72c5baa3966314e1f5d", "scans": { "js_gcode_matches": [ { @@ -274,17 +274,17 @@ "text": "const result = call(this.core, \"sim_interpreter_execute\", \"number\", [\"number\", \"number\", \"number\"], [this.interpreter, this.blockBuffer, bytes.length]);" }, { - "line": 1553, + "line": 1733, "path": "web/worker/sim-session.mjs", "text": "const result = this.xyzacProgram.executeBlock(block);" }, { - "line": 1572, + "line": 1752, "path": "web/worker/sim-session.mjs", "text": "const result = this.program.executeBlock(block);" }, { - "line": 188, + "line": 197, "path": "web/worker/wasm-program-runtime.mjs", "text": "executeBlock(block) {" } @@ -292,7 +292,7 @@ "opfs_writer_matches": [ { "line": 1, - "path": "web/react/dist/assets/sim-worker-entry-DqcdiXWu.js", + "path": "web/react/dist/assets/sim-worker-entry-CyL7D-EC.js", "text": "(function(){var e=(()=>{var e=self.location.href;return(function(t){t||={};var t=t===void 0?{}:t,n,r;t.ready=new Promise(function(e,t){n=e,r=t});var i=Object.assign({},t),a=[],o=typeof window==`object`,s=typeof importScripts==`function`;typ" }, { @@ -1492,11 +1492,11 @@ } ], "react": { - "file_count": 19, + "file_count": 20, "files": [ { "path": "web/react/src/App.tsx", - "sha256": "2c9aaa252c734a3885d41364cf87f969bf9b58780137e8240906f5be0745b22c" + "sha256": "25ccd89e22f43464e23c5920da96b780b0ca4fdb9632f5b1d84a48c37e894f44" }, { "path": "web/react/src/ThreeMachineView.tsx", @@ -1530,9 +1530,13 @@ "path": "web/react/src/main.tsx", "sha256": "421d99e6173741894a6ec5bdbf1d7da519f25c346a842892ddd00110401721d8" }, + { + "path": "web/react/src/power-off-checkpoint-storage.ts", + "sha256": "2aea7f0d2818170989d87c5532e4fb89ad62c51d8c643dd023183c03141deca5" + }, { "path": "web/react/src/protocol.ts", - "sha256": "a4f3b12ec4615c00973e1617849b40e9e3441369f18e28a86434495b154f9112" + "sha256": "2c3f37b078fdbf39a07fd6f66c92a669b2b98f591b211820faa8828f70df3519" }, { "path": "web/react/src/sim-worker-entry.ts", @@ -1544,7 +1548,7 @@ }, { "path": "web/react/src/store.ts", - "sha256": "929aba2af3291aefb4e79f500ec6567f7b7e3f1dc91b68bf67aa8224ba9a41ee" + "sha256": "899f8c4e7ce512b91375ed47289d4a9f2b5146504acde4e23d0587018d7ec766" }, { "path": "web/react/src/test_xyzac_threejs.mjs", @@ -1560,7 +1564,7 @@ }, { "path": "web/react/src/worker-client.ts", - "sha256": "47fcfc52acd5ce6e5dbbd91cf9da45aa5a089a3172da6414a347de5039e36737" + "sha256": "4aee1bca0e6580760e08549dc0dfa7771e57513bb18e19c9471ff263f8af15ee" }, { "path": "web/react/src/xyzac-panel-contract.ts", @@ -1576,7 +1580,7 @@ "react_opfs_matches": [], "react_wasm_matches": [], "worker": { - "file_count": 125, + "file_count": 126, "files": [ { "path": "web/worker/authoritative-runtime.mjs", @@ -1652,7 +1656,7 @@ }, { "path": "web/worker/sim-session.mjs", - "sha256": "4f674200bfcca9af6bc2c83017329355b88307d0749ebb5c9d0fc8eaecaa2349" + "sha256": "a676086169205f16e25c142cb97e7cfac493ea58852cec93b799797f09637845" }, { "path": "web/worker/sim-task.mjs", @@ -2006,6 +2010,10 @@ "path": "web/worker/test_xyzac_rtcp.mjs", "sha256": "bb62b28ea9ecae7328c16ed65ba013a68cabd421bea58929d30fe9271c4e6d77" }, + { + "path": "web/worker/test_xyzac_runtime_followups.mjs", + "sha256": "0d56d8331906d20a67a3c9686bf754be1c231d5392e4426b5b2d881c9120d36b" + }, { "path": "web/worker/test_xyzac_session.mjs", "sha256": "76cd74a09a0318a33d70529f7c5b9cad25fd0daebd6dd8bb05f87b42626858ef" @@ -2048,19 +2056,19 @@ }, { "path": "web/worker/wasm-motion-queue.mjs", - "sha256": "85b53bf7a31b9b1f1e7e76dad4bf29aabb36defcf6344d34bbb192c157a80764" + "sha256": "ddc215a111e3966357fde967a770a5ba0233b2ddf9d55e663a6c04cdd83b716a" }, { "path": "web/worker/wasm-program-runtime.mjs", - "sha256": "60da7b953ac4ee2659f2b05c6bb93b58c8d0c2c4b56363f6a93e44aab7aebb02" + "sha256": "6bc403380cfecdb4a05abf0918681b7d64acc97fb948ce7f4f73f554d51df6c4" }, { "path": "web/worker/wasm-xyzac-device-io.mjs", - "sha256": "1d813d59048afb6854b6fd7783dc86f65bf37ccc46f0d4f592b56cbe044211a5" + "sha256": "6963277d64bf6d7780bb8014c4491c90c4961db9345cb8066b3933957dbda17d" }, { "path": "web/worker/wasm-xyzac-io.mjs", - "sha256": "692dfb4c869130fd4a0ee78a500a54817e326991f2a5ac23a91059136d41eabd" + "sha256": "07c9bc5b365d5bdbb5e23e319e71b807986f1b603b1533508b0004e2c3929265" }, { "path": "web/worker/wasm-xyzac-kinematics.mjs", @@ -2086,10 +2094,10 @@ "scope": "selected_xyzac_simulation_only", "sources": [ { - "lines": "122-846", + "lines": "122-1000", "path": "web/worker/sim-session.mjs", "role": "session-authority-routing", - "sha256": "4f674200bfcca9af6bc2c83017329355b88307d0749ebb5c9d0fc8eaecaa2349", + "sha256": "a676086169205f16e25c142cb97e7cfac493ea58852cec93b799797f09637845", "status": "resolved", "symbol": "new ProgramRunner" }, @@ -2134,10 +2142,10 @@ "symbol": "export class SimTask" }, { - "lines": "58-58", + "lines": "62-62", "path": "web/worker/wasm-motion-queue.mjs", "role": "wasm-motion-authority", - "sha256": "85b53bf7a31b9b1f1e7e76dad4bf29aabb36defcf6344d34bbb192c157a80764", + "sha256": "ddc215a111e3966357fde967a770a5ba0233b2ddf9d55e663a6c04cdd83b716a", "status": "resolved", "symbol": "export class WasmMotionQueue" }, @@ -2158,10 +2166,10 @@ "symbol": "createModule" }, { - "lines": "49-49", + "lines": "66-66", "path": "web/react/src/worker-client.ts", "role": "react-worker-boundary", - "sha256": "47fcfc52acd5ce6e5dbbd91cf9da45aa5a089a3172da6414a347de5039e36737", + "sha256": "4aee1bca0e6580760e08549dc0dfa7771e57513bb18e19c9471ff263f8af15ee", "status": "resolved", "symbol": "postMessage" }, @@ -2182,26 +2190,26 @@ "symbol": "createSyncAccessHandle" }, { - "lines": "47-47", + "lines": "56-56", "path": "web/worker/wasm-program-runtime.mjs", "role": "wasm-program-authority", - "sha256": "60da7b953ac4ee2659f2b05c6bb93b58c8d0c2c4b56363f6a93e44aab7aebb02", + "sha256": "6bc403380cfecdb4a05abf0918681b7d64acc97fb948ce7f4f73f554d51df6c4", "status": "resolved", "symbol": "export class WasmProgramRuntime" }, { - "lines": "39-39", + "lines": "46-46", "path": "web/worker/wasm-xyzac-io.mjs", "role": "wasm-hal-io-tool-probe-authority", - "sha256": "692dfb4c869130fd4a0ee78a500a54817e326991f2a5ac23a91059136d41eabd", + "sha256": "07c9bc5b365d5bdbb5e23e319e71b807986f1b603b1533508b0004e2c3929265", "status": "resolved", "symbol": "export class WasmXyzacIo" }, { - "lines": "39-39", + "lines": "44-44", "path": "web/worker/wasm-xyzac-device-io.mjs", "role": "wasm-device-io-authority", - "sha256": "1d813d59048afb6854b6fd7783dc86f65bf37ccc46f0d4f592b56cbe044211a5", + "sha256": "6963277d64bf6d7780bb8014c4491c90c4961db9345cb8066b3933957dbda17d", "status": "resolved", "symbol": "export class WasmXyzacDeviceIo" } diff --git a/coverage/xyzac-trt-wasm-only-release-gate.report.json b/coverage/xyzac-trt-wasm-only-release-gate.report.json index fbdd801..9c093cc 100644 --- a/coverage/xyzac-trt-wasm-only-release-gate.report.json +++ b/coverage/xyzac-trt-wasm-only-release-gate.report.json @@ -96,7 +96,7 @@ "failure_samples": [], "input": { "path": "coverage/xyzac-trt-web-wasm-parity.report.json", - "sha256": "1ef84b02927a83c4221c11ecd896ac9a3a8d4ec1febc5363c6088ef04202a84f" + "sha256": "df18bbb72ac8e97af9bdf310203c181e8d7a9cf2843121e39ecd3832f08569b0" }, "known_blockers": [ "STOP_RELEASE is intentional until an owner authorizes scope expansion" @@ -104,7 +104,7 @@ "outputs": [], "release_decision": "selected_case_verified_but_release_stopped_by_scope_exclusions", "release_status": "STOP_RELEASE", - "report_sha256": "98269d8e9f60e22cc65c1eceff0ad714a614d368bdec9b20b14048db7114ce7f", + "report_sha256": "c4ea0158e4527823915b678f4fbc0667bba20fe3eff25cd7427358d2b3571b3b", "schema": "xyzac-trt-wasm-only-release-gate.v1", "status": "verified", "task_id": "RELEASE-00" diff --git a/coverage/xyzac-trt-web-wasm-parity.report.json b/coverage/xyzac-trt-web-wasm-parity.report.json index e0fa2ac..fd16f6c 100644 --- a/coverage/xyzac-trt-web-wasm-parity.report.json +++ b/coverage/xyzac-trt-web-wasm-parity.report.json @@ -91,22 +91,22 @@ }, "release_status": "STOP_RELEASE", "report_hashes": { - "coverage/xyzac-trt-gate-matrix.report.json": "6b1d7fa299ceaf6d9a355fc3d201dd872b586e8cbd3042f886b6be9ffa990603", + "coverage/xyzac-trt-gate-matrix.report.json": "3a0dd402abd95e79243e0f61e62819d595110ac6d1c3ea2a3b2251424718ba86", "coverage/xyzac-trt-hal-tool-alarm.report.json": "666fb1273c2471cd5869aea83e847a657309ee8cb4915713e25f4a99c94b3943", "coverage/xyzac-trt-manual-control.report.json": "bd38d4f20322ae3e246438740b3e35827ab2ac6b7b16b8cee22920d2c24c1847", "coverage/xyzac-trt-program-preview.report.json": "c4db73f5ebe63e26921425d44c9e914ca453300a54f51b9da05d709a9e68cf15", "coverage/xyzac-trt-protocol-react.report.json": "af78f0fd639fca95c5406b8afdcf5305225d97aba448637745bd5a941aa19689", - "coverage/xyzac-trt-recovery.report.json": "4452efdf6de6975221f4944bda53688bbb5be5b256525cf0d4f108408d8191da", + "coverage/xyzac-trt-recovery.report.json": "96df470c39326eab1979bd86669fb894335185806015479d586711f67c19ee05", "coverage/xyzac-trt-rtcp.report.json": "b1562d6d2c9cd5144c7c8c9b334bd8bf67919ea7297f103ec67cb32773608d08", "coverage/xyzac-trt-source-slice-inventory.report.json": "bf5f3fd36e1943e0dc42bafb2ed195fad147bcb3e56a8d208d7f01a3641829de", "coverage/xyzac-trt-startup.report.json": "56e040b645bcefc3d6c57244cb2d3aba16f421ff5137089d9c3336970dfd968b", "coverage/xyzac-trt-threejs.report.json": "5172d7118c555fca5f92bcb443b9c259ea0225346482558d4c33ef8a4f8da5cb", - "coverage/xyzac-trt-tool-preview.report.json": "09b670a1c952d0629a19bce1cf584a37b92bacb1d6a5c1430fc939cd2668a3ea", + "coverage/xyzac-trt-tool-preview.report.json": "c1643894bf1f2f1e49f43052731bef461ceaaf307d170e4877ac251d16e37013", "coverage/xyzac-trt-trace-parity.report.json": "6c6b3500bc1189ff68513b74f0e5144879c3af7641349cb61737191303b76950", - "coverage/xyzac-trt-wasm-authority-audit.report.json": "4f5431e951c4384c90e4ef31988189dc4f1018ddfc9d0f5e76e8f89ac09557be", + "coverage/xyzac-trt-wasm-authority-audit.report.json": "f0d1de5dc69141fc85756b37d4c2a69ed3a8071b43962a5bd549f6465d94f3de", "coverage/xyzac-trt-workflow.report.json": "9d0ed087b17ec14c906f4268d2d616dbc3800da03f731376b2ef9ae44ca5c6e7" }, - "report_sha256": "8c182cef79f750228660479587d5fa12675479dc898ba353f52e57e63b604dfb", + "report_sha256": "59b7792f766f45b0dfc2f59829d3932d8dfa75097c890ef58c41030583c11178", "schema": "xyzac-trt-web-wasm-parity-report.v1", "source_coverage": { "axis_keyboard": 10, diff --git a/web/app/worker-client.mjs b/web/app/worker-client.mjs index 61bd3df..e9b5897 100644 --- a/web/app/worker-client.mjs +++ b/web/app/worker-client.mjs @@ -1,11 +1,43 @@ 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"; + return Object.assign(new Error(String(reason)), { + code: payload.code ?? "worker_error", + reason: payload.reason ?? payload.gate_reason ?? null, + message_key: payload.message_key ?? null, + response: message, + }); +} + export class WorkerClient { constructor({ worker = null, workerFactory = null, contextId = CONTEXT_ID, sessionId = null, timeoutMs = 10000, maxPending = 64 } = {}) { this.contextId = contextId; @@ -32,6 +64,9 @@ 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; @@ -41,9 +76,7 @@ export class WorkerClient { this.pending.delete(operationId ?? `seq:${message.seq}`); if (operationId) this.completed.set(operationId, message); if (message.kind === "ERROR" || message.payload?.status === "error" || message.payload?.status === "blocked") { - const error = new Error(message.payload?.message_key ?? message.payload?.code ?? "worker_error"); - error.response = message; - pending.reject(error); + pending.reject(commandError(message)); } else pending.resolve(message); } @@ -68,7 +101,7 @@ export class WorkerClient { seq, kind, ...(operationId ? { operation_id: operationId } : {}), - payload, + payload: kind === "LOAD_WORKSPACE" ? attachPowerOffCheckpoint(payload) : payload, }; let resolvePromise; let rejectPromise; diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index 87f3b34..e4a6aa1 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -153,8 +153,8 @@ export function App() { const trajectory = [...(planner?.queue ?? []), ...(planner?.active ? [planner.active] : [])]; const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } }; const stopJogTimer = () => { if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } }; - const startJogTimer = () => { if (jogTimer.current) return; jogTimer.current = setInterval(() => { if (jogStepPending.current) return; jogStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 1 }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { jogStepPending.current = false; }); }, 50); }; - const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { if (runStepPending.current) return; runStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 5 }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { runStepPending.current = false; }); }, 50); }; + const startJogTimer = () => { if (jogTimer.current) return; jogTimer.current = setInterval(() => { if (jogStepPending.current) return; jogStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 1, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { jogStepPending.current = false; }); }, 50); }; + const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { if (runStepPending.current) return; runStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 5, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { runStepPending.current = false; }); }, 50); }; const dispatch = (kind: string, payload: Record = {}, gate = true, action: string | null = null): Promise => { setCommandStatus(`${kind} pending`); const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) }); diff --git a/web/react/src/power-off-checkpoint-storage.ts b/web/react/src/power-off-checkpoint-storage.ts new file mode 100644 index 0000000..6a23734 --- /dev/null +++ b/web/react/src/power-off-checkpoint-storage.ts @@ -0,0 +1,40 @@ +import type { MessageEnvelope } from "./protocol"; + +const STORAGE_KEY = "linuxcnc-web-wasm:xyzac-power-off-checkpoint"; + +function payloadOf(message: MessageEnvelope): Record { + return message.payload && typeof message.payload === "object" ? message.payload as Record : {}; +} + +export function persistPowerOffCheckpoint(message: MessageEnvelope): { status: string; storage: string; reason?: string } | null { + const payload = payloadOf(message); + const checkpoint = payload.recovery_checkpoint; + if (message.kind !== "COMMAND_RESULT" || payload.status !== "verified" || payload.command !== "POWER" || payload.active !== false || !checkpoint || typeof checkpoint !== "object") return null; + try { + if (!globalThis.localStorage) return { status: "blocked", storage: "browser_local_storage", reason: "local_storage_unavailable" }; + globalThis.localStorage.setItem(STORAGE_KEY, JSON.stringify(checkpoint)); + return { status: "verified", storage: "browser_local_storage" }; + } catch (error) { + return { status: "blocked", storage: "browser_local_storage", reason: error instanceof Error ? error.message : String(error) }; + } +} + +export function attachPersistedPowerOffCheckpoint(payload: unknown): unknown { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload; + try { + const raw = globalThis.localStorage?.getItem(STORAGE_KEY); + if (!raw) return payload; + const checkpoint = JSON.parse(raw); + return { ...(payload as Record), recovery_checkpoint: checkpoint }; + } catch { + return payload; + } +} + +export function clearPersistedPowerOffCheckpoint(message: MessageEnvelope): void { + const payload = payloadOf(message); + if (message.kind !== "COMMAND_RESULT" || payload.status !== "verified" || payload.command !== "POWER" || payload.active !== true) return; + try { globalThis.localStorage?.removeItem(STORAGE_KEY); } catch { /* storage cleanup is best effort */ } +} + +export { STORAGE_KEY }; diff --git a/web/react/src/protocol.ts b/web/react/src/protocol.ts index 2443722..ebb7f6d 100644 --- a/web/react/src/protocol.ts +++ b/web/react/src/protocol.ts @@ -42,6 +42,7 @@ export type SimSnapshotV1 = { simulation_tick: number; state_hash: string; task?: { state: string; mode: string; interp_state?: string; current_line?: number | null }; + program?: Record | null; motion?: MotionSnapshot; wasm_motion?: Record; authoritative?: { state_hash?: string; planner?: TrajectorySnapshot; kinematics?: Record }; @@ -56,6 +57,8 @@ export type SimSnapshotV1 = { panel_snapshot?: PanelSnapshotV1; configuration?: Record | null; visual?: PanelSnapshotV1["visual"]; + motion_sample?: { tick?: number; pose?: Record; joints?: number[]; active_segment_id?: number; active_progress?: number }; + snapshot_mode?: "full" | "motion_delta"; kinematics?: XyzacKinematicsSnapshot; xyzac?: XyzacCapabilitySnapshot; gates?: Record; diff --git a/web/react/src/store.ts b/web/react/src/store.ts index 8c230ab..4baeb89 100644 --- a/web/react/src/store.ts +++ b/web/react/src/store.ts @@ -56,6 +56,41 @@ function payload(message: MessageEnvelope): T { function projection(result: Record, message: MessageEnvelope, previous: FrontendState) { const panel = result.panel_snapshot as PanelSnapshotV1 | undefined; + const delta = result.motion_sample as { tick?: number; pose?: Record; joints?: number[]; active_segment_id?: number; active_progress?: number } | undefined; + if (!panel && delta && previous.snapshot) { + const pose = delta.pose ?? {}; + const joints = Array.isArray(delta.joints) ? [...delta.joints] : previous.snapshot.motion?.joints ?? []; + const visual = previous.snapshot.visual ? { + ...previous.snapshot.visual, + pose: { ...previous.snapshot.visual.pose, ...pose }, + joints, + ...(delta.active_segment_id !== undefined ? { active_segment_id: delta.active_segment_id } : {}), + ...(delta.active_progress !== undefined ? { active_progress: delta.active_progress } : {}), + ...(Array.isArray(result.motion_trace) ? { motion_trace: result.motion_trace } : {}), + } : previous.snapshot.visual; + const nextSnapshot = { + ...previous.snapshot, + simulation_tick: message.simulation_tick ?? delta.tick ?? previous.snapshot.simulation_tick, + state_hash: String(result.state_hash ?? previous.snapshot.state_hash), + task: (result.task as SimSnapshotV1["task"]) ?? previous.snapshot.task, + program: (result.program as Record) ?? previous.snapshot.program, + motion: { + ...previous.snapshot.motion, + axes: [Number(pose.x ?? previous.snapshot.motion?.axes?.[0] ?? 0), Number(pose.y ?? previous.snapshot.motion?.axes?.[1] ?? 0), Number(pose.z ?? previous.snapshot.motion?.axes?.[2] ?? 0)], + joints, + }, + wasm_motion: (result.wasm_motion as Record) ?? previous.snapshot.wasm_motion, + ...(visual ? { visual } : {}), + } satisfies SimSnapshotV1; + return { + snapshot: nextSnapshot, + toolpath: previous.toolpath, + task: (result.task as Record) ?? previous.task, + program: (result.program as Record) ?? previous.program, + axisState: (result.axis_state as AxisStateView) ?? previous.axisState, + gates: (result.gates as FrontendState["gates"]) ?? previous.gates, + }; + } if (panel?.schema === "axis-panel-snapshot.v1") { const visualSegments = (Array.isArray(panel.visual.toolpath) ? panel.visual.toolpath : previous.toolpath) .filter((segment) => segment && typeof segment === "object" && Number.isSafeInteger((segment as { id?: unknown }).id ?? 0)) @@ -178,7 +213,8 @@ export function reduceFrontendState(state: FrontendState, message: MessageEnvelo const projected = projection(value, message, state); const taskState = String((value.task as Record | undefined)?.state ?? "").toUpperCase(); const status = ["ESTOP", "FAULT"].includes(taskState) ? "blocked" : "error"; - return { ...base, status, error: { code: String(value.code ?? "worker_error"), message: JSON.stringify(value) }, ...projected }; + const readable = value.reason ?? value.gate_reason ?? value.message_key ?? value.code ?? "worker_error"; + return { ...base, status, error: { code: String(value.code ?? "worker_error"), message: String(readable) }, ...projected }; } default: return base; } diff --git a/web/react/src/worker-client.ts b/web/react/src/worker-client.ts index f42c35f..493f51e 100644 --- a/web/react/src/worker-client.ts +++ b/web/react/src/worker-client.ts @@ -1,7 +1,20 @@ import { CONTEXT_ID, PROTOCOL_SCHEMA, isEnvelope, type MessageEnvelope } from "./protocol"; +import { attachPersistedPowerOffCheckpoint, clearPersistedPowerOffCheckpoint, persistPowerOffCheckpoint } from "./power-off-checkpoint-storage"; type Listener = (message: MessageEnvelope) => void; +function commandError(message: MessageEnvelope): Error { + const payload = message.payload && typeof message.payload === "object" ? message.payload as Record : {}; + const error = new Error(String(payload.reason ?? payload.gate_reason ?? payload.message_key ?? payload.code ?? "worker_error")); + Object.assign(error, { + code: String(payload.code ?? "worker_error"), + reason: payload.reason ?? payload.gate_reason ?? null, + message_key: payload.message_key ?? null, + response: message, + }); + return error; +} + export class ReactWorkerClient { private readonly worker: Worker; private readonly contextId: string; @@ -19,11 +32,14 @@ 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 pending = this.pending.get(event.data.seq); if (pending) { this.pending.delete(event.data.seq); - if (event.data.kind === "ERROR" || event.data.payload && typeof event.data.payload === "object" && (event.data.payload as { status?: string }).status === "blocked") pending.reject(new Error(String((event.data.payload as { code?: string }).code ?? "worker_error"))); + if (event.data.kind === "ERROR" || event.data.payload && typeof event.data.payload === "object" && (event.data.payload as { status?: string }).status === "blocked") pending.reject(commandError(event.data)); else pending.resolve(event.data); } }; @@ -45,7 +61,8 @@ 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 message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq: ++this.sequence, ...(operationId ? { operation_id: operationId } : {}), payload }; + 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 }; return new Promise((resolve, reject) => { this.pending.set(message.seq, { resolve, reject }); this.worker.postMessage(message); }); } diff --git a/web/worker/sim-session.mjs b/web/worker/sim-session.mjs index 23a60e5..f91f2a5 100644 --- a/web/worker/sim-session.mjs +++ b/web/worker/sim-session.mjs @@ -149,7 +149,44 @@ export class SimSession { // WASM pose samples into every 10 ms step response; the frontend retains // the canonical path from PROGRAM_LOAD/PROGRAM_RESTART. const lightweightXyzacStep = input.kind === "SIM_STEP" && Boolean(this.xyzacProgram); - const panelSnapshot = this.panelSnapshot({ task, program, input, tick, motionTrace: statePayload.motion_trace, includeToolpath: !lightweightXyzacStep, includeConfiguration: !lightweightXyzacStep, includeStableRuntime: !lightweightXyzacStep }); + const currentMotion = this.wasmMotion.read(); + const completedMotion = program.status === 4 && currentMotion.state?.queue_depth === 0; + const motionDelta = lightweightXyzacStep && input.payload?.response === "motion_delta" && !completedMotion; + const panelSnapshot = motionDelta ? null : this.panelSnapshot({ task, program, input, tick, motionTrace: statePayload.motion_trace, includeToolpath: !lightweightXyzacStep, includeConfiguration: !lightweightXyzacStep, includeStableRuntime: !lightweightXyzacStep }); + const compactProgram = motionDelta ? { + schema: program.schema, + source: program.source, + status: program.status, + program_status: program.program_status, + loaded: program.loaded, + current_line: program.current_line, + line: program.line, + path: program.path, + source_hash: program.source_hash, + state_hash: program.state_hash, + } : null; + const trace = Array.isArray(statePayload.motion_trace) ? statePayload.motion_trace : []; + const motionSample = motionDelta ? (trace.at(-1) ?? { + tick, + pose: currentMotion.state?.position ?? null, + joints: [], + active_segment_id: currentMotion.state?.active_id ?? 0, + active_progress: currentMotion.state?.progress ?? 0, + }) : null; + const responsePayload = motionDelta ? { + status: statePayload.status, + cycles: statePayload.cycles, + motion_trace: trace, + motion_sample: motionSample, + snapshot_mode: "motion_delta", + motion_authority: statePayload.motion_authority ?? this.motionAuthority, + task, + task_authority: statePayload.task_authority ?? this.task.authority(), + wasm_motion: statePayload.wasm_motion ?? currentMotion, + motion: statePayload.motion ?? this.motionProjection(), + program: compactProgram, + state_hash: statePayload.state_hash ?? null, + } : statePayload; return { schema_version: PROTOCOL_SCHEMA, kind, @@ -159,12 +196,12 @@ export class SimSession { ...(text(input.operation_id) ? { operation_id: input.operation_id } : {}), simulation_tick: tick, payload: { - ...statePayload, - panel_snapshot: panelSnapshot, - motion_authority: statePayload.motion_authority ?? this.motionAuthority, - task_authority: statePayload.task_authority ?? this.task.authority(), + ...responsePayload, + ...(panelSnapshot ? { panel_snapshot: panelSnapshot } : {}), + motion_authority: responsePayload.motion_authority ?? this.motionAuthority, + task_authority: responsePayload.task_authority ?? this.task.authority(), ...(!lightweightXyzacStep ? { wasm_home_limit: statePayload.wasm_home_limit ?? this.wasmHomeLimit.read() } : {}), - axis_state: axisState({ task, program, motion: this.motionProjection(), devices: this.wasmDeviceProjection(), io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), action: input.kind, payload: input.payload ?? {} }), + axis_state: axisState({ task, program, motion: this.motionProjection(), devices: {}, io: this.xyzacIo?.canonical?.() ?? {}, device_io: this.xyzacDeviceIo?.canonical?.() ?? this.deviceIo.export(), action: input.kind, payload: input.payload ?? {} }), }, }; } @@ -516,6 +553,123 @@ export class SimSession { }); } + createPowerOffCheckpoint() { + const checkpoint = this.createCheckpoint(); + const state = structuredClone(checkpoint.state); + // Operation history and replay snapshots are useful for an explicit + // recovery command, but are redundant for a power-off restart. Omitting + // them keeps the browser durable record bounded during long JOG runs. + delete state.ledger; + const compact = { + schema: "xyzac-power-off-checkpoint.v1", + context_id: this.contextId, + simulation_tick: checkpoint.simulation_tick, + state, + state_hash: fnv1a64(state), + }; + return { ...compact, checkpoint_hash: fnv1a64(compact) }; + } + + restoreCompactPowerOff(record, transportSeq) { + if (!record || record.schema !== "xyzac-power-off-checkpoint.v1" || record.context_id !== this.contextId || !record.state) return { status: "blocked", code: "runtime_checkpoint_schema", reason: "power_off_checkpoint_schema_invalid" }; + if (fnv1a64(record.state) !== record.state_hash || fnv1a64(Object.fromEntries(Object.entries(record).filter(([key]) => key !== "checkpoint_hash"))) !== record.checkpoint_hash) return { status: "blocked", code: "runtime_checkpoint_hash", reason: "power_off_checkpoint_hash_mismatch" }; + if (!Number.isSafeInteger(transportSeq) || transportSeq < 1 || !Number.isSafeInteger(record.simulation_tick) || record.simulation_tick < 0) return { status: "blocked", code: "runtime_checkpoint_range", reason: "power_off_checkpoint_range_invalid" }; + const state = record.state; + const liveTimeline = this.timeline.export(); + const liveLedger = this.ledger; + const liveOperations = [...this.operations.values()]; + try { + if (!this.xyzacProgram || !state.program?.source_text) throw new Error("power_off_program_missing"); + const loaded = this.xyzacProgram.load(state.program.source_text, state.program.path ?? null); + if (loaded.status !== "verified") throw new Error(loaded.code); + const subroutines = this.loadXyzacSubroutines(state.program.path ?? null); + if (subroutines.status !== "verified") throw new Error(subroutines.code); + if (this.xyzacProgram.restoreState(state.program).status === "error") throw new Error("power_off_program_restore_failed"); + if (state.wasm_motion) this.wasmMotion.restoreState(state.wasm_motion); + if (state.wasm_home_limit) this.wasmHomeLimit.restoreState(state.wasm_home_limit); + if (state.device_io && this.xyzacDeviceIo) this.xyzacDeviceIo.restoreState(state.device_io); + if (state.devices && this.xyzacIo) this.xyzacIo.restoreState(state.devices); + if (state.kinematics && this.xyzacKinematics) this.xyzacKinematics.restoreState(state.kinematics); + if (state.task) { + const task = { ...state.task, session_id: this.sessionId, seq: transportSeq, simulation_tick: record.simulation_tick }; + delete task.state_hash; + task.state_hash = fnv1a64(task); + this.task.restore(task); + } + if (state.scheduler) this.scheduler.restore(state.scheduler); + this.plc = new PlcRuntime(); if (state.plc) this.plc.restore(state.plc); + this.hal = new HalRuntime(); if (state.hal_runtime?.schema === "sim_hal_runtime.v1") this.hal.restore(state.hal_runtime); + this.configurationBatch = new ConfigurationBatchRuntime({ context_id: this.contextId }); if (state.configuration_batch) this.configurationBatch.restore(state.configuration_batch); + this.ioAck = new IoAckTracker(); if (state.io_ack) this.ioAck.restore(state.io_ack); + this.callbacks = new PythonCallbacks(); if (state.callbacks) this.callbacks.restore(state.callbacks); + this.configurationState = state.configuration ?? this.configurationState; + this.halState = state.hal ?? this.halState; + this.previewState = state.preview ?? this.previewState; + this.python = new PythonBridge(); if (state.python) this.python.restoreState(state.python); + const liveEvents = liveTimeline.events.events; + const rebasedEvents = liveEvents.map((event, index) => index === liveEvents.length - 1 ? { ...event, tick: record.simulation_tick } : event); + this.timeline = new ReplayTimeline({ schema: liveTimeline.schema, events: { ...liveTimeline.events, tick: record.simulation_tick, events: rebasedEvents }, snapshots: { schema: "snapshot.v1", snapshots: [] } }); + this.tick = record.simulation_tick; + const protocolTick = call(this.core, "sim_protocol_set_tick", record.simulation_tick); + if (protocolTick !== 0) throw new Error(`protocol_tick_${protocolTick}`); + this.timeline.checkpoint(transportSeq, record.simulation_tick, this.stateForCheckpoint(), null); + this.operations = new Map(liveOperations.map((operation) => [operation.operation_id, operation])); + this.ledger = liveLedger; + return { status: "verified", storage: "browser_client_storage", checkpoint_hash: record.checkpoint_hash, simulation_tick: record.simulation_tick, rebased_seq: transportSeq }; + } catch (error) { + this.ledger = liveLedger; + this.operations = new Map(liveOperations.map((operation) => [operation.operation_id, operation])); + return { status: "blocked", code: "runtime_checkpoint_restore_failed", reason: String(error?.message ?? error) }; + } + } + + restorePersistedPowerOff(checkpoint, transportSeq) { + if (!checkpoint || !Number.isSafeInteger(transportSeq) || transportSeq < 1) return { status: "blocked", code: "runtime_checkpoint_input_invalid", reason: "checkpoint_or_sequence_invalid" }; + if (checkpoint.schema === "xyzac-power-off-checkpoint.v1") return this.restoreCompactPowerOff(checkpoint, transportSeq); + const liveTimeline = this.timeline.export(); + const liveLedger = this.ledger; + const liveOperations = [...this.operations.values()]; + // A browser restart creates a new transport session id. Adapt only the + // task identity and recompute checkpoint digests before restoring so the + // WASM task canonical (which was initialized with the new id) remains + // self-consistent. + let checkpointForRestore = checkpoint; + if (checkpoint.state?.task?.session_id && checkpoint.state.task.session_id !== this.sessionId) { + checkpointForRestore = structuredClone(checkpoint); + checkpointForRestore.state.task.session_id = this.sessionId; + checkpointForRestore.state.task.state_hash = fnv1a64(Object.fromEntries(Object.entries(checkpointForRestore.state.task).filter(([key]) => key !== "state_hash"))); + checkpointForRestore.state_hash = fnv1a64(checkpointForRestore.state); + const unsigned = Object.fromEntries(Object.entries(checkpointForRestore).filter(([key]) => key !== "checkpoint_hash")); + checkpointForRestore.checkpoint_hash = fnv1a64(unsigned); + } + const restored = this.restoreCheckpoint(checkpointForRestore); + if (restored.status !== "recovered") return restored; + const restoredTick = this.tick; + const restoredTask = this.task.export(); + const rebasedTask = { ...restoredTask, session_id: this.sessionId, seq: transportSeq, simulation_tick: restoredTick }; + delete rebasedTask.state_hash; + rebasedTask.state_hash = fnv1a64(rebasedTask); + try { + this.task.restore(rebasedTask); + const liveEvents = liveTimeline.events.events; + const rebasedEvents = liveEvents.map((event, index) => index === liveEvents.length - 1 ? { ...event, tick: restoredTick } : event); + this.timeline = new ReplayTimeline({ + schema: liveTimeline.schema, + events: { ...liveTimeline.events, tick: restoredTick, events: rebasedEvents }, + snapshots: { schema: "snapshot.v1", snapshots: [] }, + }); + this.tick = restoredTick; + const protocolTick = call(this.core, "sim_protocol_set_tick", restoredTick); + if (protocolTick !== 0) throw new Error(`protocol_tick_${protocolTick}`); + this.timeline.checkpoint(transportSeq, restoredTick, this.stateForCheckpoint(), null); + this.operations = new Map(liveOperations.map((operation) => [operation.operation_id, operation])); + this.ledger = liveLedger; + return { status: "verified", storage: "browser_client_storage", checkpoint_hash: checkpoint.checkpoint_hash, simulation_tick: restoredTick, rebased_seq: transportSeq }; + } catch (error) { + return { status: "blocked", code: "runtime_checkpoint_rebase_failed", reason: String(error?.message ?? error) }; + } + } + exportTrace() { const latest = this.timeline.snapshots.latest(); if (!latest || latest.seq !== this.timeline.events.cursor || latest.tick !== this.timeline.events.tick) { @@ -946,6 +1100,9 @@ export class SimSession { code: "axis_gate", message_key: gate.reason, gate_reason: gate.reason, + reason: gate.reason, + error_code: "axis_gate", + source: "linuxcnc-wasm-worker", retryable: false, operation_id: input.operation_id ?? null, }); @@ -1067,6 +1224,8 @@ export class SimSession { case "POWER": { if (this.xyzacProgram) { const result = this.xyzacIo.command("POWER", input.payload ?? {}); + let recoveryCheckpoint = null; + let recoveryPersistence = { status: "skipped", reason: "power_on" }; if (result.status === "verified") { if (input.payload?.active === false) { /* LinuxCNC emcTaskSetState(OFF) aborts motion, clears the @@ -1084,9 +1243,17 @@ export class SimSession { if (["READY", "RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED"); } } catch { /* the WASM IO state remains authoritative */ } + if (input.payload?.active === false) { + try { + recoveryCheckpoint = this.createPowerOffCheckpoint(); + recoveryPersistence = { status: "checkpoint_ready", persistence: "browser_client_storage" }; + } catch (error) { + recoveryPersistence = { status: "blocked", code: "runtime_checkpoint_create_failed", reason: String(error?.message ?? error) }; + } + } if (this.xyzacDeviceIo) this.xyzacDeviceIo.command("INTERLOCK_RESET", input.payload ?? {}, input.operation_id); } - return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, command: input.kind, active: input.payload?.active === true, task: this.task.export(), device_io: this.xyzacIo.export(), operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed"); + return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, command: input.kind, active: input.payload?.active === true, task: this.task.export(), device_io: this.xyzacIo.export(), ...(recoveryCheckpoint ? { recovery_checkpoint: recoveryCheckpoint } : {}), recovery_persistence: recoveryPersistence, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed"); } if (input.payload?.active !== true) return finish(this.error(input, "power_state", "power_active_required"), "failed"); const result = this.xyzacIo ? this.xyzacIo.command("POWER", input.payload ?? {}) : this.devices.command("IO_RECOVER", input.payload ?? {}); @@ -1210,12 +1377,25 @@ export class SimSession { if (kinematics.status === "blocked") return finish(this.envelope("ERROR", input, { status: "blocked", code: kinematics.code, kinematics: this.authoritative.export() }), "failed"); } const loadResult = startupLoaded ? 0 : call(this.core, "sim_load_workspace", rootPath); + let powerOffRecovery = null; + if (loadResult === 0 && input.payload?.recovery_checkpoint && this.xyzacProgram) { + const restored = this.restorePersistedPowerOff(input.payload.recovery_checkpoint, input.seq); + if (restored.status !== "verified") return finish(this.envelope("ERROR", input, { + status: "blocked", + code: restored.code ?? "runtime_checkpoint_restore_failed", + message_key: restored.reason ?? restored.code ?? "runtime_checkpoint_restore_failed", + reason: restored.reason ?? restored.code ?? "runtime_checkpoint_restore_failed", + recovery: restored, + }), "failed"); + powerOffRecovery = restored; + } return loadResult === 0 ? finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion_authority: this.motionAuthority, state_hash: this.task.hash(), task: this.task.export(), + ...(powerOffRecovery ? { power_off_recovery: powerOffRecovery } : {}), ...(this.configurationState?.schema === "configuration-startup-runtime.v1" ? { configuration: this.configurationState } : {}), })) : finish(this.coreError(input, loadResult, "sim_load_workspace"), "failed"); } diff --git a/web/worker/test_xyzac_runtime_followups.mjs b/web/worker/test_xyzac_runtime_followups.mjs new file mode 100644 index 0000000..15b9db9 --- /dev/null +++ b/web/worker/test_xyzac_runtime_followups.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import createModule from "../../build-wasm/linuxcnc-sim-core.js"; +import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs"; +import { SimSession, CONTEXT_ID } from "./sim-session.mjs"; + +const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"; +const PROGRAM = "G21 G90\nG0 X0 Y0 Z10 A0 C0\nG1 X20 Y5 Z5 A30 C45 F100\nM2\n"; +const fetchImpl = async (url) => { + try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; } + catch { return { ok: false, text: async () => "" }; } +}; +const bundle = await loadSimulationCase({ path: CASE, fetchImpl }); +const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)); + +async function open(sessionId) { + const core = await createModule({ wasmBinary }); + const session = new SimSession(core, { sessionId }); + const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: sessionId }; + let seq = 0; + const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${sessionId}-${seq}`, payload }); + send("HELLO"); send("SESSION_ATTACH"); + assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified"); + return { session, send }; +} + +const first = await open("xyzac-followups-a"); +const blocked = first.send("MDI_EXECUTE", { block: "G0 X1" }); +assert.equal(blocked.kind, "ERROR"); +assert.equal(blocked.payload.code, "axis_gate"); +assert.equal(blocked.payload.reason, "xyzac_machine_off"); +assert.equal(blocked.payload.message_key, "xyzac_machine_off"); +first.send("POWER", { active: true }); +first.send("HOME", { all: true }); +first.send("PROGRAM_LOAD", { source: PROGRAM, path: "followups.ngc" }); +first.send("PROGRAM_RUN"); +const compact = first.send("SIM_STEP", { cycles: 1, response: "motion_delta" }); +assert.equal(compact.payload.snapshot_mode, "motion_delta"); +assert.equal(compact.payload.panel_snapshot, undefined); +assert.ok(compact.payload.motion_sample?.pose); +const full = first.send("SIM_STEP", { cycles: 1 }); +const compactBytes = Buffer.byteLength(JSON.stringify(compact)); +const fullBytes = Buffer.byteLength(JSON.stringify(full)); +assert.ok(compactBytes < fullBytes / 2, `${compactBytes} >= ${fullBytes / 2}`); +const off = first.send("POWER", { active: false }); +assert.equal(off.payload.recovery_checkpoint.schema, "xyzac-power-off-checkpoint.v1"); +assert.equal(off.payload.recovery_checkpoint.state.task.state, "STOPPED"); +assert.ok(Buffer.byteLength(JSON.stringify(off.payload.recovery_checkpoint)) < 512 * 1024); + +const second = await open("xyzac-followups-b"); +const restored = second.send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle), recovery_checkpoint: off.payload.recovery_checkpoint }); +assert.equal(restored.payload.power_off_recovery.status, "verified"); +assert.equal(restored.payload.task.state, "STOPPED"); +assert.equal(restored.payload.panel_snapshot.program.program_status, "loaded"); + +console.log(JSON.stringify({ + schema: "xyzac-runtime-followups.v1", + status: "verified", + motion_delta: { compact_bytes: compactBytes, full_bytes: fullBytes, reduction_ratio: Number((1 - compactBytes / fullBytes).toFixed(3)) }, + errors: { code: blocked.payload.code, reason: blocked.payload.reason, message_key: blocked.payload.message_key }, + power_off_persistence: { schema: off.payload.recovery_checkpoint.schema, bytes: Buffer.byteLength(JSON.stringify(off.payload.recovery_checkpoint)), restored: restored.payload.power_off_recovery.status }, +})); diff --git a/web/worker/wasm-motion-queue.mjs b/web/worker/wasm-motion-queue.mjs index 5d3545c..b6047fc 100644 --- a/web/worker/wasm-motion-queue.mjs +++ b/web/worker/wasm-motion-queue.mjs @@ -7,6 +7,10 @@ const QUEUE_HEADER_BYTES = 32; const STATE_POSITION_BYTES = 72; const MOTION_QUEUE_SCHEMA = "sim_motion_queue_state.v1"; const DEFAULT_CONTROLS = Object.freeze({ profile: 0, lookahead: 1, acceleration_limit: 1e9, jerk_limit: 1e12, paused: false }); +const MOTION_ERROR_REASONS = Object.freeze({ + [-600]: "wasm_motion_invalid_argument", [-601]: "wasm_motion_nonfinite_value", [-602]: "wasm_motion_limit_range", + [-603]: "wasm_motion_invalid_state", [-604]: "wasm_motion_queue_full", [-605]: "wasm_motion_paused", +}); function call(core, name, returnType, argTypes, args) { const fn = core[`_${name}`] ?? core[name]; @@ -101,7 +105,7 @@ export class WasmMotionQueue { // Keep the WASM switchkins mode in the tag field; the C queue assigns id. ints[this.command / 4 + SEGMENT_INT_OFFSET + 3] = Number(segment.kinematics_mode ?? segment.tag ?? 0); const result = call(this.core, "sim_motion_enqueue", "number", ["number", "number", "number"], [this.queue, this.command, 0]); - return result === 0 ? { status: "verified", source_line: segment.source_line ?? null } : { status: "error", code: `wasm_motion_enqueue_${result}` }; + return result === 0 ? { status: "verified", source_line: segment.source_line ?? null } : { status: "error", code: `wasm_motion_enqueue_${result}`, reason: MOTION_ERROR_REASONS[result] ?? `wasm_motion_enqueue_${result}`, message_key: MOTION_ERROR_REASONS[result] ?? `wasm_motion_enqueue_${result}`, source: "linuxcnc-wasm-motion" }; } reset() { @@ -183,7 +187,7 @@ export class WasmMotionQueue { if (!this.available) return { status: "error", code: "wasm_motion_step_-605" }; if (this.controls.paused) return { status: "error", code: "wasm_motion_step_-605" }; const result = call(this.core, "sim_motion_step", "number", ["number"], [this.queue]); - if (result !== 0) return { status: "error", code: `wasm_motion_step_${result}` }; + if (result !== 0) return { status: "error", code: `wasm_motion_step_${result}`, reason: MOTION_ERROR_REASONS[result] ?? `wasm_motion_step_${result}`, message_key: MOTION_ERROR_REASONS[result] ?? `wasm_motion_step_${result}`, source: "linuxcnc-wasm-motion" }; return this.read(); } diff --git a/web/worker/wasm-program-runtime.mjs b/web/worker/wasm-program-runtime.mjs index 91e27f3..a437ad2 100644 --- a/web/worker/wasm-program-runtime.mjs +++ b/web/worker/wasm-program-runtime.mjs @@ -5,6 +5,15 @@ const SEGMENT_BYTES = 216; const POSE_BYTES = 72; const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"]; const DEFAULT_RAPID_VELOCITY = 1000; +const PROGRAM_ERROR_REASONS = Object.freeze({ + [-1100]: "wasm_program_invalid_argument", [-1101]: "wasm_program_range_error", [-1102]: "wasm_program_invalid_state", + [-1103]: "wasm_program_control_error", [-1104]: "wasm_program_interpreter_error", [-1105]: "wasm_program_motion_error", +}); + +function programError(errorPrefix, result) { + const reason = PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`; + return { status: "error", code: `wasm_${errorPrefix}_${result}`, result, reason, message_key: reason, source: "linuxcnc-wasm-sim_program" }; +} function call(core, name, returnType, argTypes, args) { const fn = core?.[`_${name}`] ?? core?.[name]; @@ -69,7 +78,7 @@ export class WasmProgramRuntime { invoke(name, args = [], errorPrefix = name) { if (!this.available) return { status: "blocked", code: "wasm_program_unavailable" }; const result = call(this.core, name, "number", ["number", ...args.map(() => "number")], [this.pointer, ...args]); - return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${errorPrefix}_${result}`, result }; + return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${errorPrefix}_${result}`, result, reason: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, message_key: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, source: "linuxcnc-wasm-sim_program" }; } load(source, path = null) { @@ -79,7 +88,7 @@ export class WasmProgramRuntime { this.core.HEAPU8.set(bytes, pointer); try { const result = call(this.core, "sim_program_load", "number", ["number", "number", "number"], [this.pointer, pointer, bytes.byteLength]); - if (result !== 0) return { status: "error", code: `wasm_program_load_${result}` }; + if (result !== 0) return programError("program_load", result); const velocityResult = call(this.core, "sim_program_set_rapid_velocity", "number", ["number", "number"], [this.pointer, this.rapidVelocity]); if (velocityResult !== 0) return { status: "error", code: `wasm_program_rapid_velocity_${velocityResult}` }; this.source = source; @@ -191,7 +200,7 @@ export class WasmProgramRuntime { this.core.HEAPU8.set(bytes, pointer); try { const result = call(this.core, "sim_program_execute_block", "number", ["number", "number", "number"], [this.pointer, pointer, bytes.byteLength]); - if (result !== 0) return { status: "error", code: `wasm_program_execute_${result}` }; + if (result !== 0) return programError("program_execute", result); return { status: "verified", program: this.snapshot(), motion: this.readMotion() }; } finally { this.core._free(pointer); } } diff --git a/web/worker/wasm-xyzac-device-io.mjs b/web/worker/wasm-xyzac-device-io.mjs index 4c6afae..5658f33 100644 --- a/web/worker/wasm-xyzac-device-io.mjs +++ b/web/worker/wasm-xyzac-device-io.mjs @@ -1,6 +1,11 @@ import { fnv1a64 } from "./replay.mjs"; const DEVICE_SCHEMA = "wasm_xyzac_device_io.v1"; +const DEVICE_ERROR_REASONS = Object.freeze({ + [-1000]: "wasm_device_invalid_argument", [-1001]: "wasm_device_invalid_state", [-1002]: "wasm_device_range_error", + [-1003]: "wasm_device_estop_active", [-1004]: "wasm_device_interlock_open", [-1005]: "wasm_device_fault_active", + [-1006]: "wasm_device_ack_missing", [-1007]: "wasm_device_ack_timeout", [-1008]: "wasm_device_ack_invalid", +}); function call(core, name, returnType, argTypes, args) { const fn = core?.[`_${name}`] ?? core?.[name]; @@ -52,7 +57,8 @@ export class WasmXyzacDeviceIo { invoke(name, args = []) { if (!this.available) return { status: "blocked", code: "wasm_xyzac_device_io_unavailable" }; const result = call(this.core, name, "number", ["number", ...args.map(() => "number")], [this.pointer, ...args]); - return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${name}_${result}`, result }; + const reason = DEVICE_ERROR_REASONS[result] ?? `wasm_${name}_${result}`; + return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${name}_${result}`, result, reason, message_key: reason, source: "linuxcnc-wasm-device-io" }; } canonical() { diff --git a/web/worker/wasm-xyzac-io.mjs b/web/worker/wasm-xyzac-io.mjs index 4c6e63b..ded5734 100644 --- a/web/worker/wasm-xyzac-io.mjs +++ b/web/worker/wasm-xyzac-io.mjs @@ -4,6 +4,13 @@ const IO_SCHEMA = "wasm_xyzac_io.v1"; const HAL_SCHEMA = "wasm_xyzac_hal.v1"; const POSE_BYTES = 72; const HAL_WRITE_ALLOWLIST = new Set(["motion.probe-input", "iocontrol.0.tool-prepared", "iocontrol.0.tool-changed", "spindle.0.speed-in", "spindle.0.at-speed"]); +const IO_ERROR_REASONS = Object.freeze({ + [-900]: "wasm_io_invalid_argument", [-901]: "wasm_io_range_error", [-902]: "wasm_io_invalid_state", + [-903]: "wasm_io_power_required", [-904]: "wasm_io_estop_active", [-905]: "wasm_io_fault_active", + [-906]: "wasm_io_limit_active", [-907]: "wasm_io_nonfinite_value", [-908]: "wasm_io_hal_pin_error", + [-909]: "wasm_io_capacity_exceeded", [-910]: "wasm_io_busy", [-911]: "wasm_io_probe_error", + [-912]: "wasm_io_timeout", [-913]: "wasm_io_recovery_required", +}); function call(core, name, returnType, argTypes, args) { const fn = core?.[`_${name}`] ?? core?.[name]; @@ -59,7 +66,7 @@ export class WasmXyzacIo { if (!this.available) return { status: "blocked", code: "wasm_xyzac_io_unavailable" }; const result = call(this.core, name, "number", ["number", ...args.map(() => "number")], [this.pointer, ...args]); this.lastError = result; - return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${name}_${result}`, result }; + return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${name}_${result}`, result, reason: IO_ERROR_REASONS[result] ?? `wasm_${name}_${result}`, message_key: IO_ERROR_REASONS[result] ?? `wasm_${name}_${result}`, source: "linuxcnc-wasm-io" }; } canonical() {