Skip to content

Commit 126d9c1

Browse files
authored
fix(sandbox): register SIGTERM handler before composite pre-await (#110)
1 parent b6c4cb4 commit 126d9c1

2 files changed

Lines changed: 56 additions & 14 deletions

File tree

packages/core/src/__tests__/sandbox.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,47 @@ describe("runAgentInSandbox — composite orchestration", () => {
12251225
onSpy.mockRestore();
12261226
});
12271227

1228+
it("registers SIGTERM handler before any await in composite pre-work", async () => {
1229+
// In composite mode, sandbox.ts runs `await instance.commands.run("rm -f
1230+
// /tmp/sandcaster-ipc-*.json*")` to clear stale IPC files. If SIGTERM
1231+
// arrives during that await and no handler is registered, Node exits
1232+
// immediately and the sandbox leaks. The handler must be installed
1233+
// before the first awaited operation following sandbox creation.
1234+
const instance = makeCompositeInstance([]);
1235+
registerFakeProvider(instance);
1236+
1237+
const onSpy = vi.spyOn(process, "once");
1238+
1239+
for await (const _ of runAgentInSandbox({
1240+
request: makeRequest({ composite: { maxSandboxes: 2 } }),
1241+
})) {
1242+
// consume
1243+
}
1244+
1245+
const sigtermCall = onSpy.mock.calls.find((call) => call[0] === "SIGTERM");
1246+
expect(sigtermCall).toBeDefined();
1247+
1248+
// Find the IPC cleanup call to instance.commands.run.
1249+
const ipcCleanupCall = instance.commands.run.mock.calls.find(
1250+
(call) =>
1251+
typeof call[0] === "string" && call[0].includes("sandcaster-ipc-"),
1252+
);
1253+
expect(ipcCleanupCall).toBeDefined();
1254+
1255+
const sigtermOrder = onSpy.mock.invocationCallOrder[
1256+
onSpy.mock.calls.findIndex((c) => c[0] === "SIGTERM")
1257+
] as number;
1258+
const ipcCleanupOrder = instance.commands.run.mock.invocationCallOrder[
1259+
instance.commands.run.mock.calls.findIndex(
1260+
(c) => typeof c[0] === "string" && c[0].includes("sandcaster-ipc-"),
1261+
)
1262+
] as number;
1263+
1264+
expect(sigtermOrder).toBeLessThan(ipcCleanupOrder);
1265+
1266+
onSpy.mockRestore();
1267+
});
1268+
12281269
it("registers a SIGTERM handler that kills the instance in non-composite mode", async () => {
12291270
// Without a SIGTERM handler, Node exits immediately on signal and the
12301271
// `finally` block that calls `instance.kill()` never runs, leaving the

packages/core/src/sandbox.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,21 @@ export async function* runAgentInSandbox(
277277
let compositeNonce: string | undefined;
278278
let resolvedComposite: ReturnType<typeof resolveCompositeConfig> | undefined;
279279

280+
// Register SIGTERM handler for graceful cleanup BEFORE any await that
281+
// follows sandbox creation. Required for both modes: without a handler,
282+
// Node exits immediately on SIGTERM and the `finally` block at the bottom
283+
// of this function never runs — leaving the sandbox alive until the
284+
// provider's idle timeout (5 min on E2B). Composite mode kills the whole
285+
// pool (set below); non-composite kills the single instance.
286+
const sigTermHandler: () => Promise<void> = async () => {
287+
if (pool !== undefined) {
288+
await pool.killAll();
289+
} else {
290+
await instance.kill();
291+
}
292+
};
293+
process.once("SIGTERM", sigTermHandler);
294+
280295
if (compositeActive) {
281296
resolvedComposite = resolveCompositeConfig(
282297
config?.composite,
@@ -327,20 +342,6 @@ export async function* runAgentInSandbox(
327342
await instance.commands.run(`rm -f /tmp/sandcaster-ipc-*.json*`);
328343
}
329344

330-
// Register SIGTERM handler for graceful cleanup. Required for both modes:
331-
// without a handler, Node exits immediately on SIGTERM and the `finally`
332-
// block at the bottom of this function never runs — leaving the sandbox
333-
// alive until the provider's idle timeout (5 min on E2B). Composite mode
334-
// kills the whole pool; non-composite kills the single instance.
335-
const sigTermHandler: () => Promise<void> = async () => {
336-
if (pool !== undefined) {
337-
await pool.killAll();
338-
} else {
339-
await instance.kill();
340-
}
341-
};
342-
process.once("SIGTERM", sigTermHandler);
343-
344345
// Runner directory — use instance.workDir so providers with restricted
345346
// filesystems (e.g. Vercel) can write to a writable location.
346347
const runnerDir = `${instance.workDir}/.sandcaster`;

0 commit comments

Comments
 (0)