From 50038fac708da88231646c97f0e0385bf1deeff7 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 6 Nov 2026 12:04:56 -0800 Subject: [PATCH] fix(native-runtime): contain backup verification deadline aborts (#19556) Reject an already-aborted backup verification before constructing its file stream so cancellation does not raise an uncaught AbortError. Validation: 60 focused tests and all applicable CI pass. Greptile scored the exact head 5/5, and no review threads remain unresolved. Co-Authored-By: Paperclip --- .../native-harness-backup-stamp.test.ts | 53 +++++++++++++++++++ .../native-harness-backup-stamp.ts | 1 + 2 files changed, 54 insertions(+) create mode 100644 server/src/services/native-runtime/native-harness-backup-stamp.test.ts diff --git a/server/src/services/native-runtime/native-harness-backup-stamp.test.ts b/server/src/services/native-runtime/native-harness-backup-stamp.test.ts new file mode 100644 index 00000000000..4fd0c40414a --- /dev/null +++ b/server/src/services/native-runtime/native-harness-backup-stamp.test.ts @@ -0,0 +1,53 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createNativeHarnessBackupStamp, verifyNativeHarnessBackupStampAsync } from "./native-harness-backup-stamp.js"; + +const hooks = vi.hoisted(() => ({ afterOpen: undefined as (() => void) | undefined })); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + const open = async (...args: Parameters) => { + const handle = await actual.open(...args); + hooks.afterOpen?.(); + return handle; + }; + return { ...actual, default: { ...actual, open }, open }; +}); + +describe("verifyNativeHarnessBackupStampAsync cancellation", () => { + let base: string; + beforeEach(async () => { + base = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "native-harness-stamp-abort-"))); + vi.stubEnv("PAPERCLIP_RUNNER_STATE_DIR", base); + }); + afterEach(async () => { + hooks.afterOpen = undefined; + vi.unstubAllEnvs(); + await fs.rm(base, { recursive: true, force: true }); + }); + + it("rejects with the signal reason and raises no uncaught AbortError when it aborts before a file read", async () => { + const sessionScopeId = "session-scope", completedAt = "2026-11-06T00:00:00.000Z"; + const current = path.join(base, createHash("sha256").update(sessionScopeId).digest("hex"), "failover-backups", "current"); + await fs.mkdir(path.join(current, "home"), { recursive: true }); + await fs.writeFile(path.join(current, "home", "history.json"), "{}\n"); + await fs.writeFile(path.join(current, "manifest.json"), JSON.stringify({ + schema: "paperclip.native-harness-backup.v1", normalizedSessionId: "session", runnerInstanceId: "runner", + sourceProviderLeaseId: "lease", completedAt, directories: [{ name: "home", sha256: `sha256:${"0".repeat(64)}`, bytes: 3 }], + })); + const stamp = createNativeHarnessBackupStamp({ manifestPath: path.join(current, "manifest.json"), sessionScopeId, + authorizedProviderLeaseId: "lease", normalizedSessionId: "session", runnerInstanceId: "runner", completedAt }); + const controller = new AbortController(), reason = new Error("verification cancelled"); + hooks.afterOpen = () => controller.abort(reason); + const uncaught: unknown[] = []; + const record = (error: unknown) => { uncaught.push(error); }; + process.on("uncaughtException", record); + try { + await expect(verifyNativeHarnessBackupStampAsync(stamp, "lease", controller.signal)).rejects.toBe(reason); + await new Promise(resolve => setTimeout(resolve, 50)); + } finally { process.off("uncaughtException", record); } + expect(uncaught).toEqual([]); + }); +}); diff --git a/server/src/services/native-runtime/native-harness-backup-stamp.ts b/server/src/services/native-runtime/native-harness-backup-stamp.ts index 3d377cc08c5..11b84d80492 100644 --- a/server/src/services/native-runtime/native-harness-backup-stamp.ts +++ b/server/src/services/native-runtime/native-harness-backup-stamp.ts @@ -238,6 +238,7 @@ export async function verifyNativeHarnessBackupStampAsync(value: unknown, expect try { const before = await handle.stat(); if (!before.isFile() || stats.ino !== before.ino || stats.dev !== before.dev || stats.size !== before.size) throw new Error("native_harness_backup_source_changed"); + signal.throwIfAborted(); for await (const chunk of handle.createReadStream({ signal, autoClose: false })) { bytes += chunk.length; if (bytes > directory.bytes) throw new Error("native_harness_backup_size_mismatch"); hash.update(chunk); }