Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wet-falcons-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/sandbox": patch
---

add keepAlive flag to prevent containers from shutting down
1 change: 1 addition & 0 deletions .github/workflows/pkg-pr-new.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- '**'
- '!**/*.md'
- '!.changeset/**'

Expand Down
2 changes: 1 addition & 1 deletion packages/sandbox/src/request-handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createLogger, type LogContext, TraceContext } from "@repo/shared";
import { switchPort } from "@cloudflare/containers";
import { createLogger, type LogContext, TraceContext } from "@repo/shared";
import { getSandbox, type Sandbox } from "./sandbox";
import {
sanitizeSandboxId,
Expand Down
38 changes: 35 additions & 3 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export function getSandbox(
stub.setSleepAfter(options.sleepAfter);
}

if (options?.keepAlive !== undefined) {
stub.setKeepAlive(options.keepAlive);
}

return stub;
}

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

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

// RPC method to enable keepAlive mode
async setKeepAlive(keepAlive: boolean): Promise<void> {
this.keepAliveEnabled = keepAlive;
if (keepAlive) {
this.logger.info('KeepAlive mode enabled - container will stay alive until explicitly destroyed');
} else {
this.logger.info('KeepAlive mode disabled - container will timeout normally');
}
}

// RPC method to set environment variables
async setEnvVars(envVars: Record<string, string>): Promise<void> {
// Update local state for new sessions
Expand Down Expand Up @@ -220,6 +235,22 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
this.logger.error('Sandbox error', error instanceof Error ? error : new Error(String(error)));
}

/**
* Override onActivityExpired to prevent automatic shutdown when keepAlive is enabled
* When keepAlive is disabled, calls parent implementation which stops the container
*/
override async onActivityExpired(): Promise<void> {
if (this.keepAliveEnabled) {
this.logger.debug('Activity expired but keepAlive is enabled - container will stay alive');
// Do nothing - don't call stop(), container stays alive
} else {
// Default behavior: stop the container
this.logger.debug('Activity expired - stopping container');
await super.onActivityExpired();
}
}


// Override fetch to route internal container requests to appropriate ports
override async fetch(request: Request): Promise<Response> {
// Extract or generate trace ID from request
Expand Down Expand Up @@ -327,7 +358,6 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
const startTime = Date.now();
const timestamp = new Date().toISOString();

// Handle timeout
let timeoutId: NodeJS.Timeout | undefined;

try {
Expand Down Expand Up @@ -592,8 +622,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
};
}


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

/**
* Stream logs from a background process as a ReadableStream.
*/
async streamProcessLogs(processId: string, options?: { signal?: AbortSignal }): Promise<ReadableStream<Uint8Array>> {
// Check for cancellation
if (options?.signal?.aborted) {
Expand Down
39 changes: 39 additions & 0 deletions packages/sandbox/tests/get-sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('getSandbox', () => {
setSleepAfter: vi.fn((value: string | number) => {
mockStub.sleepAfter = value;
}),
setKeepAlive: vi.fn(),
};

// Mock getContainer to return our stub
Expand Down Expand Up @@ -107,4 +108,42 @@ describe('getSandbox', () => {
expect(sandbox.sleepAfter).toBe(timeString);
}
});

it('should apply keepAlive option when provided as true', () => {
const mockNamespace = {} as any;
const sandbox = getSandbox(mockNamespace, 'test-sandbox', {
keepAlive: true,
});

expect(sandbox.setKeepAlive).toHaveBeenCalledWith(true);
});

it('should apply keepAlive option when provided as false', () => {
const mockNamespace = {} as any;
const sandbox = getSandbox(mockNamespace, 'test-sandbox', {
keepAlive: false,
});

expect(sandbox.setKeepAlive).toHaveBeenCalledWith(false);
});

it('should not call setKeepAlive when keepAlive option not provided', () => {
const mockNamespace = {} as any;
getSandbox(mockNamespace, 'test-sandbox');

expect(mockStub.setKeepAlive).not.toHaveBeenCalled();
});

it('should apply keepAlive alongside other options', () => {
const mockNamespace = {} as any;
const sandbox = getSandbox(mockNamespace, 'test-sandbox', {
sleepAfter: '5m',
baseUrl: 'https://example.com',
keepAlive: true,
});

expect(sandbox.sleepAfter).toBe('5m');
expect(sandbox.setBaseUrl).toHaveBeenCalledWith('https://example.com');
expect(sandbox.setKeepAlive).toHaveBeenCalledWith(true);
});
});
4 changes: 2 additions & 2 deletions packages/sandbox/tests/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Container } from '@cloudflare/containers';
import type { DurableObjectState } from '@cloudflare/workers-types';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Sandbox } from '../src/sandbox';
import { Container } from '@cloudflare/containers';

// Mock dependencies before imports
vi.mock('./interpreter', () => ({
Expand Down Expand Up @@ -48,7 +48,7 @@ describe('Sandbox - Automatic Session Management', () => {
delete: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue(new Map()),
} as any,
blockConcurrencyWhile: vi.fn((fn: () => Promise<void>) => fn()),
blockConcurrencyWhile: vi.fn().mockImplementation(<T>(callback: () => Promise<T>): Promise<T> => callback()),
id: {
toString: () => 'test-sandbox-id',
equals: vi.fn(),
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ export type {
ExecOptions,
ExecResult,
ExecutionSession,
FileExistsResult,
// File streaming types
FileChunk,
FileExistsResult,
FileInfo,
FileMetadata,
FileStreamEvent,
Expand Down
17 changes: 15 additions & 2 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,26 @@ export interface SandboxOptions {
* - A string like "30s", "3m", "5m", "1h" (seconds, minutes, or hours)
* - A number representing seconds (e.g., 180 for 3 minutes)
* Default: "10m" (10 minutes)
*
* Note: Ignored when keepAlive is true
*/
sleepAfter?: string | number;

/**
* Base URL for the sandbox API
*/
baseUrl?: string;

/**
* Keep the container alive indefinitely by preventing automatic shutdown
* When true, the container will never auto-timeout and must be explicitly destroyed
* - Any scenario where activity can't be automatically detected
*
* Important: You MUST call sandbox.destroy() when done to avoid resource leaks
*
* Default: false
*/
keepAlive?: boolean;
}

/**
Expand Down Expand Up @@ -590,7 +603,7 @@ export interface ExecutionSession {
// Command execution
exec(command: string, options?: ExecOptions): Promise<ExecResult>;
execStream(command: string, options?: StreamOptions): Promise<ReadableStream<Uint8Array>>;

// Background process management
startProcess(command: string, options?: ProcessOptions): Promise<Process>;
listProcesses(): Promise<Process[]>;
Expand Down Expand Up @@ -621,7 +634,7 @@ export interface ExecutionSession {
// Code interpreter methods
createCodeContext(options?: CreateContextOptions): Promise<CodeContext>;
runCode(code: string, options?: RunCodeOptions): Promise<ExecutionResult>;
runCodeStream(code: string, options?: RunCodeOptions): Promise<ReadableStream>;
runCodeStream(code: string, options?: RunCodeOptions): Promise<ReadableStream<Uint8Array>>;
listCodeContexts(): Promise<CodeContext[]>;
deleteCodeContext(contextId: string): Promise<void>;
}
Expand Down
Loading
Loading