diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index f850a302394..eae1c1610d1 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -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", diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.mjs index d75f4bbb6de..48643ea7083 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.mjs @@ -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), + ); } diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs new file mode 100644 index 00000000000..81696b8ba65 --- /dev/null +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -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 }); + } +});