Source commit: a43e907efd9d1561c23fe73499478e881f868355 Public tree identity: sha256:453dea30195485dd8939f575a69b39f4bb7acd84c4df23f8aa29b55d044f2673
1346 lines
47 KiB
JavaScript
1346 lines
47 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const assert = require("assert");
|
|
const cp = require("child_process");
|
|
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const net = require("net");
|
|
const path = require("path");
|
|
const { coordinatorWireRequest } = require("./coordinator-wire");
|
|
const { nodeIdentity } = require("./node-signing");
|
|
const { configurePodmanTestEnvironment } = require("./podman-test-env");
|
|
|
|
const repo = path.resolve(__dirname, "..");
|
|
const project = path.join(repo, "examples/launch-build-demo");
|
|
const bundleDirectory = path.join(repo, "target/acceptance/sdk-runtime-bundle");
|
|
const editedBundleDirectory = path.join(
|
|
repo,
|
|
"target/acceptance/sdk-runtime-edited-bundle"
|
|
);
|
|
const incompatibleBundleDirectory = path.join(
|
|
repo,
|
|
"target/acceptance/sdk-runtime-incompatible-bundle"
|
|
);
|
|
const wasmPath = path.join(bundleDirectory, "module.wasm");
|
|
const projectSourcePath = path.join(project, "src/build.rs");
|
|
const node = "wasmtime-assignment-node";
|
|
const identity = nodeIdentity("wasmtime-assignment-smoke", node);
|
|
const trapNode = "wasmtime-trap-node";
|
|
const trapIdentity = nodeIdentity("wasmtime-assignment-smoke", trapNode);
|
|
const clientSessionSecret = "wasmtime-assignment-client-session-secret";
|
|
|
|
class JsonLineReader {
|
|
constructor(stream) {
|
|
this.buffer = "";
|
|
this.messages = [];
|
|
this.waiters = [];
|
|
stream.on("data", (chunk) => {
|
|
this.buffer += chunk.toString();
|
|
while (this.buffer.includes("\n")) {
|
|
const newline = this.buffer.indexOf("\n");
|
|
const line = this.buffer.slice(0, newline).trim();
|
|
this.buffer = this.buffer.slice(newline + 1);
|
|
if (line) this.messages.push(JSON.parse(line));
|
|
}
|
|
this.flush();
|
|
});
|
|
}
|
|
|
|
next(timeoutMs = 240000, label = "JSON line") {
|
|
if (this.messages.length > 0) return Promise.resolve(this.messages.shift());
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.waiters = this.waiters.filter((waiter) => waiter.resolve !== resolve);
|
|
reject(new Error(`timed out waiting for ${label}`));
|
|
}, timeoutMs);
|
|
this.waiters.push({ resolve, timer });
|
|
});
|
|
}
|
|
|
|
flush() {
|
|
while (this.messages.length > 0 && this.waiters.length > 0) {
|
|
const waiter = this.waiters.shift();
|
|
clearTimeout(waiter.timer);
|
|
waiter.resolve(this.messages.shift());
|
|
}
|
|
}
|
|
}
|
|
|
|
function send(addr, message) {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = net.connect(addr.port, addr.host, () => {
|
|
socket.write(`${JSON.stringify(coordinatorWireRequest(message))}\n`);
|
|
});
|
|
let buffer = "";
|
|
const timer = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error(`timed out waiting for ${message.type}`));
|
|
}, 30000);
|
|
socket.on("data", (chunk) => {
|
|
buffer += chunk.toString();
|
|
const newline = buffer.indexOf("\n");
|
|
if (newline < 0) return;
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
try {
|
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
socket.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
function authenticated(request) {
|
|
return {
|
|
type: "authenticated",
|
|
session_secret: clientSessionSecret,
|
|
request,
|
|
};
|
|
}
|
|
|
|
function stopChild(child) {
|
|
if (!child || child.exitCode !== null) return;
|
|
child.kill("SIGTERM");
|
|
}
|
|
|
|
function delay(milliseconds) {
|
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
}
|
|
|
|
function commandWithPodman(program, args) {
|
|
if (cp.spawnSync("podman", ["--version"], { stdio: "ignore" }).status === 0) {
|
|
return { program, args };
|
|
}
|
|
if (cp.spawnSync("nix", ["--version"], { stdio: "ignore" }).status === 0) {
|
|
return {
|
|
program: "nix",
|
|
args: ["shell", "nixpkgs#podman", "--command", program, ...args],
|
|
};
|
|
}
|
|
throw new Error(
|
|
"Wasmtime assignment smoke requires rootless Podman (or Nix to provide it)"
|
|
);
|
|
}
|
|
|
|
function procChildren(pid) {
|
|
try {
|
|
return fs
|
|
.readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.map(Number);
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function procDescendants(rootPid) {
|
|
const pending = [rootPid];
|
|
const descendants = [];
|
|
while (pending.length > 0) {
|
|
const parent = pending.shift();
|
|
for (const child of procChildren(parent)) {
|
|
descendants.push(child);
|
|
pending.push(child);
|
|
}
|
|
}
|
|
return descendants;
|
|
}
|
|
|
|
function procCommand(pid) {
|
|
try {
|
|
return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replaceAll("\0", " ");
|
|
} catch (_) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function procState(pid) {
|
|
const status = fs.readFileSync(`/proc/${pid}/status`, "utf8");
|
|
return status.match(/^State:\s+([^\n]+)$/m)?.[1] || "unknown";
|
|
}
|
|
|
|
async function waitForNativeSleep(rootPid) {
|
|
for (let attempt = 0; attempt < 2400; attempt += 1) {
|
|
const sleep = procDescendants(rootPid).find((pid) =>
|
|
/(?:^|\s)sleep\s+30(?:\s|$)/.test(procCommand(pid))
|
|
);
|
|
if (sleep) return sleep;
|
|
await delay(25);
|
|
}
|
|
throw new Error(`worker ${rootPid} did not start the abort probe's native sleep process`);
|
|
}
|
|
|
|
async function waitForDebugEpochState(addr, process, epoch, field) {
|
|
for (let attempt = 0; attempt < 1200; attempt += 1) {
|
|
const status = await send(addr, authenticated({
|
|
type: "inspect_debug_epoch",
|
|
process,
|
|
epoch,
|
|
}));
|
|
assert.strictEqual(status.type, "debug_epoch_status", JSON.stringify(status));
|
|
if (status.failed) {
|
|
throw new Error(
|
|
`debug epoch ${epoch} failed: ${status.failure_messages.join("; ")}`
|
|
);
|
|
}
|
|
if (status[field]) return status;
|
|
await delay(25);
|
|
}
|
|
throw new Error(`timed out waiting for debug epoch ${epoch} ${field}`);
|
|
}
|
|
|
|
async function waitForBreakpointHit(addr, process) {
|
|
for (let attempt = 0; attempt < 1200; attempt += 1) {
|
|
const status = await send(addr, authenticated({
|
|
type: "inspect_debug_breakpoints",
|
|
process,
|
|
}));
|
|
assert.strictEqual(status.type, "debug_breakpoints", JSON.stringify(status));
|
|
if (status.hit_epoch != null) return status;
|
|
await delay(25);
|
|
}
|
|
throw new Error(`timed out waiting for executing Wasm breakpoint in ${process}`);
|
|
}
|
|
|
|
async function waitForRunningTaskInstances(addr, process, taskInstances) {
|
|
for (let attempt = 0; attempt < 1200; attempt += 1) {
|
|
const response = await send(addr, authenticated({
|
|
type: "list_task_snapshots",
|
|
process,
|
|
}));
|
|
assert.strictEqual(response.type, "task_snapshots", JSON.stringify(response));
|
|
const running = new Set(
|
|
response.snapshots
|
|
.filter((snapshot) => snapshot.current && snapshot.state === "running")
|
|
.map((snapshot) => snapshot.task)
|
|
);
|
|
if (taskInstances.every((task) => running.has(task))) return;
|
|
await delay(50);
|
|
}
|
|
throw new Error(
|
|
`timed out waiting for running task instances ${taskInstances.join(", ")} in ${process}`
|
|
);
|
|
}
|
|
|
|
async function waitForTaskJoin(addr, process, task, timeoutMs = 60000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const response = await send(addr, authenticated({
|
|
type: "join_task",
|
|
process,
|
|
task,
|
|
}));
|
|
assert.strictEqual(response.type, "task_joined", JSON.stringify(response));
|
|
if (response.join.state !== "pending") return response;
|
|
await delay(25);
|
|
}
|
|
throw new Error(`timed out waiting to join task ${task} in ${process}`);
|
|
}
|
|
|
|
(async () => {
|
|
configurePodmanTestEnvironment(repo);
|
|
const build = JSON.parse(
|
|
cp.execFileSync(
|
|
"cargo",
|
|
[
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"clusterflux-cli",
|
|
"--bin",
|
|
"clusterflux",
|
|
"--",
|
|
"build",
|
|
"--project",
|
|
project,
|
|
"--output",
|
|
bundleDirectory,
|
|
"--json",
|
|
],
|
|
{ cwd: repo, encoding: "utf8" }
|
|
)
|
|
);
|
|
assert.strictEqual(build.status, "built");
|
|
assert.strictEqual(build.bundle_artifact.module, wasmPath);
|
|
assert(build.bundle_artifact.task_descriptor_count >= 2);
|
|
const wasm = fs.readFileSync(wasmPath);
|
|
const wasmModuleBase64 = wasm.toString("base64");
|
|
const bundleDigest = `sha256:${crypto.createHash("sha256").update(wasm).digest("hex")}`;
|
|
assert.strictEqual(build.bundle_artifact.bundle_digest, bundleDigest);
|
|
const linuxEnvironment = build.bundle.metadata.environments.find(
|
|
(environment) => environment.name === "linux"
|
|
);
|
|
assert(linuxEnvironment, "build bundle must declare the Linux environment");
|
|
|
|
const coordinator = cp.spawn(
|
|
"cargo",
|
|
[
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"clusterflux-coordinator",
|
|
"--bin",
|
|
"clusterflux-coordinator",
|
|
"--",
|
|
"--listen",
|
|
"127.0.0.1:0",
|
|
],
|
|
{
|
|
cwd: repo,
|
|
env: {
|
|
...process.env,
|
|
CLUSTERFLUX_SELF_HOSTED_SESSION_SECRET: clientSessionSecret,
|
|
CLUSTERFLUX_SELF_HOSTED_TENANT: "tenant",
|
|
CLUSTERFLUX_SELF_HOSTED_PROJECT: "project",
|
|
CLUSTERFLUX_SELF_HOSTED_USER: "user",
|
|
},
|
|
}
|
|
);
|
|
const coordinatorLines = new JsonLineReader(coordinator.stdout);
|
|
let worker;
|
|
let projectSourceBeforeEdit;
|
|
let coordinatorStderr = "";
|
|
let workerStderr = "";
|
|
coordinator.stderr.on("data", (chunk) => {
|
|
coordinatorStderr += chunk.toString();
|
|
});
|
|
|
|
try {
|
|
const ready = await coordinatorLines.next();
|
|
const [host, portText] = ready.listen.split(":");
|
|
const addr = { host, port: Number(portText) };
|
|
assert.strictEqual(ready.client_authority, "strict");
|
|
assert.strictEqual(ready.self_hosted_session_bootstrapped, true);
|
|
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
|
|
|
const grant = await send(addr, authenticated({
|
|
type: "create_node_enrollment_grant",
|
|
ttl_seconds: 900,
|
|
}));
|
|
assert.strictEqual(
|
|
grant.type,
|
|
"node_enrollment_grant_created",
|
|
JSON.stringify(grant)
|
|
);
|
|
|
|
const initialWorkerInvocation = commandWithPodman("cargo", [
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"clusterflux-node",
|
|
"--bin",
|
|
"clusterflux-node",
|
|
"--",
|
|
"--coordinator",
|
|
ready.listen,
|
|
"--tenant",
|
|
"tenant",
|
|
"--project-id",
|
|
"project",
|
|
"--node",
|
|
node,
|
|
"--public-key",
|
|
identity.publicKey,
|
|
"--enrollment-grant",
|
|
grant.grant,
|
|
"--worker",
|
|
"--assignment-poll-ms",
|
|
"25",
|
|
"--emit-ready",
|
|
"--project-root",
|
|
project,
|
|
]);
|
|
worker = cp.spawn(
|
|
initialWorkerInvocation.program,
|
|
initialWorkerInvocation.args,
|
|
{
|
|
cwd: repo,
|
|
env: { ...process.env, CLUSTERFLUX_NODE_PRIVATE_KEY: identity.privateKey },
|
|
}
|
|
);
|
|
worker.stderr.on("data", (chunk) => {
|
|
workerStderr += chunk.toString();
|
|
});
|
|
const workerLines = new JsonLineReader(worker.stdout);
|
|
const workerReady = await workerLines.next();
|
|
assert.strictEqual(workerReady.node_status, "ready");
|
|
assert.strictEqual(workerReady.node, node);
|
|
|
|
const started = await send(addr, authenticated({
|
|
type: "start_process",
|
|
process: "vp-wasmtime-assignment",
|
|
restart: false,
|
|
}));
|
|
assert.strictEqual(started.type, "process_started");
|
|
|
|
const sdkRun = JSON.parse(
|
|
cp.execFileSync(
|
|
"cargo",
|
|
[
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"launch-build-demo",
|
|
"--bin",
|
|
"sdk-product-runtime",
|
|
],
|
|
{
|
|
cwd: repo,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
CLUSTERFLUX_SDK_COORDINATOR: ready.listen,
|
|
CLUSTERFLUX_SDK_TENANT: "tenant",
|
|
CLUSTERFLUX_SDK_PROJECT: "project",
|
|
CLUSTERFLUX_SDK_PROCESS: "vp-wasmtime-assignment",
|
|
CLUSTERFLUX_SDK_USER: "user",
|
|
CLUSTERFLUX_SDK_SESSION_SECRET: clientSessionSecret,
|
|
CLUSTERFLUX_SDK_BUNDLE_DIGEST: bundleDigest,
|
|
CLUSTERFLUX_SDK_WASM_MODULE_PATH: wasmPath,
|
|
CLUSTERFLUX_SDK_VFS_EPOCH: String(started.epoch),
|
|
},
|
|
}
|
|
)
|
|
);
|
|
assert.strictEqual(sdkRun.kind, "clusterflux-sdk-product-runtime");
|
|
assert.strictEqual(sdkRun.remote_result, 42);
|
|
assert.strictEqual(sdkRun.local_function_invoked_by_spawn, false);
|
|
assert.strictEqual(sdkRun.task_spec.task_definition, "task_add_one");
|
|
assert.match(sdkRun.task_spec.task_instance, /^ti:vp-wasmtime-assignment:\d+$/);
|
|
assert.strictEqual(sdkRun.task_spec.environment_id, "linux");
|
|
assert.strictEqual(sdkRun.task_spec.vfs_epoch, started.epoch);
|
|
assert.strictEqual(sdkRun.task_spec.bundle_digest, bundleDigest);
|
|
assert.strictEqual(
|
|
sdkRun.task_spec.dispatch.kind,
|
|
"coordinator_node_wasm"
|
|
);
|
|
assert.strictEqual(sdkRun.task_spec.dispatch.export, null);
|
|
assert.strictEqual(sdkRun.task_spec.dispatch.abi, "task_v1");
|
|
assert.deepStrictEqual(sdkRun.task_spec.args, [{ SmallJson: 41 }]);
|
|
|
|
const nodeRun = await workerLines.next();
|
|
assert.strictEqual(nodeRun.node_status, "completed");
|
|
assert.strictEqual(nodeRun.status_code, 0);
|
|
assert.strictEqual(nodeRun.stdout_tail, '{"SmallJson":42}\n');
|
|
assert.strictEqual(nodeRun.staged_artifact, null);
|
|
assert.strictEqual(nodeRun.large_bytes_uploaded, false);
|
|
assert.strictEqual(
|
|
nodeRun.task_assignment_response.task,
|
|
sdkRun.task_spec.task_instance
|
|
);
|
|
assert.strictEqual(
|
|
nodeRun.task_assignment_response.task_spec.dispatch.abi,
|
|
"task_v1"
|
|
);
|
|
assert.strictEqual(nodeRun.vfs_metadata_response.type, "vfs_metadata_recorded");
|
|
assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded");
|
|
|
|
const events = await send(addr, authenticated({
|
|
type: "list_task_events",
|
|
process: "vp-wasmtime-assignment",
|
|
}));
|
|
assert.strictEqual(events.type, "task_events");
|
|
assert.strictEqual(events.events.length, 1);
|
|
assert.strictEqual(events.events[0].task, sdkRun.task_spec.task_instance);
|
|
assert.strictEqual(events.events[0].stdout_tail, '{"SmallJson":42}\n');
|
|
assert.deepStrictEqual(events.events[0].result, { SmallJson: 42 });
|
|
|
|
// Prove the edited-task restart boundary with a real Rust source edit and
|
|
// real Wasm rebuild. The first execution uses the original bundle, then the
|
|
// coordinator accepts a compatible replacement bundle, preserves the
|
|
// original argument envelope, assigns a fresh task-instance ID, and the
|
|
// same node executes the changed code from the clean task-entry checkpoint.
|
|
const editableLaunch = await send(addr, authenticated({
|
|
type: "launch_task",
|
|
task_spec: {
|
|
tenant: "tenant",
|
|
project: "project",
|
|
process: "vp-wasmtime-assignment",
|
|
task_definition: "task_add_one",
|
|
task_instance: "task_add_one-editable-1",
|
|
dispatch: {
|
|
kind: "coordinator_node_wasm",
|
|
export: null,
|
|
abi: "task_v1",
|
|
},
|
|
environment_id: null,
|
|
environment: null,
|
|
environment_digest: null,
|
|
required_capabilities: [],
|
|
dependency_cache: null,
|
|
source_snapshot: null,
|
|
required_artifacts: [],
|
|
args: [{ SmallJson: 41 }],
|
|
vfs_epoch: started.epoch,
|
|
bundle_digest: bundleDigest,
|
|
},
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/task-add-one-editable.json",
|
|
wasm_module_base64: wasmModuleBase64,
|
|
}));
|
|
assert.strictEqual(editableLaunch.type, "task_launched", JSON.stringify(editableLaunch));
|
|
const initialEditableRun = await workerLines.next(
|
|
60000,
|
|
"initial editable task completion"
|
|
);
|
|
assert.strictEqual(initialEditableRun.node_status, "completed");
|
|
assert.strictEqual(initialEditableRun.task_assignment_response.task, "task_add_one-editable-1");
|
|
assert.strictEqual(initialEditableRun.stdout_tail, '{"SmallJson":42}\n');
|
|
|
|
const initialEditableJoin = await send(addr, authenticated({
|
|
type: "join_task",
|
|
process: "vp-wasmtime-assignment",
|
|
task: "task_add_one-editable-1",
|
|
}));
|
|
assert.strictEqual(initialEditableJoin.type, "task_joined");
|
|
assert.strictEqual(initialEditableJoin.join.state, "completed");
|
|
assert.deepStrictEqual(initialEditableJoin.join.result, { SmallJson: 42 });
|
|
|
|
projectSourceBeforeEdit = fs.readFileSync(projectSourcePath, "utf8");
|
|
const originalBody = "pub extern \"C\" fn task_add_one(input: i32) -> i32 {\n input + 1\n}";
|
|
const editedBody =
|
|
"pub extern \"C\" fn task_add_one(input: i32) -> i32 {\n" +
|
|
" input + 2 // edited-task restart integration probe\n" +
|
|
"}";
|
|
assert(
|
|
projectSourceBeforeEdit.includes(originalBody),
|
|
"flagship task_add_one source must retain the expected editable body"
|
|
);
|
|
fs.writeFileSync(
|
|
projectSourcePath,
|
|
projectSourceBeforeEdit.replace(originalBody, editedBody)
|
|
);
|
|
|
|
let editedBuild;
|
|
try {
|
|
editedBuild = JSON.parse(
|
|
cp.execFileSync(
|
|
"cargo",
|
|
[
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"clusterflux-cli",
|
|
"--bin",
|
|
"clusterflux",
|
|
"--",
|
|
"build",
|
|
"--project",
|
|
project,
|
|
"--output",
|
|
editedBundleDirectory,
|
|
"--json",
|
|
],
|
|
{ cwd: repo, encoding: "utf8" }
|
|
)
|
|
);
|
|
} finally {
|
|
fs.writeFileSync(projectSourcePath, projectSourceBeforeEdit);
|
|
projectSourceBeforeEdit = undefined;
|
|
}
|
|
assert.strictEqual(editedBuild.status, "built");
|
|
const editedWasmPath = path.join(editedBundleDirectory, "module.wasm");
|
|
assert.strictEqual(editedBuild.bundle_artifact.module, editedWasmPath);
|
|
const editedWasm = fs.readFileSync(editedWasmPath);
|
|
const editedBundleDigest = `sha256:${crypto
|
|
.createHash("sha256")
|
|
.update(editedWasm)
|
|
.digest("hex")}`;
|
|
assert.strictEqual(editedBuild.bundle_artifact.bundle_digest, editedBundleDigest);
|
|
assert.notStrictEqual(
|
|
editedBundleDigest,
|
|
bundleDigest,
|
|
"the real Rust source edit must produce changed Wasm bytes"
|
|
);
|
|
|
|
const originalTaskDescriptor = JSON.parse(
|
|
fs.readFileSync(path.join(bundleDirectory, "task-descriptors.json"), "utf8")
|
|
).find((descriptor) => descriptor.name === "task_add_one");
|
|
const editedTaskDescriptor = JSON.parse(
|
|
fs.readFileSync(path.join(editedBundleDirectory, "task-descriptors.json"), "utf8")
|
|
).find((descriptor) => descriptor.name === "task_add_one");
|
|
assert(originalTaskDescriptor, "original bundle must describe task_add_one");
|
|
assert(editedTaskDescriptor, "edited bundle must describe task_add_one");
|
|
assert.strictEqual(editedTaskDescriptor.stable_id, originalTaskDescriptor.stable_id);
|
|
assert.strictEqual(
|
|
editedTaskDescriptor.restart_compatibility_hash,
|
|
originalTaskDescriptor.restart_compatibility_hash,
|
|
"body-only edit must remain restart-compatible"
|
|
);
|
|
|
|
const editedRestart = await send(addr, authenticated({
|
|
type: "restart_task",
|
|
process: "vp-wasmtime-assignment",
|
|
task: "task_add_one-editable-1",
|
|
replacement_bundle: {
|
|
bundle_digest: editedBundleDigest,
|
|
wasm_module_base64: editedWasm.toString("base64"),
|
|
},
|
|
}));
|
|
assert.strictEqual(editedRestart.type, "task_restart", JSON.stringify(editedRestart));
|
|
assert.strictEqual(editedRestart.accepted, true);
|
|
assert.strictEqual(editedRestart.clean_boundary_available, true);
|
|
assert.strictEqual(editedRestart.active_task, false);
|
|
assert.strictEqual(editedRestart.completed_event_observed, true);
|
|
assert.strictEqual(editedRestart.requires_whole_process_restart, false);
|
|
assert.strictEqual(
|
|
editedRestart.restarted_task_instance,
|
|
"task_add_one-editable-1",
|
|
"a restart attempt must retain the logical task instance identity"
|
|
);
|
|
|
|
const editedNodeRun = await workerLines.next(
|
|
60000,
|
|
"edited replacement task completion"
|
|
);
|
|
assert.strictEqual(editedNodeRun.node_status, "completed");
|
|
assert.strictEqual(
|
|
editedNodeRun.task_assignment_response.task,
|
|
editedRestart.restarted_task_instance
|
|
);
|
|
assert.strictEqual(
|
|
editedNodeRun.task_assignment_response.task_spec.bundle_digest,
|
|
editedBundleDigest
|
|
);
|
|
assert.deepStrictEqual(
|
|
editedNodeRun.task_assignment_response.task_spec.args,
|
|
[{ SmallJson: 41 }],
|
|
"edited restart must preserve the original serialized argument envelope"
|
|
);
|
|
assert.strictEqual(
|
|
editedNodeRun.task_assignment_response.task_spec.vfs_epoch,
|
|
started.epoch,
|
|
"edited restart must use the original clean VFS entry boundary"
|
|
);
|
|
assert.strictEqual(
|
|
editedNodeRun.stdout_tail,
|
|
'{"SmallJson":43}\n',
|
|
"the restarted assignment must execute the edited Rust code"
|
|
);
|
|
|
|
const editedJoin = await send(addr, authenticated({
|
|
type: "join_task",
|
|
process: "vp-wasmtime-assignment",
|
|
task: editedRestart.restarted_task_instance,
|
|
}));
|
|
assert.strictEqual(editedJoin.type, "task_joined");
|
|
assert.strictEqual(editedJoin.join.state, "completed");
|
|
assert.deepStrictEqual(editedJoin.join.result, { SmallJson: 43 });
|
|
|
|
// A real signature edit must be rejected before a replacement assignment
|
|
// is created, while the compatible restarted result remains intact.
|
|
projectSourceBeforeEdit = fs.readFileSync(projectSourcePath, "utf8");
|
|
const incompatibleBody =
|
|
"pub extern \"C\" fn task_add_one(input: i32) -> i64 {\n" +
|
|
" i64::from(input) + 1\n" +
|
|
"}";
|
|
assert(projectSourceBeforeEdit.includes(originalBody));
|
|
const restartJoinOriginal =
|
|
' .expect("restart probe child should complete");';
|
|
const restartJoinWithConversion =
|
|
' .expect("restart probe child should complete")\n' +
|
|
' .try_into()\n' +
|
|
' .expect("incompatible fixture result should fit i32");';
|
|
const restartHostOriginal = " task_add_one(41)\n}";
|
|
const restartHostWithConversion =
|
|
' i32::try_from(task_add_one(41))\n' +
|
|
' .expect("incompatible fixture result should fit i32")\n' +
|
|
"}";
|
|
assert(projectSourceBeforeEdit.includes(restartJoinOriginal));
|
|
assert(projectSourceBeforeEdit.includes(restartHostOriginal));
|
|
const incompatibleSource = projectSourceBeforeEdit
|
|
.replace(originalBody, incompatibleBody)
|
|
.replace(restartJoinOriginal, restartJoinWithConversion)
|
|
.replace(restartHostOriginal, restartHostWithConversion);
|
|
fs.writeFileSync(
|
|
projectSourcePath,
|
|
incompatibleSource
|
|
);
|
|
let incompatibleBuild;
|
|
try {
|
|
incompatibleBuild = JSON.parse(
|
|
cp.execFileSync(
|
|
"cargo",
|
|
[
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"clusterflux-cli",
|
|
"--bin",
|
|
"clusterflux",
|
|
"--",
|
|
"build",
|
|
"--project",
|
|
project,
|
|
"--output",
|
|
incompatibleBundleDirectory,
|
|
"--json",
|
|
],
|
|
{ cwd: repo, encoding: "utf8" }
|
|
)
|
|
);
|
|
} finally {
|
|
fs.writeFileSync(projectSourcePath, projectSourceBeforeEdit);
|
|
projectSourceBeforeEdit = undefined;
|
|
}
|
|
assert.strictEqual(incompatibleBuild.status, "built");
|
|
const incompatibleWasmPath = path.join(incompatibleBundleDirectory, "module.wasm");
|
|
const incompatibleWasm = fs.readFileSync(incompatibleWasmPath);
|
|
const incompatibleBundleDigest = `sha256:${crypto
|
|
.createHash("sha256")
|
|
.update(incompatibleWasm)
|
|
.digest("hex")}`;
|
|
assert.strictEqual(
|
|
incompatibleBuild.bundle_artifact.bundle_digest,
|
|
incompatibleBundleDigest
|
|
);
|
|
const incompatibleTaskDescriptor = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(incompatibleBundleDirectory, "task-descriptors.json"),
|
|
"utf8"
|
|
)
|
|
).find((descriptor) => descriptor.name === "task_add_one");
|
|
assert(incompatibleTaskDescriptor, "incompatible bundle must still name task_add_one");
|
|
assert.notStrictEqual(
|
|
incompatibleTaskDescriptor.restart_compatibility_hash,
|
|
originalTaskDescriptor.restart_compatibility_hash,
|
|
"signature edit must change restart compatibility metadata"
|
|
);
|
|
|
|
const incompatibleRestart = await send(addr, authenticated({
|
|
type: "restart_task",
|
|
process: "vp-wasmtime-assignment",
|
|
task: "task_add_one-editable-1",
|
|
replacement_bundle: {
|
|
bundle_digest: incompatibleBundleDigest,
|
|
wasm_module_base64: incompatibleWasm.toString("base64"),
|
|
},
|
|
}));
|
|
assert.strictEqual(
|
|
incompatibleRestart.type,
|
|
"task_restart",
|
|
JSON.stringify(incompatibleRestart)
|
|
);
|
|
assert.strictEqual(incompatibleRestart.accepted, false);
|
|
assert.strictEqual(incompatibleRestart.requires_whole_process_restart, true);
|
|
assert.strictEqual(incompatibleRestart.restarted_task_instance, null);
|
|
assert.match(
|
|
incompatibleRestart.message,
|
|
/task (?:entrypoint|ABI) changed/,
|
|
"signature edit must explain which restart-compatibility boundary changed"
|
|
);
|
|
|
|
const preservedEditedJoin = await send(addr, authenticated({
|
|
type: "join_task",
|
|
process: "vp-wasmtime-assignment",
|
|
task: editedRestart.restarted_task_instance,
|
|
}));
|
|
assert.strictEqual(preservedEditedJoin.type, "task_joined");
|
|
assert.strictEqual(preservedEditedJoin.join.state, "completed");
|
|
assert.deepStrictEqual(
|
|
preservedEditedJoin.join.result,
|
|
{ SmallJson: 43 },
|
|
"incompatible edit denial must not corrupt the compatible restarted result"
|
|
);
|
|
|
|
const retiredFirstWorker = await send(addr, authenticated({
|
|
type: "revoke_node_credential",
|
|
node,
|
|
}));
|
|
assert.strictEqual(retiredFirstWorker.type, "node_credential_revoked");
|
|
stopChild(worker);
|
|
await delay(100);
|
|
|
|
const trapGrant = await send(addr, authenticated({
|
|
type: "create_node_enrollment_grant",
|
|
ttl_seconds: 900,
|
|
}));
|
|
assert.strictEqual(trapGrant.type, "node_enrollment_grant_created");
|
|
const trapWorkerInvocation = commandWithPodman("cargo", [
|
|
"run",
|
|
"-q",
|
|
"-p",
|
|
"clusterflux-node",
|
|
"--bin",
|
|
"clusterflux-node",
|
|
"--",
|
|
"--coordinator",
|
|
ready.listen,
|
|
"--tenant",
|
|
"tenant",
|
|
"--project-id",
|
|
"project",
|
|
"--node",
|
|
trapNode,
|
|
"--public-key",
|
|
trapIdentity.publicKey,
|
|
"--enrollment-grant",
|
|
trapGrant.grant,
|
|
"--worker",
|
|
"--assignment-poll-ms",
|
|
"25",
|
|
"--emit-ready",
|
|
"--project-root",
|
|
project,
|
|
]);
|
|
worker = cp.spawn(
|
|
trapWorkerInvocation.program,
|
|
trapWorkerInvocation.args,
|
|
{
|
|
cwd: repo,
|
|
env: {
|
|
...process.env,
|
|
CLUSTERFLUX_NODE_PRIVATE_KEY: trapIdentity.privateKey,
|
|
},
|
|
}
|
|
);
|
|
worker.stderr.on("data", (chunk) => {
|
|
workerStderr += chunk.toString();
|
|
});
|
|
const trapWorkerLines = new JsonLineReader(worker.stdout);
|
|
const trapWorkerReady = await trapWorkerLines.next();
|
|
assert.strictEqual(trapWorkerReady.node_status, "ready");
|
|
assert.strictEqual(trapWorkerReady.node, trapNode);
|
|
const nodeDescriptors = await send(addr, authenticated({
|
|
type: "list_node_descriptors",
|
|
}));
|
|
assert.strictEqual(
|
|
nodeDescriptors.type,
|
|
"node_descriptors",
|
|
JSON.stringify(nodeDescriptors)
|
|
);
|
|
const trapNodeDescriptor = nodeDescriptors.descriptors.find(
|
|
(descriptor) => descriptor.id === trapNode
|
|
);
|
|
assert(trapNodeDescriptor, "trap worker descriptor must be visible");
|
|
assert.deepStrictEqual(
|
|
trapNodeDescriptor.source_snapshots.length,
|
|
1,
|
|
"trap worker must report its authoritative checkout snapshot"
|
|
);
|
|
const sourceSnapshot = trapNodeDescriptor.source_snapshots[0];
|
|
|
|
const trapLaunch = await send(addr, authenticated({
|
|
type: "launch_task",
|
|
task_spec: {
|
|
tenant: "tenant",
|
|
project: "project",
|
|
process: "vp-wasmtime-assignment",
|
|
task_definition: "task_trap",
|
|
task_instance: "task_trap-1",
|
|
dispatch: {
|
|
kind: "coordinator_node_wasm",
|
|
export: "task_trap",
|
|
abi: "task_v1",
|
|
},
|
|
environment_id: null,
|
|
environment: null,
|
|
environment_digest: null,
|
|
required_capabilities: [],
|
|
dependency_cache: null,
|
|
source_snapshot: null,
|
|
required_artifacts: [],
|
|
args: [{ SmallJson: 0 }],
|
|
vfs_epoch: started.epoch,
|
|
bundle_digest: bundleDigest,
|
|
},
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/wasmtime-trap.txt",
|
|
wasm_module_base64: wasmModuleBase64,
|
|
}));
|
|
assert.strictEqual(trapLaunch.type, "task_launched", JSON.stringify(trapLaunch));
|
|
const trapNodeRun = await trapWorkerLines.next();
|
|
assert.strictEqual(trapNodeRun.node_status, "failed");
|
|
assert.strictEqual(trapNodeRun.terminal_state, "failed");
|
|
assert.match(trapNodeRun.stderr_tail, /wasmtime task failed|unreachable|trap/i);
|
|
assert.strictEqual(trapNodeRun.log_event_response.type, "task_log_recorded");
|
|
assert.strictEqual(trapNodeRun.coordinator_response.type, "task_recorded");
|
|
|
|
const trapJoin = await send(addr, authenticated({
|
|
type: "join_task",
|
|
process: "vp-wasmtime-assignment",
|
|
task: "task_trap-1",
|
|
}));
|
|
assert.strictEqual(trapJoin.type, "task_joined");
|
|
assert.strictEqual(trapJoin.join.state, "failed");
|
|
assert.strictEqual(trapJoin.join.remote_completion_observed, true);
|
|
assert.match(trapJoin.join.message, /wasmtime task failed|unreachable|trap/i);
|
|
|
|
const trapRestart = await send(addr, authenticated({
|
|
type: "restart_task",
|
|
process: "vp-wasmtime-assignment",
|
|
task: "task_trap-1",
|
|
}));
|
|
assert.strictEqual(trapRestart.type, "task_restart", JSON.stringify(trapRestart));
|
|
assert.strictEqual(trapRestart.accepted, true);
|
|
assert.strictEqual(trapRestart.clean_boundary_available, true);
|
|
assert.strictEqual(trapRestart.active_task, false);
|
|
assert.strictEqual(trapRestart.completed_event_observed, true);
|
|
assert.strictEqual(trapRestart.requires_whole_process_restart, false);
|
|
assert.strictEqual(
|
|
trapRestart.restarted_task_instance,
|
|
"task_trap-1",
|
|
"a failed-task retry must retain the logical task instance identity"
|
|
);
|
|
assert.match(trapRestart.message, /clean VFS entry boundary epoch/);
|
|
const restartedTrapNodeRun = await trapWorkerLines.next(
|
|
60000,
|
|
"restarted trap task completion"
|
|
);
|
|
assert.strictEqual(restartedTrapNodeRun.node_status, "failed");
|
|
assert.strictEqual(restartedTrapNodeRun.terminal_state, "failed");
|
|
assert.strictEqual(
|
|
restartedTrapNodeRun.task_assignment_response.task,
|
|
trapRestart.restarted_task_instance
|
|
);
|
|
|
|
const configuredBreakpoints = await send(addr, authenticated({
|
|
type: "set_debug_breakpoints",
|
|
process: "vp-wasmtime-assignment",
|
|
probe_symbols: ["clusterflux.probe.cooperative_cancellation_probe"],
|
|
}));
|
|
assert.strictEqual(configuredBreakpoints.type, "debug_breakpoints");
|
|
assert.deepStrictEqual(configuredBreakpoints.probe_symbols, [
|
|
"clusterflux.probe.cooperative_cancellation_probe",
|
|
]);
|
|
const cancellationLaunch = await send(addr, authenticated({
|
|
type: "launch_task",
|
|
task_spec: {
|
|
tenant: "tenant",
|
|
project: "project",
|
|
process: "vp-wasmtime-assignment",
|
|
task_definition: "cooperative_cancellation_probe",
|
|
task_instance: "cooperative_cancellation_probe-1",
|
|
dispatch: {
|
|
kind: "coordinator_node_wasm",
|
|
export: null,
|
|
abi: "task_v1",
|
|
},
|
|
environment_id: null,
|
|
environment: null,
|
|
environment_digest: null,
|
|
required_capabilities: [],
|
|
dependency_cache: null,
|
|
source_snapshot: null,
|
|
required_artifacts: [],
|
|
args: [],
|
|
vfs_epoch: started.epoch,
|
|
bundle_digest: bundleDigest,
|
|
},
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/cooperative-cancellation-probe.txt",
|
|
wasm_module_base64: wasmModuleBase64,
|
|
}));
|
|
assert.strictEqual(cancellationLaunch.type, "task_launched");
|
|
const breakpointHit = await waitForBreakpointHit(
|
|
addr,
|
|
"vp-wasmtime-assignment"
|
|
);
|
|
assert.strictEqual(
|
|
breakpointHit.hit_probe_symbol,
|
|
"clusterflux.probe.cooperative_cancellation_probe"
|
|
);
|
|
assert.strictEqual(
|
|
breakpointHit.hit_task,
|
|
"cooperative_cancellation_probe-1"
|
|
);
|
|
const debugEpoch = { epoch: breakpointHit.hit_epoch };
|
|
const frozenDebugEpoch = await waitForDebugEpochState(
|
|
addr,
|
|
"vp-wasmtime-assignment",
|
|
debugEpoch.epoch,
|
|
"fully_frozen"
|
|
);
|
|
assert.strictEqual(frozenDebugEpoch.acknowledgements.length, 1);
|
|
assert.strictEqual(frozenDebugEpoch.acknowledgements[0].state, "frozen");
|
|
assert.strictEqual(
|
|
frozenDebugEpoch.acknowledgements[0].task,
|
|
"cooperative_cancellation_probe-1"
|
|
);
|
|
const debugResume = await send(addr, authenticated({
|
|
type: "resume_debug_epoch",
|
|
process: "vp-wasmtime-assignment",
|
|
epoch: debugEpoch.epoch,
|
|
}));
|
|
assert.strictEqual(debugResume.type, "debug_epoch");
|
|
assert.strictEqual(debugResume.command, "resume");
|
|
const resumedDebugEpoch = await waitForDebugEpochState(
|
|
addr,
|
|
"vp-wasmtime-assignment",
|
|
debugEpoch.epoch,
|
|
"fully_resumed"
|
|
);
|
|
assert.strictEqual(resumedDebugEpoch.acknowledgements.length, 1);
|
|
assert.strictEqual(resumedDebugEpoch.acknowledgements[0].state, "running");
|
|
const cancellationRequested = await send(addr, authenticated({
|
|
type: "cancel_process",
|
|
process: "vp-wasmtime-assignment",
|
|
}));
|
|
assert.strictEqual(
|
|
cancellationRequested.type,
|
|
"process_cancellation_requested"
|
|
);
|
|
assert.strictEqual(cancellationRequested.cancelled_tasks.length, 1);
|
|
assert.strictEqual(
|
|
cancellationRequested.cancelled_tasks[0].task,
|
|
"cooperative_cancellation_probe-1"
|
|
);
|
|
const cancellationNodeRun = await trapWorkerLines.next(
|
|
10000,
|
|
"cooperative cancellation task completion"
|
|
);
|
|
assert.strictEqual(cancellationNodeRun.node_status, "completed");
|
|
assert.strictEqual(cancellationNodeRun.terminal_state, "completed");
|
|
const cancellationJoin = await send(addr, authenticated({
|
|
type: "join_task",
|
|
process: "vp-wasmtime-assignment",
|
|
task: "cooperative_cancellation_probe-1",
|
|
}));
|
|
assert.strictEqual(cancellationJoin.type, "task_joined");
|
|
assert.strictEqual(cancellationJoin.join.state, "completed");
|
|
assert.deepStrictEqual(cancellationJoin.join.result, { SmallJson: 17 });
|
|
|
|
// Completing the cooperatively cancelled task releases the process slot.
|
|
// A redundant forced abort must not resurrect or mutate that process.
|
|
const cancelledProcessCleanup = await send(addr, authenticated({
|
|
type: "abort_process",
|
|
process: "vp-wasmtime-assignment",
|
|
}));
|
|
assert.strictEqual(cancelledProcessCleanup.type, "error");
|
|
assert.match(
|
|
cancelledProcessCleanup.message,
|
|
/process abort requires an active virtual process/
|
|
);
|
|
|
|
const multiParticipantProcess = await send(addr, authenticated({
|
|
type: "start_process",
|
|
process: "vp-wasmtime-multi-debug",
|
|
restart: false,
|
|
}));
|
|
assert.strictEqual(multiParticipantProcess.type, "process_started");
|
|
const multiParticipantLaunch = await send(addr, authenticated({
|
|
type: "launch_task",
|
|
task_spec: {
|
|
tenant: "tenant",
|
|
project: "project",
|
|
process: multiParticipantProcess.process,
|
|
task_definition: "debug_parent_probe",
|
|
task_instance: "debug_parent_probe-1",
|
|
dispatch: {
|
|
kind: "coordinator_node_wasm",
|
|
export: null,
|
|
abi: "task_v1",
|
|
},
|
|
environment_id: null,
|
|
environment: null,
|
|
environment_digest: null,
|
|
required_capabilities: [],
|
|
dependency_cache: null,
|
|
source_snapshot: null,
|
|
required_artifacts: [],
|
|
args: [],
|
|
vfs_epoch: multiParticipantProcess.epoch,
|
|
bundle_digest: bundleDigest,
|
|
},
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/debug-parent-probe.txt",
|
|
wasm_module_base64: wasmModuleBase64,
|
|
}));
|
|
assert.strictEqual(multiParticipantLaunch.type, "task_launched");
|
|
const debugChildInstance = "debug_parent_probe-1:child:1";
|
|
await waitForRunningTaskInstances(addr, multiParticipantProcess.process, [
|
|
"debug_parent_probe-1",
|
|
debugChildInstance,
|
|
]);
|
|
const multiParticipantFreeze = await send(addr, authenticated({
|
|
type: "create_debug_epoch",
|
|
process: multiParticipantProcess.process,
|
|
stopped_task: debugChildInstance,
|
|
reason: "prove all-stop across active parent and child Wasm participants",
|
|
}));
|
|
assert.strictEqual(
|
|
multiParticipantFreeze.type,
|
|
"debug_epoch",
|
|
JSON.stringify(multiParticipantFreeze)
|
|
);
|
|
const multiParticipantFrozen = await waitForDebugEpochState(
|
|
addr,
|
|
multiParticipantProcess.process,
|
|
multiParticipantFreeze.epoch,
|
|
"fully_frozen"
|
|
);
|
|
assert.strictEqual(multiParticipantFreeze.affected_tasks.length, 2);
|
|
assert.strictEqual(multiParticipantFrozen.acknowledgements.length, 2);
|
|
assert.deepStrictEqual(
|
|
multiParticipantFrozen.acknowledgements
|
|
.map((acknowledgement) => acknowledgement.task)
|
|
.sort(),
|
|
[debugChildInstance, "debug_parent_probe-1"].sort()
|
|
);
|
|
const multiParticipantResume = await send(addr, authenticated({
|
|
type: "resume_debug_epoch",
|
|
process: multiParticipantProcess.process,
|
|
epoch: multiParticipantFreeze.epoch,
|
|
}));
|
|
assert.strictEqual(multiParticipantResume.type, "debug_epoch");
|
|
const multiParticipantResumed = await waitForDebugEpochState(
|
|
addr,
|
|
multiParticipantProcess.process,
|
|
multiParticipantFreeze.epoch,
|
|
"fully_resumed"
|
|
);
|
|
assert.strictEqual(multiParticipantResumed.acknowledgements.length, 2);
|
|
const multiParticipantCancellation = await send(addr, authenticated({
|
|
type: "cancel_process",
|
|
process: multiParticipantProcess.process,
|
|
}));
|
|
assert.strictEqual(
|
|
multiParticipantCancellation.type,
|
|
"process_cancellation_requested"
|
|
);
|
|
assert.strictEqual(multiParticipantCancellation.cancelled_tasks.length, 2);
|
|
const multiParticipantJoin = await waitForTaskJoin(
|
|
addr,
|
|
multiParticipantProcess.process,
|
|
"debug_parent_probe-1"
|
|
);
|
|
const multiParticipantNodeRun = await trapWorkerLines.next(
|
|
10000,
|
|
"multi-participant parent completion"
|
|
);
|
|
assert.strictEqual(multiParticipantNodeRun.node_status, "completed");
|
|
assert.strictEqual(multiParticipantJoin.join.state, "completed");
|
|
assert.deepStrictEqual(multiParticipantJoin.join.result, { SmallJson: 23 });
|
|
const multiParticipantCleanup = await send(addr, authenticated({
|
|
type: "abort_process",
|
|
process: multiParticipantProcess.process,
|
|
}));
|
|
assert.strictEqual(multiParticipantCleanup.type, "error");
|
|
assert.match(
|
|
multiParticipantCleanup.message,
|
|
/process abort requires an active virtual process/
|
|
);
|
|
|
|
const abortProcess = await send(addr, authenticated({
|
|
type: "start_process",
|
|
process: "vp-wasmtime-abort",
|
|
restart: false,
|
|
}));
|
|
assert.strictEqual(abortProcess.type, "process_started");
|
|
|
|
const abortLaunch = await send(addr, authenticated({
|
|
type: "launch_task",
|
|
task_spec: {
|
|
tenant: "tenant",
|
|
project: "project",
|
|
process: abortProcess.process,
|
|
task_definition: "abort_probe",
|
|
task_instance: "abort_probe-1",
|
|
dispatch: {
|
|
kind: "coordinator_node_wasm",
|
|
export: null,
|
|
abi: "task_v1",
|
|
},
|
|
environment_id: "linux",
|
|
environment: linuxEnvironment.requirements,
|
|
environment_digest: linuxEnvironment.digest,
|
|
required_capabilities: ["Command"],
|
|
dependency_cache: null,
|
|
source_snapshot: sourceSnapshot,
|
|
required_artifacts: [],
|
|
args: [],
|
|
vfs_epoch: abortProcess.epoch,
|
|
bundle_digest: bundleDigest,
|
|
},
|
|
wait_for_node: false,
|
|
artifact_path: "/vfs/artifacts/abort-probe.txt",
|
|
wasm_module_base64: wasmModuleBase64,
|
|
}));
|
|
assert.strictEqual(abortLaunch.type, "task_launched");
|
|
assert.strictEqual(process.platform, "linux", "native all-stop proof is the Linux MVP path");
|
|
const nativeSleepPid = await waitForNativeSleep(worker.pid);
|
|
const nativeFreeze = await send(addr, authenticated({
|
|
type: "create_debug_epoch",
|
|
process: abortProcess.process,
|
|
stopped_task: "abort_probe-1",
|
|
reason: "freeze while the abort probe native command is running",
|
|
}));
|
|
assert.strictEqual(nativeFreeze.type, "debug_epoch", JSON.stringify(nativeFreeze));
|
|
assert.strictEqual(nativeFreeze.command, "freeze");
|
|
const nativeFrozen = await waitForDebugEpochState(
|
|
addr,
|
|
abortProcess.process,
|
|
nativeFreeze.epoch,
|
|
"fully_frozen"
|
|
);
|
|
assert.strictEqual(nativeFrozen.acknowledgements.length, 1);
|
|
assert.strictEqual(nativeFrozen.acknowledgements[0].task, "abort_probe-1");
|
|
assert.match(procState(nativeSleepPid), /^T \(stopped\)/);
|
|
const nativeResume = await send(addr, authenticated({
|
|
type: "resume_debug_epoch",
|
|
process: abortProcess.process,
|
|
epoch: nativeFreeze.epoch,
|
|
}));
|
|
assert.strictEqual(nativeResume.type, "debug_epoch");
|
|
assert.strictEqual(nativeResume.command, "resume");
|
|
const nativeResumed = await waitForDebugEpochState(
|
|
addr,
|
|
abortProcess.process,
|
|
nativeFreeze.epoch,
|
|
"fully_resumed"
|
|
);
|
|
assert.strictEqual(nativeResumed.acknowledgements.length, 1);
|
|
assert.doesNotMatch(procState(nativeSleepPid), /^T \(stopped\)/);
|
|
const abortStartedAt = Date.now();
|
|
const aborted = await send(addr, authenticated({
|
|
type: "abort_process",
|
|
process: abortProcess.process,
|
|
}));
|
|
assert.strictEqual(aborted.type, "process_aborted");
|
|
assert.strictEqual(aborted.aborted_tasks.length, 1);
|
|
assert.strictEqual(aborted.aborted_tasks[0].task, "abort_probe-1");
|
|
const abortedNodeRun = await trapWorkerLines.next(
|
|
10000,
|
|
"forced-abort task completion"
|
|
);
|
|
assert.strictEqual(
|
|
abortedNodeRun.node_status,
|
|
"cancelled",
|
|
JSON.stringify(abortedNodeRun)
|
|
);
|
|
assert.strictEqual(abortedNodeRun.terminal_state, "cancelled");
|
|
const abortElapsedMs = Date.now() - abortStartedAt;
|
|
assert(
|
|
abortElapsedMs < 10000,
|
|
`abort should stop the running command promptly; observed ${abortElapsedMs}ms`
|
|
);
|
|
const afterAbort = await send(addr, authenticated({ type: "list_processes" }));
|
|
assert.strictEqual(afterAbort.type, "process_statuses");
|
|
assert.deepStrictEqual(afterAbort.processes, []);
|
|
|
|
const report = {
|
|
kind: "clusterflux-wasmtime-assignment-smoke",
|
|
coordinator: ready.listen,
|
|
client_authority: ready.client_authority,
|
|
process: started.process,
|
|
epoch: started.epoch,
|
|
task: sdkRun.task_spec.task,
|
|
node,
|
|
bundle_digest: bundleDigest,
|
|
sdk_product_runtime: sdkRun,
|
|
assignment_response: nodeRun.task_assignment_response.type,
|
|
assignment_wasm_abi: nodeRun.task_assignment_response.task_spec.dispatch.abi,
|
|
node_runtime_status: nodeRun.node_status,
|
|
node_runtime_stdout: nodeRun.stdout_tail,
|
|
vfs_metadata_response: nodeRun.vfs_metadata_response.type,
|
|
task_completion_response: nodeRun.coordinator_response.type,
|
|
task_events: events.events.length,
|
|
large_bytes_uploaded: nodeRun.large_bytes_uploaded,
|
|
edited_task_restart: {
|
|
source_edit: "task_add_one: input + 1 -> input + 2",
|
|
original_bundle_digest: bundleDigest,
|
|
replacement_bundle_digest: editedBundleDigest,
|
|
original_task_instance: "task_add_one-editable-1",
|
|
restarted_task_instance: editedRestart.restarted_task_instance,
|
|
original_args: [{ SmallJson: 41 }],
|
|
clean_vfs_epoch: started.epoch,
|
|
initial_result: initialEditableJoin.join.result,
|
|
edited_result: editedJoin.join.result,
|
|
incompatible_source_edit: "task_add_one result: i32 -> i64",
|
|
incompatible_bundle_digest: incompatibleBundleDigest,
|
|
incompatible_requires_whole_process_restart:
|
|
incompatibleRestart.requires_whole_process_restart,
|
|
preserved_result_after_incompatible_denial:
|
|
preservedEditedJoin.join.result,
|
|
},
|
|
trapped_task: {
|
|
task: trapLaunch.task,
|
|
node_status: trapNodeRun.node_status,
|
|
terminal_state: trapNodeRun.terminal_state,
|
|
log_response: trapNodeRun.log_event_response.type,
|
|
completion_response: trapNodeRun.coordinator_response.type,
|
|
join_state: trapJoin.join.state,
|
|
remote_completion_observed:
|
|
trapJoin.join.remote_completion_observed,
|
|
checkpoint_restart: {
|
|
accepted: trapRestart.accepted,
|
|
clean_boundary_available: trapRestart.clean_boundary_available,
|
|
requires_whole_process_restart:
|
|
trapRestart.requires_whole_process_restart,
|
|
rerun_terminal_state: restartedTrapNodeRun.terminal_state,
|
|
},
|
|
},
|
|
cooperative_cancellation: {
|
|
task: cancellationLaunch.task,
|
|
request: cancellationRequested.type,
|
|
task_observed_request: true,
|
|
terminal_state: cancellationNodeRun.terminal_state,
|
|
result: cancellationJoin.join.result,
|
|
process_cleanup_was_explicit: false,
|
|
process_slot_released: true,
|
|
},
|
|
debug_epoch: {
|
|
epoch: debugEpoch.epoch,
|
|
wasm_probe_hit: breakpointHit.hit_probe_symbol,
|
|
freeze_acknowledgements: frozenDebugEpoch.acknowledgements.length,
|
|
fully_frozen: frozenDebugEpoch.fully_frozen,
|
|
resume_acknowledgements: resumedDebugEpoch.acknowledgements.length,
|
|
fully_resumed: resumedDebugEpoch.fully_resumed,
|
|
},
|
|
native_command_debug_epoch: {
|
|
process: abortProcess.process,
|
|
task: abortLaunch.task,
|
|
native_sleep_pid: nativeSleepPid,
|
|
epoch: nativeFreeze.epoch,
|
|
freeze_acknowledgements: nativeFrozen.acknowledgements.length,
|
|
sleep_process_observed_stopped: true,
|
|
resume_acknowledgements: nativeResumed.acknowledgements.length,
|
|
sleep_process_observed_resumed: true,
|
|
},
|
|
multi_participant_debug_epoch: {
|
|
process: multiParticipantProcess.process,
|
|
parent_task: multiParticipantLaunch.task,
|
|
epoch: multiParticipantFreeze.epoch,
|
|
expected_participants: multiParticipantFreeze.affected_tasks.length,
|
|
freeze_acknowledgements:
|
|
multiParticipantFrozen.acknowledgements.length,
|
|
fully_frozen: multiParticipantFrozen.fully_frozen,
|
|
resume_acknowledgements:
|
|
multiParticipantResumed.acknowledgements.length,
|
|
fully_resumed: multiParticipantResumed.fully_resumed,
|
|
parent_result: multiParticipantJoin.join.result,
|
|
},
|
|
aborted_running_task: {
|
|
process: abortProcess.process,
|
|
task: abortLaunch.task,
|
|
terminal_state: abortedNodeRun.terminal_state,
|
|
process_slot_released: afterAbort.processes.length === 0,
|
|
elapsed_ms: abortElapsedMs,
|
|
},
|
|
};
|
|
const reportPath = path.join(repo, "target", "acceptance", "wasmtime-assignment.json");
|
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
console.log(`Wasmtime assignment smoke passed: ${reportPath}`);
|
|
} catch (error) {
|
|
if (coordinatorStderr.trim()) console.error(coordinatorStderr);
|
|
if (workerStderr.trim()) console.error(workerStderr);
|
|
throw error;
|
|
} finally {
|
|
if (projectSourceBeforeEdit !== undefined) {
|
|
fs.writeFileSync(projectSourcePath, projectSourceBeforeEdit);
|
|
}
|
|
stopChild(worker);
|
|
stopChild(coordinator);
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error.stack || error.message);
|
|
process.exit(1);
|
|
});
|