Skip to content

Commit 7f4442b

Browse files
authored
add keepAlive flag to prevent container from shutting down for long processes (#137)
* add auto timeout renewal and some health checks * cleanup the issues * fix worker compat for timeout * add more tests * fix minor nits * more fixes * update pkg * fix the workflow * disable claude reviews (annoying) * Create wet-falcons-hang.md * check for any running processes * test long running process * fix for streaming * nits * fix and neats * use keepAlive flag * cleanup * more nits * cleanup more * nits and bits * more nits and bits * fix pkg workflow * Add keepAlive flag to prevent container shutdown
1 parent 153e416 commit 7f4442b

File tree

11 files changed

+617
-10
lines changed

11 files changed

+617
-10
lines changed

.changeset/wet-falcons-hang.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/sandbox": patch
3+
---
4+
5+
add keepAlive flag to prevent containers from shutting down

.github/workflows/pkg-pr-new.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
pull_request:
99
types: [opened, synchronize, reopened]
1010
paths:
11+
- '**'
1112
- '!**/*.md'
1213
- '!.changeset/**'
1314

packages/sandbox/src/request-handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { createLogger, type LogContext, TraceContext } from "@repo/shared";
21
import { switchPort } from "@cloudflare/containers";
2+
import { createLogger, type LogContext, TraceContext } from "@repo/shared";
33
import { getSandbox, type Sandbox } from "./sandbox";
44
import {
55
sanitizeSandboxId,

packages/sandbox/src/sandbox.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export function getSandbox(
4949
stub.setSleepAfter(options.sleepAfter);
5050
}
5151

52+
if (options?.keepAlive !== undefined) {
53+
stub.setKeepAlive(options.keepAlive);
54+
}
55+
5256
return stub;
5357
}
5458

@@ -64,6 +68,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
6468
private defaultSession: string | null = null;
6569
envVars: Record<string, string> = {};
6670
private logger: ReturnType<typeof createLogger>;
71+
private keepAliveEnabled: boolean = false;
6772

6873
constructor(ctx: DurableObject['ctx'], env: Env) {
6974
super(ctx, env);
@@ -131,6 +136,16 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
131136
this.sleepAfter = sleepAfter;
132137
}
133138

139+
// RPC method to enable keepAlive mode
140+
async setKeepAlive(keepAlive: boolean): Promise<void> {
141+
this.keepAliveEnabled = keepAlive;
142+
if (keepAlive) {
143+
this.logger.info('KeepAlive mode enabled - container will stay alive until explicitly destroyed');
144+
} else {
145+
this.logger.info('KeepAlive mode disabled - container will timeout normally');
146+
}
147+
}
148+
134149
// RPC method to set environment variables
135150
async setEnvVars(envVars: Record<string, string>): Promise<void> {
136151
// Update local state for new sessions
@@ -220,6 +235,22 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
220235
this.logger.error('Sandbox error', error instanceof Error ? error : new Error(String(error)));
221236
}
222237

238+
/**
239+
* Override onActivityExpired to prevent automatic shutdown when keepAlive is enabled
240+
* When keepAlive is disabled, calls parent implementation which stops the container
241+
*/
242+
override async onActivityExpired(): Promise<void> {
243+
if (this.keepAliveEnabled) {
244+
this.logger.debug('Activity expired but keepAlive is enabled - container will stay alive');
245+
// Do nothing - don't call stop(), container stays alive
246+
} else {
247+
// Default behavior: stop the container
248+
this.logger.debug('Activity expired - stopping container');
249+
await super.onActivityExpired();
250+
}
251+
}
252+
253+
223254
// Override fetch to route internal container requests to appropriate ports
224255
override async fetch(request: Request): Promise<Response> {
225256
// Extract or generate trace ID from request
@@ -327,7 +358,6 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
327358
const startTime = Date.now();
328359
const timestamp = new Date().toISOString();
329360

330-
// Handle timeout
331361
let timeoutId: NodeJS.Timeout | undefined;
332362

333363
try {
@@ -592,8 +622,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
592622
};
593623
}
594624

595-
596-
// Streaming methods - return ReadableStream for RPC compatibility
625+
// Streaming methods - return ReadableStream for RPC compatibility
597626
async execStream(command: string, options?: StreamOptions): Promise<ReadableStream<Uint8Array>> {
598627
// Check for cancellation
599628
if (options?.signal?.aborted) {
@@ -617,6 +646,9 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
617646
return this.client.commands.executeStream(command, sessionId);
618647
}
619648

649+
/**
650+
* Stream logs from a background process as a ReadableStream.
651+
*/
620652
async streamProcessLogs(processId: string, options?: { signal?: AbortSignal }): Promise<ReadableStream<Uint8Array>> {
621653
// Check for cancellation
622654
if (options?.signal?.aborted) {

packages/sandbox/tests/get-sandbox.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe('getSandbox', () => {
3030
setSleepAfter: vi.fn((value: string | number) => {
3131
mockStub.sleepAfter = value;
3232
}),
33+
setKeepAlive: vi.fn(),
3334
};
3435

3536
// Mock getContainer to return our stub
@@ -107,4 +108,42 @@ describe('getSandbox', () => {
107108
expect(sandbox.sleepAfter).toBe(timeString);
108109
}
109110
});
111+
112+
it('should apply keepAlive option when provided as true', () => {
113+
const mockNamespace = {} as any;
114+
const sandbox = getSandbox(mockNamespace, 'test-sandbox', {
115+
keepAlive: true,
116+
});
117+
118+
expect(sandbox.setKeepAlive).toHaveBeenCalledWith(true);
119+
});
120+
121+
it('should apply keepAlive option when provided as false', () => {
122+
const mockNamespace = {} as any;
123+
const sandbox = getSandbox(mockNamespace, 'test-sandbox', {
124+
keepAlive: false,
125+
});
126+
127+
expect(sandbox.setKeepAlive).toHaveBeenCalledWith(false);
128+
});
129+
130+
it('should not call setKeepAlive when keepAlive option not provided', () => {
131+
const mockNamespace = {} as any;
132+
getSandbox(mockNamespace, 'test-sandbox');
133+
134+
expect(mockStub.setKeepAlive).not.toHaveBeenCalled();
135+
});
136+
137+
it('should apply keepAlive alongside other options', () => {
138+
const mockNamespace = {} as any;
139+
const sandbox = getSandbox(mockNamespace, 'test-sandbox', {
140+
sleepAfter: '5m',
141+
baseUrl: 'https://example.com',
142+
keepAlive: true,
143+
});
144+
145+
expect(sandbox.sleepAfter).toBe('5m');
146+
expect(sandbox.setBaseUrl).toHaveBeenCalledWith('https://example.com');
147+
expect(sandbox.setKeepAlive).toHaveBeenCalledWith(true);
148+
});
110149
});

packages/sandbox/tests/sandbox.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import { Container } from '@cloudflare/containers';
12
import type { DurableObjectState } from '@cloudflare/workers-types';
23
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
34
import { Sandbox } from '../src/sandbox';
4-
import { Container } from '@cloudflare/containers';
55

66
// Mock dependencies before imports
77
vi.mock('./interpreter', () => ({
@@ -48,7 +48,7 @@ describe('Sandbox - Automatic Session Management', () => {
4848
delete: vi.fn().mockResolvedValue(undefined),
4949
list: vi.fn().mockResolvedValue(new Map()),
5050
} as any,
51-
blockConcurrencyWhile: vi.fn((fn: () => Promise<void>) => fn()),
51+
blockConcurrencyWhile: vi.fn().mockImplementation(<T>(callback: () => Promise<T>): Promise<T> => callback()),
5252
id: {
5353
toString: () => 'test-sandbox-id',
5454
equals: vi.fn(),

packages/shared/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ export type {
5454
ExecOptions,
5555
ExecResult,
5656
ExecutionSession,
57-
FileExistsResult,
5857
// File streaming types
5958
FileChunk,
59+
FileExistsResult,
6060
FileInfo,
6161
FileMetadata,
6262
FileStreamEvent,

packages/shared/src/types.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,13 +262,26 @@ export interface SandboxOptions {
262262
* - A string like "30s", "3m", "5m", "1h" (seconds, minutes, or hours)
263263
* - A number representing seconds (e.g., 180 for 3 minutes)
264264
* Default: "10m" (10 minutes)
265+
*
266+
* Note: Ignored when keepAlive is true
265267
*/
266268
sleepAfter?: string | number;
267269

268270
/**
269271
* Base URL for the sandbox API
270272
*/
271273
baseUrl?: string;
274+
275+
/**
276+
* Keep the container alive indefinitely by preventing automatic shutdown
277+
* When true, the container will never auto-timeout and must be explicitly destroyed
278+
* - Any scenario where activity can't be automatically detected
279+
*
280+
* Important: You MUST call sandbox.destroy() when done to avoid resource leaks
281+
*
282+
* Default: false
283+
*/
284+
keepAlive?: boolean;
272285
}
273286

274287
/**
@@ -590,7 +603,7 @@ export interface ExecutionSession {
590603
// Command execution
591604
exec(command: string, options?: ExecOptions): Promise<ExecResult>;
592605
execStream(command: string, options?: StreamOptions): Promise<ReadableStream<Uint8Array>>;
593-
606+
594607
// Background process management
595608
startProcess(command: string, options?: ProcessOptions): Promise<Process>;
596609
listProcesses(): Promise<Process[]>;
@@ -621,7 +634,7 @@ export interface ExecutionSession {
621634
// Code interpreter methods
622635
createCodeContext(options?: CreateContextOptions): Promise<CodeContext>;
623636
runCode(code: string, options?: RunCodeOptions): Promise<ExecutionResult>;
624-
runCodeStream(code: string, options?: RunCodeOptions): Promise<ReadableStream>;
637+
runCodeStream(code: string, options?: RunCodeOptions): Promise<ReadableStream<Uint8Array>>;
625638
listCodeContexts(): Promise<CodeContext[]>;
626639
deleteCodeContext(contextId: string): Promise<void>;
627640
}

0 commit comments

Comments
 (0)