mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
fix: enforce file operation deadlines inside the remote process group
Apply GNU timeout after SSH admission deducts queue time. Verify a delayed remote-shaped Python write is killed independently of local SSH termination. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ff2598c3d8
commit
47b4bc1d4e
2 files changed
+42
-4
No files matched your search
@@ -56,12 +56,40 @@ describe("Boat transport validation", () => {
|
||||
}));
|
||||
try {
|
||||
await backend.remote(scoped, { action: "read", path: "AGENTS.md" }, { deadlineMs: 1_120_000 });
|
||||
expect(execute).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 30_000 }));
|
||||
expect(execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: "timeout", args: expect.arrayContaining(["--signal=KILL", "30s", "python3"]), timeoutMs: 30_000,
|
||||
}));
|
||||
clock = 1_120_000;
|
||||
await expect(backend.remote(scoped, { action: "write" }, { deadlineMs: clock })).rejects.toThrow("timed out");
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
} finally { time.mockRestore(); sshFactory.mockReset(); }
|
||||
});
|
||||
it("kills the remote Python group at the file deadline, independent of SSH closure", async () => {
|
||||
const temp = realpathSync(mkdtempSync(join(tmpdir(), "boat-file-deadline-")));
|
||||
const lateWrite = join(temp, "late-write");
|
||||
let pid: number | undefined;
|
||||
const scoped = { ...record, id: randomUUID(), providerId: `bx_${randomUUID()}` };
|
||||
const execute = vi.fn(async (input: { command: string; args: string[]; stdin?: string }) => {
|
||||
expect(input.command).toBe("timeout");
|
||||
const args = [...input.args];
|
||||
args[args.length - 1] = `import os,time;print(os.getpid(),flush=True);time.sleep(2);open(${JSON.stringify(lateWrite)},'w').write('late')`;
|
||||
const result = spawnSync(input.command, args, { encoding: "utf8", input: input.stdin });
|
||||
pid = Number(result.stdout.trim());
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr, timedOut: false, signal: result.signal, pid: null, startedAt: "" };
|
||||
});
|
||||
sshFactory.mockReturnValue({ execute });
|
||||
const backend = boatBackend(async () => "fixture", vi.fn(async () => json({ hostKey: "ssh-ed25519 AAAA", sshEndpoint: "fixture.invalid:2222" })));
|
||||
try {
|
||||
await expect(backend.remote(scoped, { action: "read" }, { deadlineMs: Date.now() + 300 })).rejects.toThrow("Computer operation failed");
|
||||
await vi.waitFor(() => expect(() => process.kill(pid!, 0)).toThrow(), { timeout: 2000 });
|
||||
expect(existsSync(lateWrite)).toBe(false);
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
sshFactory.mockReset();
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("expires only the file waiter while shared SSH initialization remains usable", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scoped = { ...record, id: randomUUID(), providerId: `bx_${randomUUID()}` };
|
||||
|
||||
@@ -340,7 +340,7 @@ export function boatBackend(
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
async function runner(record: ComputerRecord, options?: { control?: boolean }): Promise<CommandManagedRuntimeRunner> {
|
||||
async function runner(record: ComputerRecord, options?: { control?: boolean; remoteTimeout?: boolean }): Promise<CommandManagedRuntimeRunner> {
|
||||
const raw = await rawRunner(record);
|
||||
let admission = transportAdmission.get(record.providerId);
|
||||
if (!admission) {
|
||||
@@ -348,7 +348,17 @@ export function boatBackend(
|
||||
transportAdmission.set(record.providerId, admission);
|
||||
}
|
||||
const execute = admission;
|
||||
return { ...raw, execute: (input) => execute(raw, input, options?.control === true) };
|
||||
const bounded = options?.remoteTimeout ? {
|
||||
...raw,
|
||||
execute: (input: CommandInput) => raw.execute({
|
||||
...input,
|
||||
// Admission has already deducted queue time. Bound the remote process
|
||||
// group as well as the local SSH child; socket closure is not stop proof.
|
||||
command: "timeout",
|
||||
args: ["--signal=KILL", `${input.timeoutMs! / 1000}s`, input.command, ...(input.args ?? [])],
|
||||
}),
|
||||
} : raw;
|
||||
return { ...raw, execute: (input) => execute(bounded, input, options?.control === true) };
|
||||
}
|
||||
async function fileTransport(
|
||||
record: ComputerRecord,
|
||||
@@ -356,7 +366,7 @@ export function boatBackend(
|
||||
options?: ComputerOperationOptions,
|
||||
) {
|
||||
remaining(options, 120_000);
|
||||
const pending = runner(record, { control });
|
||||
const pending = runner(record, { control, remoteTimeout: options?.deadlineMs !== undefined });
|
||||
if (options?.deadlineMs === undefined) return pending;
|
||||
// Initialization is shared with unrelated owners. Expire this waiter only;
|
||||
// the cached promise retains its normal success/eviction behavior.
|
||||
|
||||
Reference in new issue
Block a user