Skip to content

Commit f234ba5

Browse files
committed
fix(sandbox): address review feedback on exec cancellation and typing
- collectExec stops delivering onData chunks once the promise has settled, so a timeout/abort rejection is the last thing a streaming caller sees - transport.exec rejects an already-aborted signal before opening the SSH channel, so the remote command never starts - RunCommandOptions is now a blocking/detached union: timeout, signal, and output callbacks no longer type-check with detached: true, and dynamic options resolve to Command | CommandFinished instead of the wrong overload Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrhrNWGpQR9qo7ovxQ13qY
1 parent dca9a9a commit f234ba5

5 files changed

Lines changed: 89 additions & 27 deletions

File tree

packages/sandbox/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ export { Command, CommandFinished, type LogChunk } from "./command.ts";
22
export { CommandTimeoutError, SandboxError } from "./errors.ts";
33
export { Sandbox } from "./sandbox.ts";
44
export type {
5+
BlockingCommandOptions,
56
CreateOptions,
7+
DetachedCommandOptions,
68
FileToWrite,
79
GetOptions,
810
RunCommandOptions,

packages/sandbox/src/sandbox.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
shellQuote,
1515
} from "./sandbox.ts";
1616
import { collectExec, fileEntryFromAttrs } from "./transport.ts";
17+
import type { RunCommandOptions } from "./types.ts";
1718

1819
describe("Sandbox.create", () => {
1920
test("rejects reserved env key AGENT_TOKEN before any network call", async () => {
@@ -44,9 +45,15 @@ describe("Sandbox.runCommand validation", () => {
4445
);
4546
});
4647

48+
// The union type forbids these statically; the casts stand in for
49+
// un-typechecked JS callers, which the runtime guard still protects.
4750
test("rejects timeout combined with detached", async () => {
4851
await expect(
49-
sandbox.runCommand({ cmd: "ls", detached: true, timeout: 5000 }),
52+
sandbox.runCommand({
53+
cmd: "ls",
54+
detached: true,
55+
timeout: 5000,
56+
} as RunCommandOptions),
5057
).rejects.toThrow("not supported with detached");
5158
});
5259

@@ -56,16 +63,24 @@ describe("Sandbox.runCommand validation", () => {
5663
cmd: "ls",
5764
detached: true,
5865
signal: new AbortController().signal,
59-
}),
66+
} as RunCommandOptions),
6067
).rejects.toThrow("not supported with detached");
6168
});
6269

6370
test("rejects output callbacks combined with detached", async () => {
6471
await expect(
65-
sandbox.runCommand({ cmd: "ls", detached: true, onStdout: () => {} }),
72+
sandbox.runCommand({
73+
cmd: "ls",
74+
detached: true,
75+
onStdout: () => {},
76+
} as RunCommandOptions),
6677
).rejects.toThrow("not supported with detached");
6778
await expect(
68-
sandbox.runCommand({ cmd: "ls", detached: true, onStderr: () => {} }),
79+
sandbox.runCommand({
80+
cmd: "ls",
81+
detached: true,
82+
onStderr: () => {},
83+
} as RunCommandOptions),
6984
).rejects.toThrow("not supported with detached");
7085
});
7186
});
@@ -159,6 +174,21 @@ describe("collectExec", () => {
159174
{ stream: "stderr", data: "err" },
160175
]);
161176
});
177+
178+
test("stops streaming to onData once the promise has settled", async () => {
179+
const stream = fakeStream();
180+
const chunks: Array<{ stream: string; data: string }> = [];
181+
const pending = collectExec(stream, {
182+
timeoutMs: 5,
183+
onData: (c) => chunks.push(c),
184+
});
185+
stream.emit("data", Buffer.from("before"));
186+
await expect(pending).rejects.toThrow(CommandTimeoutError);
187+
// Chunks still buffered in the channel arrive after rejection.
188+
stream.emit("data", Buffer.from("after"));
189+
stream.emitStderr(Buffer.from("late-err"));
190+
expect(chunks).toEqual([{ stream: "stdout", data: "before" }]);
191+
});
162192
});
163193

