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:
DottaandPaperclip committed 2026-10-10 13:59:39 -05:00
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()}` };
+13 -3
View File
@@ -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.