fix: publish staged runner daemon atomically

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-02 05:28:04 -05:00
1 parent 65fd61e6f3
commit 8559cdf370
3 files changed
+113 -16

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
"test": "pnpm run test:typescript && pnpm run test:rust",
"test:typescript": "pnpm run test:typescript:prep && vitest run",
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-package-executable.test.mjs",
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs",
"test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs",
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
@@ -1,24 +1,38 @@
import { execFile } from "node:child_process";
import { chmod, copyFile, mkdir } from "node:fs/promises";
import { constants } from "node:fs";
import { chmod, copyFile, mkdir, mkdtemp, rename, rm } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
const source = path.join(packageRoot, "runner", "target", "release", executable);
const destinationDirectory = path.join(packageRoot, "dist", "bin");
const destination = path.join(destinationDirectory, executable);
export async function stageRunnerBinary(source, destination) {
const destinationDirectory = path.dirname(destination);
await mkdir(destinationDirectory, { recursive: true });
const stagingDirectory = await mkdtemp(path.join(destinationDirectory, ".runnerd-stage-"));
const staged = path.join(stagingDirectory, path.basename(destination));
try {
await copyFile(source, staged, constants.COPYFILE_EXCL);
if (process.platform !== "win32") await chmod(staged, 0o755);
// Sign the new inode before publication. Replacing the pathname atomically
// preserves a running old executable instead of truncating its live inode.
if (process.platform === "darwin") {
await execFileAsync("codesign", ["--force", "--sign", "-", staged], { timeout: 30_000 });
await execFileAsync("codesign", ["--verify", "--strict", staged], { timeout: 30_000 });
}
await rename(staged, destination);
} finally {
// This fresh directory is the only cleanup target, never the destination.
await rm(stagingDirectory, { recursive: true, force: true });
}
}
await mkdir(destinationDirectory, { recursive: true });
await copyFile(source, destination);
if (process.platform !== "win32") await chmod(destination, 0o755);
// Rust's linker emits an ad-hoc Mach-O signature. Copying that executable to
// its package location preserves the bytes but can leave the kernel rejecting
// the new inode with SIGKILL. Re-sign the staged inode so local packaged-runner
// evals execute the same artifact that was just built.
if (process.platform === "darwin") {
await execFileAsync("codesign", ["--force", "--sign", "-", destination]);
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
await stageRunnerBinary(
path.join(packageRoot, "runner", "target", "release", executable),
path.join(packageRoot, "dist", "bin", executable),
);
}
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import { stageRunnerBinary } from "./stage-runner-binary.mjs";
async function withOwnedChildren(root, action) {
const owned = [];
const start = (command, args) => {
const child = spawn(command, args, { env: { PATH: "/usr/bin:/bin" }, stdio: "ignore" });
// close follows both exit and spawn error. A failed second spawn must not
// interrupt retirement of the first still-running executable.
const closed = new Promise(resolve => child.once("close", resolve));
const spawned = once(child, "spawn");
void spawned.catch(() => {});
owned.push({ child, closed });
return { child, spawned, closed };
};
try { return await action(start); }
finally {
try {
const results = await Promise.allSettled(owned.map(async ({ child, closed }) => {
if (child.pid && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
await closed;
}));
const failures = results.filter(result => result.status === "rejected");
if (failures.length) throw new AggregateError(failures.map(result => result.reason), "Owned test child cleanup failed");
} finally { await rm(root, { recursive: true, force: true }); }
}
}
test("atomically publishes while the previous executable is running", { skip: process.platform === "win32", timeout: 15_000 }, async () => {
const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-test-"));
const destination = path.join(root, "paperclip-runnerd");
await withOwnedChildren(root, async start => {
await stageRunnerBinary("/bin/sleep", destination);
const previous = await stat(destination);
const old = start(destination, ["30"]);
await old.spawned;
await stageRunnerBinary("/bin/sleep", destination);
const published = await stat(destination);
assert.notEqual(published.ino, previous.ino);
assert.equal(published.mode & 0o777, 0o755);
assert.equal(old.child.exitCode, null);
assert.equal(old.child.signalCode, null);
assert.deepEqual(await readdir(root), ["paperclip-runnerd"]);
const next = start(destination, ["0"]);
await next.spawned; await next.closed;
assert.equal(next.child.exitCode, 0);
});
});
test("a second spawn error still retires the live old child and removes its fixture", { skip: process.platform === "win32", timeout: 15_000 }, async () => {
const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-spawn-failure-"));
let old;
await assert.rejects(withOwnedChildren(root, async start => {
old = start("/bin/sleep", ["30"]); await old.spawned;
const failed = start(path.join(root, "missing-executable"), []);
await failed.spawned;
}), { code: "ENOENT" });
assert.notEqual(old.child.signalCode, null);
await assert.rejects(stat(root), { code: "ENOENT" });
});
test("failed preparation preserves the published bytes and mode", async () => {
const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-failure-"));
const destination = path.join(root, "paperclip-runnerd");
try {
await writeFile(destination, "previous build", { mode: 0o755 });
const before = await stat(destination);
await assert.rejects(stageRunnerBinary(path.join(root, "absent-source"), destination), { code: "ENOENT" });
assert.equal(await readFile(destination, "utf8"), "previous build");
const after = await stat(destination);
assert.equal(after.ino, before.ino);
assert.equal(after.mode, before.mode);
assert.deepEqual(await readdir(root), ["paperclip-runnerd"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});