164194
describe("fileEntryFromAttrs", () => {

packages/sandbox/src/sandbox.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import {
2121
} from "./provision.ts";
2222
import { SshTransport } from "./transport.ts";
2323
import type {
24+
BlockingCommandOptions,
2425
CreateOptions,
26+
DetachedCommandOptions,
2527
FileToWrite,
2628
GetOptions,
2729
RunCommandOptions,
@@ -171,16 +173,36 @@ export class Sandbox {
171173

172174
/** Run a command, blocking for the result unless detached is set. */
173175
async runCommand(command: string, args?: string[]): Promise<CommandFinished>;
176+
async runCommand(command: DetachedCommandOptions): Promise<Command>;
177+
async runCommand(command: BlockingCommandOptions): Promise<CommandFinished>;
178+
// Options not statically known to be blocking or detached get the union.
174179
async runCommand(
175-
command: RunCommandOptions & { detached: true },
176-
): Promise<Command>;
177-
async runCommand(command: RunCommandOptions): Promise<CommandFinished>;
180+
command: RunCommandOptions,
181+
): Promise<CommandFinished | Command>;
178182
async runCommand(
179183
command: string | RunCommandOptions,
180184
args: string[] = [],
181185
): Promise<CommandFinished | Command> {
182186
const opts: RunCommandOptions =
183187
typeof command === "string" ? { cmd: command, args } : command;
188+
const remote = buildRemoteCommand(opts);
189+
190+
if (opts.detached) {
191+
// The types forbid this, but un-typechecked callers can still pass blocking-only options.
192+
const stray = opts as unknown as Partial<BlockingCommandOptions>;
193+
if (
194+
stray.timeout !== undefined ||
195+
stray.signal ||
196+
stray.onStdout ||
197+
stray.onStderr
198+
) {
199+
throw new SandboxError(
200+
"timeout, signal, onStdout, and onStderr are not supported with detached; use command.kill() and command.logs().",
201+
);
202+
}
203+
return new Command(await this.transport.execStream(remote));
204+
}
205+
184206
if (
185207
opts.timeout !== undefined &&
186208
(!Number.isFinite(opts.timeout) || opts.timeout <= 0)
@@ -190,19 +212,6 @@ export class Sandbox {
190212
);
191213
}
192214
const { onStdout, onStderr } = opts;
193-
if (
194-
opts.detached &&
195-
(opts.timeout !== undefined || opts.signal || onStdout || onStderr)
196-
) {
197-
throw new SandboxError(
198-
"timeout, signal, onStdout, and onStderr are not supported with detached; use command.kill() and command.logs().",
199-
);
200-
}
201-
const remote = buildRemoteCommand(opts);
202-
203-
if (opts.detached) {
204-
return new Command(await this.transport.execStream(remote));
205-
}
206215
const onData =
207216
onStdout || onStderr
208217
? ({ stream, data }: LogChunk) => {

packages/sandbox/src/transport.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,24 @@ export function collectExec(
4646
): Promise<ExecResult> {
4747
let stdout = "";
4848
let stderr = "";
49+
// Chunks buffered in the channel can still arrive after a timeout/abort
50+
// settles the promise; keep them out of onData once settled.
51+
let settled = false;
4952
stream.on("data", (d: Buffer) => {
5053
const data = d.toString();
5154
stdout += data;
52-
limits.onData?.({ stream: "stdout", data });
55+
if (!settled) limits.onData?.({ stream: "stdout", data });
5356
});
5457
stream.stderr.on("data", (d: Buffer) => {
5558
const data = d.toString();
5659
stderr += data;
57-
limits.onData?.({ stream: "stderr", data });
60+
if (!settled) limits.onData?.({ stream: "stderr", data });
5861
});
5962

6063
return new Promise((resolve, reject) => {
6164
let timer: ReturnType<typeof setTimeout> | undefined;
6265
const cleanup = () => {
66+
settled = true;
6367
clearTimeout(timer);
6468
limits.signal?.removeEventListener("abort", onAbort);
6569
};
@@ -159,6 +163,9 @@ export class SshTransport {
159163

160164
/** Run a command to completion and collect its output. */
161165
async exec(command: string, limits?: ExecLimits): Promise<ExecResult> {
166+
// Don't open a channel (and start the command) for an already-cancelled call.
167+
// An abort mid-open is caught by collectExec, which kills the fresh channel.
168+
limits?.signal?.throwIfAborted();
162169
return collectExec(await this.execStream(command), limits);
163170
}
164171

packages/sandbox/src/types.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,26 +49,40 @@ export interface SandboxHandle {
4949
ports?: Record<number, string>;
5050
}
5151

52-
export interface RunCommandOptions {
52+
interface RunCommandOptionsBase {
5353
cmd: string;
5454
args?: string[];
5555
/** Working directory. Defaults to the sandbox workplace. */
5656
cwd?: string;
5757
/** Extra environment variables for this command only. */
5858
env?: Record<string, string>;
5959
sudo?: boolean;
60-
/** Return immediately with a live Command instead of blocking. */
61-
detached?: boolean;
60+
}
61+
62+
/** Options for a blocking command (the default). */
63+
export interface BlockingCommandOptions extends RunCommandOptionsBase {
64+
detached?: false;
6265
/** Kill the command and reject with CommandTimeoutError after this many milliseconds. */
6366
timeout?: number;
6467
/** Abort to kill the command and reject with the signal's reason. */
6568
signal?: AbortSignal;
66-
/** Called with stdout chunks as they arrive. Blocking mode only. */
69+
/** Called with stdout chunks as they arrive. */
6770
onStdout?: (chunk: string) => void;
68-
/** Called with stderr chunks as they arrive. Blocking mode only. */
71+
/** Called with stderr chunks as they arrive. */
6972
onStderr?: (chunk: string) => void;
7073
}
7174

75+
/**
76+
* Options for a detached command, which returns a live Command immediately.
77+
* A detached command manages its own lifetime — use `command.kill()` and
78+
* `command.logs()` instead of `timeout`/`signal`/output callbacks.
79+
*/
80+
export interface DetachedCommandOptions extends RunCommandOptionsBase {
81+
detached: true;
82+
}
83+
84+
export type RunCommandOptions = BlockingCommandOptions | DetachedCommandOptions;
85+
7286
/** A directory entry returned by listFiles. */
7387
export interface SandboxFileEntry {
7488
name: string;

0 commit comments

Comments
 (0)