Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/add-wait-for-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudflare/sandbox': minor
---

Add waitForExit() method to Process interface for waiting until a process terminates
62 changes: 62 additions & 0 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
SandboxOptions,
SessionOptions,
StreamOptions,
WaitForExitResult,
WaitForLogResult,
WaitForPortOptions
} from '@repo/shared';
Expand Down Expand Up @@ -1263,6 +1264,10 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
options?: WaitForPortOptions
): Promise<void> => {
await this.waitForPortReady(data.id, data.command, port, options);
},

waitForExit: async (timeout?: number): Promise<WaitForExitResult> => {
return this.waitForProcessExit(data.id, data.command, timeout);
}
};
}
Expand Down Expand Up @@ -1521,6 +1526,63 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
}
}

/**
* Wait for a process to exit
* Returns the exit code
*/
private async waitForProcessExit(
processId: string,
command: string,
timeout?: number
): Promise<WaitForExitResult> {
const stream = await this.streamProcessLogs(processId);

// Set up timeout if specified
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let timeoutPromise: Promise<never> | undefined;

if (timeout !== undefined) {
timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
this.createReadyTimeoutError(
processId,
command,
'process exit',
timeout
)
);
}, timeout);
});
}

try {
const streamProcessor = async (): Promise<WaitForExitResult> => {
for await (const event of parseSSEStream<LogEvent>(stream)) {
if (event.type === 'exit') {
return {
exitCode: event.exitCode ?? 1
};
}
}

// Stream ended without exit event - shouldn't happen, but handle gracefully
throw new Error(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use ProcessNotFoundError instead of generic Error for consistency with other process methods.

if (!processInfo) {
  throw new ProcessNotFoundError({
    code: ErrorCode.PROCESS_NOT_FOUND,
    message: `Process ${processId} not found. It may have been cleaned up or never existed.`,
    context: { processId },
    httpStatus: 404,
    timestamp: new Date().toISOString()
  });
}

`Process ${processId} stream ended unexpectedly without exit event`
);
};

if (timeoutPromise) {
return await Promise.race([streamProcessor(), timeoutPromise]);
}
return await streamProcessor();
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}

/**
* Match a pattern against text
*/
Expand Down
140 changes: 140 additions & 0 deletions packages/sandbox/tests/process-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,146 @@ data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"}
});
});

describe('waitForExit() method', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests can be more concise. Lots of overlap with current setup.

it('should resolve when process exits successfully', async () => {
vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({
success: true,
processId: 'proc-build',
pid: 12345,
command: 'npm run build',
timestamp: new Date().toISOString()
} as any);

// Mock stream that emits exit event with code 0
const sseData = `data: {"type":"exit","exitCode":0,"timestamp":"${new Date().toISOString()}"}\n\n`;
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(sseData));
controller.close();
}
});

vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue(
mockStream
);

const proc = await sandbox.startProcess('npm run build');
const result = await proc.waitForExit();

expect(result.exitCode).toBe(0);
});

it('should return non-zero exit code when process fails', async () => {
vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({
success: true,
processId: 'proc-build',
pid: 12345,
command: 'npm run build',
timestamp: new Date().toISOString()
} as any);

// Mock stream that emits exit event with code 1
const sseData = `data: {"type":"exit","exitCode":1,"timestamp":"${new Date().toISOString()}"}\n\n`;
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(sseData));
controller.close();
}
});

vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue(
mockStream
);

const proc = await sandbox.startProcess('npm run build');
const result = await proc.waitForExit();

expect(result.exitCode).toBe(1);
});

it('should wait for exit event in stream', async () => {
vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({
success: true,
processId: 'proc-build',
pid: 12345,
command: 'npm run build',
timestamp: new Date().toISOString()
} as any);

// Mock stream that emits some log events before exit
const sseData = `data: {"type":"stdout","data":"Building...\\n","timestamp":"${new Date().toISOString()}"}\n\ndata: {"type":"stdout","data":"Done!\\n","timestamp":"${new Date().toISOString()}"}\n\ndata: {"type":"exit","exitCode":0,"timestamp":"${new Date().toISOString()}"}\n\n`;
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(sseData));
controller.close();
}
});

vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue(
mockStream
);

const proc = await sandbox.startProcess('npm run build');
const result = await proc.waitForExit();

expect(result.exitCode).toBe(0);
});

it('should throw ProcessReadyTimeoutError when timeout exceeded', async () => {
vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({
success: true,
processId: 'proc-long',
pid: 12345,
command: 'sleep 1000',
timestamp: new Date().toISOString()
} as any);

// Mock stream that never emits exit event
const mockStream = new ReadableStream({
start() {
// Never close - simulates long-running process
}
});

vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue(
mockStream
);

const proc = await sandbox.startProcess('sleep 1000');

await expect(proc.waitForExit(100)).rejects.toThrow(
ProcessReadyTimeoutError
);
});

it('should throw error when stream ends without exit event', async () => {
vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({
success: true,
processId: 'proc-build',
pid: 12345,
command: 'npm run build',
timestamp: new Date().toISOString()
} as any);

// Mock stream that closes without exit event
const mockStream = new ReadableStream({
start(controller) {
controller.close();
}
});

vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue(
mockStream
);

const proc = await sandbox.startProcess('npm run build');

await expect(proc.waitForExit()).rejects.toThrow(
'stream ended unexpectedly'
);
});
});

describe('conditionToString helper', () => {
it('should format string conditions as quoted strings', async () => {
vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export type {
ShutdownResult,
StreamOptions,
// Process readiness types
WaitForExitResult,
WaitForLogResult,
WaitForPortOptions,
WriteFileResult
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ export interface WaitForLogResult {
match?: RegExpMatchArray;
}

/**
* Result from waiting for process exit
*/
export interface WaitForExitResult {
/** Process exit code */
exitCode: number;
}

/**
* Options for waiting for a port to become ready
*/
Expand Down Expand Up @@ -311,6 +319,14 @@ export interface Process {
* await proc.waitForPort(5432, { mode: 'tcp' });
*/
waitForPort(port: number, options?: WaitForPortOptions): Promise<void>;

/**
* Wait for the process to exit
*
* Returns the exit code. Use getProcessLogs() or streamProcessLogs()
* to retrieve output after the process exits.
*/
waitForExit(timeout?: number): Promise<WaitForExitResult>;
}

// Streaming event types
Expand Down
21 changes: 21 additions & 0 deletions tests/e2e/test-worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,27 @@ console.log('Terminal server on port ' + port);
});
}

// Process waitForExit - waits for process to exit
if (
url.pathname.startsWith('/api/process/') &&
url.pathname.endsWith('/waitForExit') &&
request.method === 'POST'
) {
const pathParts = url.pathname.split('/');
const processId = pathParts[3];
const process = await executor.getProcess(processId);
if (!process) {
return new Response(JSON.stringify({ error: 'Process not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
const result = await process.waitForExit(body.timeout);
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' }
});
}

// Process list
if (url.pathname === '/api/process/list' && request.method === 'GET') {
const processes = await executor.listProcesses();
Expand Down
Loading