Files
linuxcncWebWASM/web/worker/python-callbacks.mjs

106 lines
7.1 KiB
JavaScript

import { fnv1a64 } from "./replay.mjs";
export const CALLBACK_SCHEMA = "python-callbacks.v1";
const MAX_CALLBACKS = 256;
const MAX_SOURCE_BYTES = 65536;
function clone(value) { return structuredClone(value); }
function validName(value) { return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(value); }
function finiteJson(value) {
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(finiteJson);
if (value && typeof value === "object") return Object.values(value).every(finiteJson);
return true;
}
function typedValue(type, value = 0) {
if (type === "bit") { if (![0, 1, false, true].includes(value)) throw new Error("vcp_bit_range"); return value ? 1 : 0; }
if (type === "s32") { if (!Number.isSafeInteger(value) || value < -2147483648 || value > 2147483647) throw new Error("vcp_s32_range"); return value; }
if (type === "u32") { if (!Number.isSafeInteger(value) || value < 0 || value > 4294967295) throw new Error("vcp_u32_range"); return value; }
if (type === "float" && typeof value === "number" && Number.isFinite(value)) return value;
throw new Error("vcp_value_type");
}
export class PythonCallbacks {
constructor({ maxCallbacks = MAX_CALLBACKS } = {}) { this.maxCallbacks = maxCallbacks; this.reset(); }
reset() { this.state = { schema: CALLBACK_SCHEMA, tick: 0, remaps: {}, vcp: {}, filters: {}, calls: [], errors: [], reentrant: false }; this.handlers = new Map(); return this.export(); }
export() { const value = clone(this.state); return { ...value, state_hash: fnv1a64(value) }; }
hash() { return this.export().state_hash; }
fail(code, detail = null) { this.state.errors.push({ code, tick: this.state.tick }); return { status: "blocked", code, ...(detail ? { detail } : {}), state_hash: this.hash() }; }
setTick(tick) { if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("callback_clock_regression"); this.state.tick = tick; return { status: "verified", state_hash: this.hash() }; }
registerRemap(name, descriptor = {}, handler = null) {
const { prolog = null, epilog = null, ngc = null, python = null, modalgroup = null } = descriptor ?? {};
if (!validName(name) || (prolog !== null && !validName(prolog)) || (epilog !== null && !validName(epilog)) || (ngc !== null && !validName(ngc)) || (python !== null && !validName(python)) || (modalgroup !== null && (!Number.isSafeInteger(modalgroup) || modalgroup < 0))) return this.fail("remap_descriptor_invalid");
if (this.state.remaps[name]) return this.fail("remap_duplicate");
this.state.remaps[name] = { name, prolog, epilog, ngc, python, modalgroup, calls: 0, failures: 0 };
if (typeof handler === "function") this.handlers.set(`remap:${name}`, handler);
else if (handler && typeof handler === "object") for (const phase of ["prolog", "epilog"]) if (typeof handler[phase] === "function") this.handlers.set(`remap:${name}:${phase}`, handler[phase]);
return { status: "verified", descriptor: clone(this.state.remaps[name]), state_hash: this.hash() };
}
registerVcp(name, pins = [], handler = null) {
if (!validName(name) || !Array.isArray(pins) || pins.length > 256 || this.state.vcp[name] || pins.some((pin) => !validName(pin?.name) || !["bit", "s32", "u32", "float"].includes(pin?.type))) return this.fail(this.state.vcp[name] ? "vcp_duplicate" : "vcp_descriptor_invalid");
try {
this.state.vcp[name] = { name, pins: pins.map((pin) => ({ name: pin.name, type: pin.type, value: typedValue(pin.type, pin.value ?? 0) })), calls: 0, failures: 0 };
} catch (error) { return this.fail("vcp_descriptor_invalid", String(error.message)); }
if (typeof handler === "function") this.handlers.set(`vcp:${name}`, handler);
return { status: "verified", descriptor: clone(this.state.vcp[name]), state_hash: this.hash() };
}
registerFilter(name, handler = null) {
if (!validName(name)) return this.fail("filter_name_invalid");
if (this.state.filters[name]) return this.fail("filter_duplicate");
this.state.filters[name] = { name, calls: 0, failures: 0 };
if (typeof handler === "function") this.handlers.set(`filter:${name}`, handler);
return { status: "verified", descriptor: clone(this.state.filters[name]), state_hash: this.hash() };
}
invoke(kind, name, phase = "prolog", args = []) {
if (this.state.calls.length >= this.maxCallbacks) return this.fail("callback_budget");
if (!Array.isArray(args) || !finiteJson(args)) return this.fail("callback_arguments_invalid");
if (kind === "remap" && !["prolog", "epilog"].includes(phase)) return this.fail("remap_phase_invalid");
const key = `${kind}:${name}`;
const descriptor = kind === "remap" ? this.state.remaps[name] : kind === "vcp" ? this.state.vcp[name] : this.state.filters[name];
if (!descriptor) return this.fail("callback_missing");
if (kind === "remap" && descriptor[phase] === null) return this.fail("remap_phase_unsupported");
if (this.state.reentrant) return this.fail("callback_reentrant");
const handler = this.handlers.get(kind === "remap" ? `${key}:${phase}` : key) ?? this.handlers.get(key);
this.state.reentrant = true;
try {
const value = handler ? handler({ phase, args: clone(args), tick: this.state.tick }) : null;
descriptor.calls += 1; this.state.calls.push({ kind, name, phase, tick: this.state.tick });
return { status: "verified", value: value ?? null, state_hash: this.hash() };
} catch (error) {
descriptor.failures += 1; this.state.errors.push({ code: "callback_exception", tick: this.state.tick });
return { status: "blocked", code: "callback_exception", detail: String(error?.message ?? error), state_hash: this.hash() };
} finally { this.state.reentrant = false; }
}
filter(name, source, args = []) {
if (typeof source !== "string" || new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) return this.fail("filter_source_limit");
if (/\b(?:subprocess|socket|os\.system|device_paths|arbitrary_host_paths)\b/.test(source)) return this.fail("real_hardware_excluded");
if (!Array.isArray(args) || !finiteJson(args)) return this.fail("filter_arguments_invalid");
return this.invoke("filter", name, "filter", [source, ...args]);
}
updateVcp(name, pinName, value) {
const descriptor = this.state.vcp[name];
const pin = descriptor?.pins.find((item) => item.name === pinName);
if (!pin) return this.fail("vcp_pin_missing");
let normalized;
try { normalized = typedValue(pin.type, value); } catch (error) { return this.fail("vcp_value_invalid", String(error.message)); }
const previous = pin.value;
pin.value = normalized;
const result = this.invoke("vcp", name, "pin", [pinName, normalized]);
if (result.status !== "verified") pin.value = previous;
return result;
}
restore(value) {
if (!value || value.schema !== CALLBACK_SCHEMA) throw new Error("callback_restore_schema");
const unsigned = { ...value }; delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("callback_restore_hash");
this.state = clone(unsigned); this.handlers = new Map(); return this.export();
}
}