接入L4-TOOL-DB节点运行时
结论:ToolDbProcessPort 已可通过 Node/native adapter 真实执行 LinuxCNC DB_PROGRAM 协议,浏览器端仍保持锁定直到 Python/WASM Worker 接入。
This commit is contained in:
@@ -569,6 +569,12 @@ private module paths:
|
||||
LinuxCNC-owned tool DB process port contract. Contract-only mode does not run
|
||||
`DB_PROGRAM`, does not parse `.tbl` as a fallback, and keeps execution and
|
||||
promotion disabled.
|
||||
- `createLinuxCncToolDbNodeRuntimeAdapter()` in
|
||||
`runtime/sdk/src/tool-db-node-runtime-adapter.js` for Node/native verification
|
||||
of the real LinuxCNC `DB_PROGRAM` child process. This adapter is intentionally
|
||||
not exported from the browser-facing SDK index because it depends on Node
|
||||
process APIs; browser runtime remains locked until a Python/WASM Worker runs
|
||||
the DB program.
|
||||
- `saveToolDbFile()`, `loadToolDbFile()`, `saveToolDbTranscript()`, and
|
||||
`loadToolDbTranscript()` for OPFS persistence of the DB flat file and protocol
|
||||
transcript artifacts.
|
||||
|
||||
160
wasm-port/runtime/sdk/src/tool-db-node-runtime-adapter.js
Normal file
160
wasm-port/runtime/sdk/src/tool-db-node-runtime-adapter.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
function requireString(value, label) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`${label} must be a non-empty string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function defaultRepoRoot() {
|
||||
return fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
}
|
||||
|
||||
function appendPathEnv(current, next) {
|
||||
return current ? `${next}:${current}` : next;
|
||||
}
|
||||
|
||||
export function createLinuxCncToolDbNodeRuntimeAdapter({
|
||||
repoRoot = defaultRepoRoot(),
|
||||
linuxCncRoot = join(repoRoot, "linuxcnc"),
|
||||
dbProgramPath = join(linuxCncRoot, "configs/sim/axis/db_demo/db_nonran.py"),
|
||||
dbProgramCwd = dirname(dbProgramPath),
|
||||
dbSavefile = join(repoRoot, "wasm-port/build/native/tool-db-runtime/sdk-node-db-nonran"),
|
||||
stdoutLog = join(repoRoot, "wasm-port/build/native/tool-db-runtime/sdk-node-db-program.stdout.log"),
|
||||
stderrLog = join(repoRoot, "wasm-port/build/native/tool-db-runtime/sdk-node-db-program.stderr.log"),
|
||||
periodMinutes = 999,
|
||||
} = {}) {
|
||||
requireString(repoRoot, "repoRoot");
|
||||
requireString(linuxCncRoot, "linuxCncRoot");
|
||||
requireString(dbProgramPath, "dbProgramPath");
|
||||
requireString(dbProgramCwd, "dbProgramCwd");
|
||||
requireString(dbSavefile, "dbSavefile");
|
||||
|
||||
let proc = null;
|
||||
let stdoutFile = null;
|
||||
let stderrFile = null;
|
||||
let stdoutBuffer = "";
|
||||
const lineQueue = [];
|
||||
const waiters = [];
|
||||
let closed = false;
|
||||
|
||||
function flushWaiters() {
|
||||
while (lineQueue.length > 0 && waiters.length > 0) {
|
||||
const waiter = waiters.shift();
|
||||
waiter.resolve(lineQueue.shift());
|
||||
}
|
||||
}
|
||||
|
||||
function failWaiters(error) {
|
||||
while (waiters.length > 0) {
|
||||
const waiter = waiters.shift();
|
||||
waiter.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
function pushStdout(chunk) {
|
||||
stdoutBuffer += chunk.toString("utf8");
|
||||
let newlineIndex = stdoutBuffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
||||
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
||||
lineQueue.push(line);
|
||||
newlineIndex = stdoutBuffer.indexOf("\n");
|
||||
}
|
||||
flushWaiters();
|
||||
}
|
||||
|
||||
return {
|
||||
runtimeMode: "node-linuxcnc-db-program",
|
||||
dbSavefile,
|
||||
stdoutLog,
|
||||
stderrLog,
|
||||
|
||||
async start() {
|
||||
await mkdir(dirname(dbSavefile), { recursive: true });
|
||||
await rm(dbSavefile, { force: true });
|
||||
stdoutFile = createWriteStream(stdoutLog, { encoding: "utf8" });
|
||||
stderrFile = createWriteStream(stderrLog, { encoding: "utf8" });
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PYTHONPATH: appendPathEnv(process.env.PYTHONPATH, join(linuxCncRoot, "lib/python")),
|
||||
LD_LIBRARY_PATH: appendPathEnv(process.env.LD_LIBRARY_PATH, join(linuxCncRoot, "lib")),
|
||||
};
|
||||
|
||||
proc = spawn(dbProgramPath, [`--period_minutes=${periodMinutes}`, dbSavefile], {
|
||||
cwd: dbProgramCwd,
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
proc.stdout.on("data", (chunk) => {
|
||||
stdoutFile.write(chunk);
|
||||
pushStdout(chunk);
|
||||
});
|
||||
proc.stderr.on("data", (chunk) => stderrFile.write(chunk));
|
||||
proc.on("error", (error) => failWaiters(error));
|
||||
proc.on("exit", (code) => {
|
||||
if (!closed && code !== 0) {
|
||||
failWaiters(new Error(`DB_PROGRAM exited with code ${code}.`));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async writeLine(line) {
|
||||
if (!proc?.stdin || proc.stdin.destroyed) {
|
||||
throw new Error("DB_PROGRAM stdin is not writable.");
|
||||
}
|
||||
proc.stdin.write(`${line}\n`);
|
||||
},
|
||||
|
||||
async readLine({ timeoutMs = 5000 } = {}) {
|
||||
if (lineQueue.length > 0) {
|
||||
return lineQueue.shift();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let waiter;
|
||||
const timer = setTimeout(() => {
|
||||
const index = waiters.indexOf(waiter);
|
||||
if (index !== -1) {
|
||||
waiters.splice(index, 1);
|
||||
}
|
||||
reject(new Error("timeout waiting for DB_PROGRAM output."));
|
||||
}, timeoutMs);
|
||||
waiter = {
|
||||
resolve: (line) => {
|
||||
clearTimeout(timer);
|
||||
resolve(line);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
};
|
||||
waiters.push(waiter);
|
||||
});
|
||||
},
|
||||
|
||||
async close() {
|
||||
closed = true;
|
||||
try {
|
||||
proc?.stdin?.end();
|
||||
} catch {
|
||||
// ignore close races
|
||||
}
|
||||
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
||||
await new Promise((resolve) => {
|
||||
proc.once("exit", resolve);
|
||||
proc.kill("SIGTERM");
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
}
|
||||
stdoutFile?.end();
|
||||
stderrFile?.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -207,8 +207,18 @@ export function createLinuxCncToolDbProcessPort({
|
||||
for (const step of plan) {
|
||||
if (step.direction === "write") {
|
||||
await this.writeLine(step.line);
|
||||
if (step.requiresFini) {
|
||||
let finiSeen = false;
|
||||
while (!finiSeen) {
|
||||
const reply = await this.readLine();
|
||||
finiSeen = reply.startsWith("FINI");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await this.readLine();
|
||||
const line = await this.readLine();
|
||||
if (step.expect && line !== step.expect) {
|
||||
throw new Error(`ToolDbProcessPort ${step.phase} expected ${step.expect}, got ${line}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -226,9 +236,9 @@ export function createLinuxCncToolDbProcessPort({
|
||||
return transcript.map((entry) => ({ ...entry }));
|
||||
},
|
||||
|
||||
close() {
|
||||
async close() {
|
||||
if (runtimeAdapter?.close) {
|
||||
runtimeAdapter.close();
|
||||
await runtimeAdapter.close();
|
||||
}
|
||||
closed = true;
|
||||
return {
|
||||
|
||||
@@ -35,6 +35,17 @@ assert.equal(jsonWorkflow.report.reportVersion, 1);
|
||||
assert.equal(jsonWorkflow.report.ready, true);
|
||||
assert.equal(jsonWorkflow.report.capabilityMatrix.ready, true);
|
||||
assert.deepEqual(jsonWorkflow.report.capabilityMatrix.capabilityTypes, ["workflow", "api", "gate"]);
|
||||
assert.equal(jsonWorkflow.report.capabilityMatrix.capabilityCount, 5);
|
||||
assert.equal(jsonWorkflow.report.capabilityMatrix.acceptedCount, 5);
|
||||
assert.equal(
|
||||
jsonWorkflow.report.capabilityMatrix.capabilities.some(
|
||||
(capability) =>
|
||||
capability.id === "tool-db-node-runtime-adapter" &&
|
||||
capability.evidence === "tool_db_node_runtime_adapter=ok" &&
|
||||
capability.command === "wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.sh",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(jsonWorkflow.report.workflow.ready, true);
|
||||
assert.equal(jsonWorkflow.report.workflow.virtualHalSimConfigSourceCoverageReady, true);
|
||||
assert.equal(jsonWorkflow.report.workflow.virtualHalMotionControllerMatrixReady, true);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
createLinuxCncToolDbProcessPort,
|
||||
createToolDbProcessDiagnostics,
|
||||
validateToolDbTranscript,
|
||||
} from "../../../runtime/sdk/src/tool-db-process-port.js";
|
||||
import {
|
||||
createLinuxCncToolDbNodeRuntimeAdapter,
|
||||
} from "../../../runtime/sdk/src/tool-db-node-runtime-adapter.js";
|
||||
|
||||
const adapter = createLinuxCncToolDbNodeRuntimeAdapter({
|
||||
dbSavefile: resolve("wasm-port/build/native/tool-db-runtime/sdk-node-runtime-adapter-db-nonran"),
|
||||
stdoutLog: resolve("wasm-port/build/native/tool-db-runtime/sdk-node-runtime-adapter.stdout.log"),
|
||||
stderrLog: resolve("wasm-port/build/native/tool-db-runtime/sdk-node-runtime-adapter.stderr.log"),
|
||||
});
|
||||
|
||||
const port = createLinuxCncToolDbProcessPort({
|
||||
dbProgramPath: "./db_nonran.py",
|
||||
sourceFiles: [
|
||||
"configs/sim/axis/db_demo/db_nonran.py",
|
||||
"configs/sim/axis/db_demo/db.py",
|
||||
"lib/python/tooldb.py",
|
||||
],
|
||||
runtimeMode: adapter.runtimeMode,
|
||||
runtimeAdapter: adapter,
|
||||
});
|
||||
|
||||
const startResult = await port.start();
|
||||
assert.equal(startResult.runtimeMode, "node-linuxcnc-db-program");
|
||||
assert.equal(startResult.runtimeExecutionReady, true);
|
||||
assert.equal(startResult.executionEnabled, false);
|
||||
assert.equal(startResult.promotionAllowed, false);
|
||||
|
||||
const planResult = await port.runTransactionPlan();
|
||||
assert.equal(planResult.status, "runtime_adapter_transaction_plan_executed");
|
||||
assert.equal(planResult.runtimeExecutionReady, true);
|
||||
assert.equal(planResult.executionEnabled, false);
|
||||
assert.equal(planResult.promotionAllowed, false);
|
||||
assert.equal(planResult.tblFallbackSufficient, false);
|
||||
|
||||
await port.writeLine("t 14");
|
||||
const t14AfterUnload = await port.readLine();
|
||||
assert.match(t14AfterUnload, /^T14 /);
|
||||
assert.match(t14AfterUnload, /P114\b/);
|
||||
|
||||
const transcript = port.exportTranscript();
|
||||
const validation = validateToolDbTranscript(transcript);
|
||||
assert.equal(validation.ready, true);
|
||||
assert.equal(transcript.some((entry) => entry.line === "v2.1"), true);
|
||||
assert.equal(transcript.some((entry) => entry.line.startsWith("T10 ") && entry.line.includes("P110")), true);
|
||||
assert.equal(transcript.some((entry) => entry.line.startsWith("FINI")), true);
|
||||
|
||||
await port.close();
|
||||
|
||||
const dbFileText = readFileSync(adapter.dbSavefile, "utf8");
|
||||
assert.match(dbFileText, /T11 P111 D0.33 Z0.11/);
|
||||
assert.match(dbFileText, /T14 P114\b/);
|
||||
|
||||
const diagnostics = createToolDbProcessDiagnostics({
|
||||
dbProgramPath: "./db_nonran.py",
|
||||
opfsDbPath: "/machines/db-demo/tool-db/db_nonran_file",
|
||||
transcript,
|
||||
dbFileText,
|
||||
startupToolCount: 10,
|
||||
mutationCount: 3,
|
||||
runtimeMode: adapter.runtimeMode,
|
||||
runtimeExecutionReady: true,
|
||||
});
|
||||
assert.equal(diagnostics.runtimeMode, "node-linuxcnc-db-program");
|
||||
assert.equal(diagnostics.runtimeExecutionReady, true);
|
||||
assert.equal(diagnostics.protocolTranscriptReady, true);
|
||||
assert.equal(diagnostics.opfsPersistenceReady, true);
|
||||
assert.equal(diagnostics.tblFallbackSufficient, false);
|
||||
assert.equal(diagnostics.executionEnabled, false);
|
||||
assert.equal(diagnostics.promotionAllowed, false);
|
||||
|
||||
console.log("tool_db_node_runtime_adapter=ok");
|
||||
6
wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.sh
Executable file
6
wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
|
||||
node "$ROOT_DIR/tests/sdk/node/verify_tool_db_node_runtime_adapter.mjs"
|
||||
@@ -91,6 +91,6 @@ assert.equal(diagnostics.promotionAllowed, false);
|
||||
assert.match(diagnostics.transcriptHash, /^[0-9a-f]{8}$/);
|
||||
assert.match(diagnostics.dbFileHash, /^[0-9a-f]{8}$/);
|
||||
|
||||
assert.equal(port.close().promotionAllowed, false);
|
||||
assert.equal((await port.close()).promotionAllowed, false);
|
||||
|
||||
console.log("tool_db_process_port_sdk=ok");
|
||||
|
||||
@@ -44,8 +44,18 @@ const report = createProjectBatchAcceptanceReport({
|
||||
evidence: "project_batch_acceptance_workflow_node_smoke=ok",
|
||||
command: "wasm-port/tests/sdk/node/verify_project_batch_acceptance_workflow.sh",
|
||||
},
|
||||
{
|
||||
id: "tool-db-node-runtime-adapter",
|
||||
type: "gate",
|
||||
label: "L4-TOOL-DB Node/native DB_PROGRAM runtime",
|
||||
evidence: "tool_db_node_runtime_adapter=ok",
|
||||
command: "wasm-port/tests/sdk/node/verify_tool_db_node_runtime_adapter.sh",
|
||||
},
|
||||
],
|
||||
observedOutputs: [
|
||||
"project_batch_acceptance_workflow_node_smoke=ok",
|
||||
"tool_db_node_runtime_adapter=ok",
|
||||
],
|
||||
observedOutputs: ["project_batch_acceptance_workflow_node_smoke=ok"],
|
||||
virtualHalSimConfigSourceCoverage: createVirtualHalSimConfigSourceCoverageReport({
|
||||
manifestText: sourceManifestText,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user