Optimize XYZAC motion updates and persist power-off recovery

This commit is contained in:
2026-08-29 04:52:44 +08:00
parent a97971ea19
commit f6e545018e
17 changed files with 479 additions and 74 deletions

View File

@@ -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");
}