diff --git a/.changeset/wet-falcons-hang.md b/.changeset/wet-falcons-hang.md new file mode 100644 index 00000000..2c520a50 --- /dev/null +++ b/.changeset/wet-falcons-hang.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/sandbox": patch +--- + +add keepAlive flag to prevent containers from shutting down diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 96747b84..f057bdd5 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -8,6 +8,7 @@ on: pull_request: types: [opened, synchronize, reopened] paths: + - '**' - '!**/*.md' - '!.changeset/**' diff --git a/packages/sandbox/src/request-handler.ts b/packages/sandbox/src/request-handler.ts index 9b7a1765..bc0a9aac 100644 --- a/packages/sandbox/src/request-handler.ts +++ b/packages/sandbox/src/request-handler.ts @@ -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, diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 8ea44296..af7a056d 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -49,6 +49,10 @@ export function getSandbox( stub.setSleepAfter(options.sleepAfter); } + if (options?.keepAlive !== undefined) { + stub.setKeepAlive(options.keepAlive); + } + return stub; } @@ -64,6 +68,7 @@ export class Sandbox extends Container implements ISandbox { private defaultSession: string | null = null; envVars: Record = {}; private logger: ReturnType; + private keepAliveEnabled: boolean = false; constructor(ctx: DurableObject['ctx'], env: Env) { super(ctx, env); @@ -131,6 +136,16 @@ export class Sandbox extends Container implements ISandbox { this.sleepAfter = sleepAfter; } + // RPC method to enable keepAlive mode + async setKeepAlive(keepAlive: boolean): Promise { + 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): Promise { // Update local state for new sessions @@ -220,6 +235,22 @@ export class Sandbox extends Container 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 { + 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 { // Extract or generate trace ID from request @@ -327,7 +358,6 @@ export class Sandbox extends Container implements ISandbox { const startTime = Date.now(); const timestamp = new Date().toISOString(); - // Handle timeout let timeoutId: NodeJS.Timeout | undefined; try { @@ -592,8 +622,7 @@ export class Sandbox extends Container implements ISandbox { }; } - - // Streaming methods - return ReadableStream for RPC compatibility +// Streaming methods - return ReadableStream for RPC compatibility async execStream(command: string, options?: StreamOptions): Promise> { // Check for cancellation if (options?.signal?.aborted) { @@ -617,6 +646,9 @@ export class Sandbox extends Container 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> { // Check for cancellation if (options?.signal?.aborted) { diff --git a/packages/sandbox/tests/get-sandbox.test.ts b/packages/sandbox/tests/get-sandbox.test.ts index 6f77f175..15da1fa8 100644 --- a/packages/sandbox/tests/get-sandbox.test.ts +++ b/packages/sandbox/tests/get-sandbox.test.ts @@ -30,6 +30,7 @@ describe('getSandbox', () => { setSleepAfter: vi.fn((value: string | number) => { mockStub.sleepAfter = value; }), + setKeepAlive: vi.fn(), }; // Mock getContainer to return our stub @@ -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); + }); }); diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index c6881cb0..4995856e 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -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', () => ({ @@ -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) => fn()), + blockConcurrencyWhile: vi.fn().mockImplementation((callback: () => Promise): Promise => callback()), id: { toString: () => 'test-sandbox-id', equals: vi.fn(), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e6913625..693e7eff 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -54,9 +54,9 @@ export type { ExecOptions, ExecResult, ExecutionSession, - FileExistsResult, // File streaming types FileChunk, + FileExistsResult, FileInfo, FileMetadata, FileStreamEvent, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 45bb0220..d885425f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -262,6 +262,8 @@ 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; @@ -269,6 +271,17 @@ export interface SandboxOptions { * 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; } /** @@ -590,7 +603,7 @@ export interface ExecutionSession { // Command execution exec(command: string, options?: ExecOptions): Promise; execStream(command: string, options?: StreamOptions): Promise>; - + // Background process management startProcess(command: string, options?: ProcessOptions): Promise; listProcesses(): Promise; @@ -621,7 +634,7 @@ export interface ExecutionSession { // Code interpreter methods createCodeContext(options?: CreateContextOptions): Promise; runCode(code: string, options?: RunCodeOptions): Promise; - runCodeStream(code: string, options?: RunCodeOptions): Promise; + runCodeStream(code: string, options?: RunCodeOptions): Promise>; listCodeContexts(): Promise; deleteCodeContext(contextId: string): Promise; } diff --git a/tests/e2e/keepalive-workflow.test.ts b/tests/e2e/keepalive-workflow.test.ts new file mode 100644 index 00000000..55b117d8 --- /dev/null +++ b/tests/e2e/keepalive-workflow.test.ts @@ -0,0 +1,276 @@ +import { describe, test, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; +import { createSandboxId, createTestHeaders, fetchWithStartup, cleanupSandbox } from './helpers/test-fixtures'; + +/** + * KeepAlive Workflow Integration Tests + * + * Tests the keepAlive feature that keeps containers alive indefinitely: + * - Container stays alive with keepAlive: true + * - Container respects normal timeout without keepAlive + * - Long-running processes work with keepAlive + * - Explicit destroy stops keepAlive container + * + * This validates that: + * - The keepAlive interval properly renews activity timeout + * - Containers don't auto-timeout when keepAlive is enabled + * - Manual cleanup via destroy() works correctly + */ +describe('KeepAlive Workflow', () => { + describe('local', () => { + let runner: WranglerDevRunner | null = null; + let workerUrl: string; + let currentSandboxId: string | null = null; + + beforeAll(async () => { + const result = await getTestWorkerUrl(); + workerUrl = result.url; + runner = result.runner; + }); + + afterEach(async () => { + // Cleanup sandbox container after each test + if (currentSandboxId) { + await cleanupSandbox(workerUrl, currentSandboxId); + currentSandboxId = null; + } + }); + + afterAll(async () => { + // Only stop runner if we spawned one locally (CI uses deployed worker) + if (runner) { + await runner.stop(); + } + }); + + test('should keep container alive with keepAlive enabled', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Add keepAlive header to enable keepAlive mode + const keepAliveHeaders = { + ...headers, + 'X-Sandbox-KeepAlive': 'true', + }; + + // Step 1: Initialize sandbox with keepAlive + const initResponse = await vi.waitFor( + async () => fetchWithStartup(`${workerUrl}/api/execute`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + command: 'echo "Container initialized with keepAlive"', + }), + }), + { timeout: 90000, interval: 2000 } + ); + + expect(initResponse.status).toBe(200); + const initData = await initResponse.json(); + expect(initData.stdout).toContain('Container initialized with keepAlive'); + + // Step 2: Wait longer than normal activity timeout would allow (15 seconds) + // With keepAlive, container should stay alive + await new Promise((resolve) => setTimeout(resolve, 15000)); + + // Step 3: Execute another command to verify container is still alive + const verifyResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + command: 'echo "Still alive after timeout period"', + }), + }); + + expect(verifyResponse.status).toBe(200); + const verifyData = await verifyResponse.json(); + expect(verifyData.stdout).toContain('Still alive after timeout period'); + }, 120000); + + test('should support long-running processes with keepAlive', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + const keepAliveHeaders = { + ...headers, + 'X-Sandbox-KeepAlive': 'true', + }; + + // Start a long sleep process (30 seconds) + const startResponse = await vi.waitFor( + async () => fetchWithStartup(`${workerUrl}/api/process/start`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + command: 'sleep 30', + }), + }), + { timeout: 90000, interval: 2000 } + ); + + expect(startResponse.status).toBe(200); + const startData = await startResponse.json(); + expect(startData.status).toBe('running'); + const processId = startData.id; + + // Wait 20 seconds (longer than normal activity timeout) + await new Promise((resolve) => setTimeout(resolve, 20000)); + + // Verify process is still running + const statusResponse = await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'GET', + headers: keepAliveHeaders, + }); + + expect(statusResponse.status).toBe(200); + const statusData = await statusResponse.json(); + expect(statusData.status).toBe('running'); + + // Cleanup - kill the process + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers: keepAliveHeaders, + }); + }, 120000); + + test('should destroy container when explicitly requested', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + const keepAliveHeaders = { + ...headers, + 'X-Sandbox-KeepAlive': 'true', + }; + + // Step 1: Initialize sandbox with keepAlive + await vi.waitFor( + async () => fetchWithStartup(`${workerUrl}/api/execute`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + command: 'echo "Testing destroy"', + }), + }), + { timeout: 90000, interval: 2000 } + ); + + // Step 2: Explicitly destroy the container + const destroyResponse = await fetch(`${workerUrl}/cleanup`, { + method: 'POST', + headers: keepAliveHeaders, + }); + + expect(destroyResponse.status).toBe(200); + + // Step 3: Verify container was destroyed by trying to execute a command + // This should fail or require re-initialization + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const verifyResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + command: 'echo "After destroy"', + }), + }); + + // Container should be restarted (new container), not the same one + // We can verify by checking that the response is successful but it's a fresh container + expect(verifyResponse.status).toBe(200); + + // Mark as null so afterEach doesn't try to clean it up again + currentSandboxId = null; + }, 120000); + + test('should handle multiple commands with keepAlive over time', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + const keepAliveHeaders = { + ...headers, + 'X-Sandbox-KeepAlive': 'true', + }; + + // Initialize + await vi.waitFor( + async () => fetchWithStartup(`${workerUrl}/api/execute`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + command: 'echo "Command 1"', + }), + }), + { timeout: 90000, interval: 2000 } + ); + + // Execute multiple commands with delays between them + for (let i = 2; i <= 4; i++) { + // Wait 8 seconds between commands (would timeout without keepAlive) + await new Promise((resolve) => setTimeout(resolve, 8000)); + + const response = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + command: `echo "Command ${i}"`, + }), + }); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.stdout).toContain(`Command ${i}`); + } + }, 120000); + + test('should work with file operations while keepAlive is enabled', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + const keepAliveHeaders = { + ...headers, + 'X-Sandbox-KeepAlive': 'true', + }; + + // Initialize + await vi.waitFor( + async () => fetchWithStartup(`${workerUrl}/api/file/write`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + path: '/workspace/test.txt', + content: 'Initial content', + }), + }), + { timeout: 90000, interval: 2000 } + ); + + // Wait longer than normal timeout + await new Promise((resolve) => setTimeout(resolve, 15000)); + + // Perform file operations - should still work + const writeResponse = await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + path: '/workspace/test.txt', + content: 'Updated content after keepAlive', + }), + }); + + expect(writeResponse.status).toBe(200); + + // Read file to verify + const readResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + path: '/workspace/test.txt', + }), + }); + + expect(readResponse.status).toBe(200); + const readData = await readResponse.json(); + expect(readData.content).toContain('Updated content after keepAlive'); + }, 120000); + }); +}); diff --git a/tests/e2e/streaming-operations-workflow.test.ts b/tests/e2e/streaming-operations-workflow.test.ts index df1b96d7..9b47a3e1 100644 --- a/tests/e2e/streaming-operations-workflow.test.ts +++ b/tests/e2e/streaming-operations-workflow.test.ts @@ -444,5 +444,239 @@ describe('Streaming Operations Workflow', () => { const completeEvent = events.find((e) => e.type === 'complete'); expect(completeEvent?.exitCode).toBe(0); }, 90000); + + + test('should handle 15+ second streaming command', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + console.log('[Test] Starting 15+ second streaming command...'); + + // Stream a command that runs for 15+ seconds with output every 2 seconds + const streamResponse = await vi.waitFor( + async () => fetchWithStartup(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: "bash -c 'for i in {1..8}; do echo \"Tick $i at $(date +%s)\"; sleep 2; done; echo \"SUCCESS\"'", + }), + }), + { timeout: 90000, interval: 2000 } + ); + + expect(streamResponse.status).toBe(200); + + const startTime = Date.now(); + const events = await collectSSEEvents(streamResponse, 50); + const duration = Date.now() - startTime; + + console.log(`[Test] Stream completed in ${duration}ms`); + + // Verify command ran for approximately 16 seconds (8 ticks * 2 seconds) + expect(duration).toBeGreaterThan(14000); // At least 14 seconds + expect(duration).toBeLessThan(25000); // But completed (not timed out) + + // Should have received all ticks + const stdoutEvents = events.filter((e) => e.type === 'stdout'); + const output = stdoutEvents.map((e) => e.data).join(''); + + for (let i = 1; i <= 8; i++) { + expect(output).toContain(`Tick ${i}`); + } + expect(output).toContain('SUCCESS'); + + // Most importantly: should complete with exit code 0 (not timeout) + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).toBe(0); + + console.log('[Test] ✅ Streaming command completed successfully after 16+ seconds!'); + }, 90000); + + test('should handle high-volume streaming over extended period', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + console.log('[Test] Starting high-volume streaming test...'); + + // Stream command that generates many lines over 10+ seconds + // Tests throttling: renewActivityTimeout shouldn't be called for every chunk + const streamResponse = await vi.waitFor( + async () => fetchWithStartup(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: "bash -c 'for i in {1..100}; do echo \"Line $i: $(date +%s.%N)\"; sleep 0.1; done'", + }), + }), + { timeout: 90000, interval: 2000 } + ); + + expect(streamResponse.status).toBe(200); + + const events = await collectSSEEvents(streamResponse, 150); + + // Should have many stdout events + const stdoutEvents = events.filter((e) => e.type === 'stdout'); + expect(stdoutEvents.length).toBeGreaterThanOrEqual(50); + + // Verify we got output from beginning and end + const output = stdoutEvents.map((e) => e.data).join(''); + expect(output).toContain('Line 1'); + expect(output).toContain('Line 100'); + + // Should complete successfully + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).toBe(0); + + console.log('[Test] ✅ High-volume streaming completed successfully'); + }, 90000); + + test('should handle streaming with intermittent output gaps', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + console.log('[Test] Starting intermittent output test...'); + + // Command with gaps between output bursts + // Tests that activity renewal works even when output is periodic + const streamResponse = await vi.waitFor( + async () => fetchWithStartup(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: "bash -c 'echo \"Burst 1\"; sleep 3; echo \"Burst 2\"; sleep 3; echo \"Burst 3\"; sleep 3; echo \"Complete\"'", + }), + }), + { timeout: 90000, interval: 2000 } + ); + + expect(streamResponse.status).toBe(200); + + const events = await collectSSEEvents(streamResponse, 30); + + const stdoutEvents = events.filter((e) => e.type === 'stdout'); + const output = stdoutEvents.map((e) => e.data).join(''); + + // All bursts should be received despite gaps + expect(output).toContain('Burst 1'); + expect(output).toContain('Burst 2'); + expect(output).toContain('Burst 3'); + expect(output).toContain('Complete'); + + // Should complete successfully + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).toBe(0); + + console.log('[Test] ✅ Intermittent output handled correctly'); + }, 90000); + + /** + * Test for streaming execution + * This validates that long-running commands work via streaming + */ + test('should handle very long-running commands (60+ seconds) via streaming', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Add keepAlive header to keep container alive during long execution + const keepAliveHeaders = { + ...headers, + 'X-Sandbox-KeepAlive': 'true', + }; + + console.log('[Test] Starting 60+ second command via streaming...'); + + // With streaming, it should complete successfully + const streamResponse = await vi.waitFor( + async () => fetchWithStartup(`${workerUrl}/api/execStream`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + // Command that runs for 60+ seconds with periodic output + command: "bash -c 'for i in {1..12}; do echo \"Minute mark $i\"; sleep 5; done; echo \"COMPLETED\"'", + }), + }), + { timeout: 90000, interval: 2000 } + ); + + expect(streamResponse.status).toBe(200); + + const startTime = Date.now(); + const events = await collectSSEEvents(streamResponse, 100); + const duration = Date.now() - startTime; + + console.log(`[Test] Very long stream completed in ${duration}ms`); + + // Verify command ran for approximately 60 seconds (12 ticks * 5 seconds) + expect(duration).toBeGreaterThan(55000); // At least 55 seconds + expect(duration).toBeLessThan(75000); // But not timed out (under 75s) + + // Should have received all minute marks + const stdoutEvents = events.filter((e) => e.type === 'stdout'); + const output = stdoutEvents.map((e) => e.data).join(''); + + for (let i = 1; i <= 12; i++) { + expect(output).toContain(`Minute mark ${i}`); + } + expect(output).toContain('COMPLETED'); + + // Most importantly: should complete with exit code 0 (not timeout/disconnect) + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).toBe(0); + + console.log('[Test] ✅ Very long-running command completed!'); + }, 90000); + + test('should handle command that sleeps for extended period', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Add keepAlive header to keep container alive during long sleep + const keepAliveHeaders = { + ...headers, + 'X-Sandbox-KeepAlive': 'true', + }; + + console.log('[Test] Testing sleep 45 && echo "done" pattern...'); + + // This is the exact pattern that was failing before + const streamResponse = await vi.waitFor( + async () => fetchWithStartup(`${workerUrl}/api/execStream`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + command: 'sleep 45 && echo "done"', + }), + }), + { timeout: 90000, interval: 2000 } + ); + + expect(streamResponse.status).toBe(200); + + const startTime = Date.now(); + const events = await collectSSEEvents(streamResponse, 20); + const duration = Date.now() - startTime; + + console.log(`[Test] Sleep command completed in ${duration}ms`); + + // Should have taken at least 45 seconds + expect(duration).toBeGreaterThan(44000); + + // Should have the output + const stdoutEvents = events.filter((e) => e.type === 'stdout'); + const output = stdoutEvents.map((e) => e.data).join(''); + expect(output).toContain('done'); + + // Should complete successfully + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).toBe(0); + + console.log('[Test] ✅ Long sleep command completed without disconnect!'); + }, 90000); }); }); diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index bc0a9dc3..3e13e3e3 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -31,7 +31,14 @@ export default { // Get sandbox ID from header // Sandbox ID determines which container instance (Durable Object) const sandboxId = request.headers.get('X-Sandbox-Id') || 'default-test-sandbox'; - const sandbox = getSandbox(env.Sandbox, sandboxId) as Sandbox; + + // Check if keepAlive is requested + const keepAliveHeader = request.headers.get('X-Sandbox-KeepAlive'); + const keepAlive = keepAliveHeader === 'true'; + + const sandbox = getSandbox(env.Sandbox, sandboxId, { + keepAlive, + }) as Sandbox; // Get session ID from header (optional) // If provided, retrieve the session fresh from the Sandbox DO on each request