diff --git a/.changeset/quiet-stream-sleepafter.md b/.changeset/quiet-stream-sleepafter.md new file mode 100644 index 000000000..940e8bdae --- /dev/null +++ b/.changeset/quiet-stream-sleepafter.md @@ -0,0 +1,5 @@ +--- +'@cloudflare/sandbox': patch +--- + +Require `@cloudflare/containers` 0.2.2 or newer so long-running streamed commands stay alive past `sleepAfter` while work is still in progress. diff --git a/package-lock.json b/package-lock.json index ce2a3d551..7678b0187 100644 --- a/package-lock.json +++ b/package-lock.json @@ -190,6 +190,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -400,6 +401,7 @@ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "json-schema": "^0.4.0" }, @@ -635,6 +637,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -656,6 +659,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -795,6 +799,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1777,9 +1782,9 @@ } }, "node_modules/@cloudflare/containers": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.2.0.tgz", - "integrity": "sha512-npxGQCHKx5grXnzAHHVAlg6FbyrH07v3COHxXniq7qWfqqIxD64Vrp7Jy9iCPkZamTRfXr0E4Pi0tRIY10Dq9w==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.2.2.tgz", + "integrity": "sha512-cfTSd7M96Z9NeCiaN4i3FuMb+i2Nzi0HbMuxvMAnUmkXwtWIZCQ3XLqUWQkdCKWNJZ/3BLMInC6ntFvpbTWIpA==", "license": "ISC" }, "node_modules/@cloudflare/kv-asset-handler": { @@ -2547,6 +2552,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -2703,7 +2709,8 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260317.1.tgz", "integrity": "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==", "devOptional": true, - "license": "MIT OR Apache-2.0" + "license": "MIT OR Apache-2.0", + "peer": true }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -4102,6 +4109,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -4421,6 +4429,7 @@ "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6334,6 +6343,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6349,6 +6359,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6358,6 +6369,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6483,6 +6495,7 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -6498,6 +6511,7 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -6526,6 +6540,7 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -6699,6 +6714,7 @@ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.79.tgz", "integrity": "sha512-xmZHdJu3g+tbafqU+RDat/cfL4C9UUehjZuwn3+Il88E6T2gZdSRm2h/TV9Txaqj86vz9OSmdrELDUKWJB+Kog==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@ai-sdk/gateway": "3.0.40", "@ai-sdk/provider": "3.0.8", @@ -6717,6 +6733,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7547,6 +7564,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8098,7 +8116,8 @@ "version": "1.0.20", "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/axobject-query": { "version": "4.1.0", @@ -8258,6 +8277,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -10169,16 +10189,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hono": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz", - "integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -10479,6 +10489,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -10749,6 +10760,7 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "devOptional": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -12685,6 +12697,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12930,6 +12943,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12961,6 +12975,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12983,6 +12998,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -13323,6 +13339,7 @@ "integrity": "sha512-ZRLgPlS91l4JztLYEZnmMcd3Umcla1hkXJgiEiR4HloRJBBoeaX8qogTu5Jfu36rRMVLndzqYv0h+M5gJAkUfg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.98.0", "@rolldown/pluginutils": "1.0.0-beta.51" @@ -14293,6 +14310,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14493,6 +14511,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -14667,6 +14686,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14740,6 +14760,7 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "license": "MIT", + "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -15216,6 +15237,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -15350,6 +15372,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15382,6 +15405,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -15807,6 +15831,7 @@ "integrity": "sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -16591,6 +16616,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -16641,7 +16667,7 @@ "version": "0.8.1", "license": "Apache-2.0", "dependencies": { - "@cloudflare/containers": "^0.2.0", + "@cloudflare/containers": "^0.2.2", "aws4fetch": "^1.0.20" }, "devDependencies": { diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 7840c93c0..38c4b7770 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -8,7 +8,7 @@ "description": "A sandboxed environment for running commands", "type": "module", "dependencies": { - "@cloudflare/containers": "^0.2.0", + "@cloudflare/containers": "^0.2.2", "aws4fetch": "^1.0.20" }, "devDependencies": { diff --git a/packages/sandbox/src/clients/transport/ws-transport.ts b/packages/sandbox/src/clients/transport/ws-transport.ts index c97d9324c..a57e1a409 100644 --- a/packages/sandbox/src/clients/transport/ws-transport.ts +++ b/packages/sandbox/src/clients/transport/ws-transport.ts @@ -18,6 +18,7 @@ import type { TransportConfig, TransportMode } from './types'; const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; // 2 minutes for non-streaming requests const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000; // 5 minutes idle timeout for streams const DEFAULT_CONNECT_TIMEOUT_MS = 30_000; // 30 seconds for WebSocket connection +const DEFAULT_IDLE_DISCONNECT_MS = 1_000; // Close idle control socket promptly const MIN_TIME_FOR_CONNECT_RETRY_MS = 15_000; // Need 15s remaining to retry /** @@ -50,6 +51,7 @@ export class WebSocketTransport extends BaseTransport { private state: WSTransportState = 'disconnected'; private pendingRequests: Map = new Map(); private connectPromise: Promise | null = null; + private idleDisconnectTimer: ReturnType | null = null; // Bound event handlers for proper add/remove private boundHandleMessage: (event: MessageEvent) => void; @@ -85,6 +87,8 @@ export class WebSocketTransport extends BaseTransport { * callers share the same connection attempt. */ async connect(): Promise { + this.clearIdleDisconnectTimer(); + // Already connected if (this.isConnected()) { return; @@ -376,6 +380,7 @@ export class WebSocketTransport extends BaseTransport { headers?: Record ): Promise<{ status: number; body: T }> { await this.connect(); + this.clearIdleDisconnectTimer(); const id = generateRequestId(); const request: WSRequest = { @@ -392,6 +397,7 @@ export class WebSocketTransport extends BaseTransport { this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const timeoutId = setTimeout(() => { this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); reject( new Error(`Request timeout after ${timeoutMs}ms: ${method} ${path}`) ); @@ -401,11 +407,13 @@ export class WebSocketTransport extends BaseTransport { resolve: (response: WSResponse) => { clearTimeout(timeoutId); this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); resolve({ status: response.status, body: response.body as T }); }, reject: (error: Error) => { clearTimeout(timeoutId); this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); reject(error); }, isStreaming: false, @@ -417,6 +425,7 @@ export class WebSocketTransport extends BaseTransport { } catch (error) { clearTimeout(timeoutId); this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); reject(error instanceof Error ? error : new Error(String(error))); } }); @@ -443,6 +452,7 @@ export class WebSocketTransport extends BaseTransport { headers?: Record ): Promise> { await this.connect(); + this.clearIdleDisconnectTimer(); const id = generateRequestId(); const request: WSRequest = { @@ -467,6 +477,7 @@ export class WebSocketTransport extends BaseTransport { const createIdleTimeout = (): ReturnType => { return setTimeout(() => { this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); const error = new Error( `Stream idle timeout after ${idleTimeoutMs}ms: ${method} ${path}` ); @@ -506,6 +517,7 @@ export class WebSocketTransport extends BaseTransport { } this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); } }); @@ -516,6 +528,7 @@ export class WebSocketTransport extends BaseTransport { clearTimeout(pending.timeoutId); } this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); if (!firstMessageReceived) { // First message is a final response (not streaming) - this is an error case @@ -554,6 +567,7 @@ export class WebSocketTransport extends BaseTransport { clearTimeout(pending.timeoutId); } this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); if (firstMessageReceived) { try { streamController?.error(error); @@ -594,6 +608,7 @@ export class WebSocketTransport extends BaseTransport { clearTimeout(pending.timeoutId); } this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); } pending.bufferedChunks = undefined; } @@ -608,6 +623,7 @@ export class WebSocketTransport extends BaseTransport { } catch (error) { clearTimeout(timeoutId); this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); rejectStream(error instanceof Error ? error : new Error(String(error))); } }); @@ -742,6 +758,7 @@ export class WebSocketTransport extends BaseTransport { clearTimeout(pending.timeoutId); } this.pendingRequests.delete(chunk.id); + this.scheduleIdleDisconnect(); } } @@ -758,6 +775,7 @@ export class WebSocketTransport extends BaseTransport { this.config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS; pending.timeoutId = setTimeout(() => { this.pendingRequests.delete(id); + this.scheduleIdleDisconnect(); if (pending.streamController) { try { pending.streamController.error( @@ -829,6 +847,8 @@ export class WebSocketTransport extends BaseTransport { * Cleanup resources */ private cleanup(): void { + this.clearIdleDisconnectTimer(); + if (this.ws) { this.ws.removeEventListener('close', this.boundHandleClose); this.ws.removeEventListener('message', this.boundHandleMessage); @@ -845,4 +865,27 @@ export class WebSocketTransport extends BaseTransport { } this.pendingRequests.clear(); } + + private scheduleIdleDisconnect(): void { + if (!this.isConnected() || this.pendingRequests.size > 0) { + return; + } + + this.clearIdleDisconnectTimer(); + this.idleDisconnectTimer = setTimeout(() => { + this.idleDisconnectTimer = null; + + if (this.pendingRequests.size === 0 && this.isConnected()) { + this.logger.debug('Disconnecting idle WebSocket transport'); + this.cleanup(); + } + }, DEFAULT_IDLE_DISCONNECT_MS); + } + + private clearIdleDisconnectTimer(): void { + if (this.idleDisconnectTimer) { + clearTimeout(this.idleDisconnectTimer); + this.idleDisconnectTimer = null; + } + } } diff --git a/tests/e2e/helpers/global-sandbox.ts b/tests/e2e/helpers/global-sandbox.ts index 8ba77283d..e83a1cf14 100644 --- a/tests/e2e/helpers/global-sandbox.ts +++ b/tests/e2e/helpers/global-sandbox.ts @@ -32,6 +32,8 @@ export interface CreateTestSandboxOptions { type?: SandboxType; /** Command to run for initialization. Defaults to 'echo ready'. */ initCommand?: string; + /** sleepAfter value applied to the sandbox for every request in this helper. */ + sleepAfter?: string | number; } /** @@ -41,7 +43,7 @@ export interface CreateTestSandboxOptions { export async function createTestSandbox( options: CreateTestSandboxOptions = {} ): Promise { - const { type = 'default', initCommand = 'echo ready' } = options; + const { type = 'default', initCommand = 'echo ready', sleepAfter } = options; const workerUrl = await getWorkerUrl(); const sandboxId = createSandboxId(); @@ -52,6 +54,9 @@ export async function createTestSandbox( if (type !== 'default') { h['X-Sandbox-Type'] = type; } + if (sleepAfter !== undefined) { + h['X-Sandbox-Sleep-After'] = String(sleepAfter); + } return h; }; diff --git a/tests/e2e/streaming-operations-workflow.test.ts b/tests/e2e/streaming-operations-workflow.test.ts index a6aa7398e..5bc521e9a 100644 --- a/tests/e2e/streaming-operations-workflow.test.ts +++ b/tests/e2e/streaming-operations-workflow.test.ts @@ -8,6 +8,12 @@ import { type TestSandbox } from './helpers/global-sandbox'; +interface SandboxStateResponse { + status: 'healthy' | 'stopped' | 'stopped_with_code' | 'stopping'; + lastChange: number; + exitCode?: number; +} + /** * Streaming Operations Edge Case Tests * @@ -144,6 +150,81 @@ describe('Streaming Operations Edge Cases', () => { expect(completeEvent?.exitCode).toBe(0); }, 15000); + test('should keep a quiet execStream alive past sleepAfter', async () => { + const shortSleepSandbox = await createTestSandbox({ sleepAfter: '3s' }); + + try { + const streamResponse = await fetch( + `${shortSleepSandbox.workerUrl}/api/execStream`, + { + method: 'POST', + headers: shortSleepSandbox.headers(createUniqueSession()), + body: JSON.stringify({ + command: "bash -c 'sleep 5; printf done'" + }) + } + ); + + expect(streamResponse.status).toBe(200); + + const startTime = Date.now(); + const events = await collectSSEEvents(streamResponse, 20); + const duration = Date.now() - startTime; + + expect(duration).toBeGreaterThan(4500); + + const stdout = events + .filter((event) => event.type === 'stdout') + .map((event) => event.data) + .join(''); + const completeEvent = events.find((event) => event.type === 'complete'); + + expect(stdout.trimEnd()).toBe('done'); + expect(completeEvent?.exitCode).toBe(0); + } finally { + await cleanupTestSandbox(shortSleepSandbox); + } + }, 20000); + + test('should still stop idle sandboxes after sleepAfter', async () => { + const shortSleepSandbox = await createTestSandbox({ sleepAfter: '3s' }); + + try { + const execResponse = await fetch( + `${shortSleepSandbox.workerUrl}/api/execute`, + { + method: 'POST', + headers: shortSleepSandbox.headers(createUniqueSession()), + body: JSON.stringify({ command: 'printf idle-check' }) + } + ); + + expect(execResponse.status).toBe(200); + + const stateHeaders = { ...shortSleepSandbox.headers() }; + delete stateHeaders['X-Sandbox-Sleep-After']; + + // Give the alarm loop time to observe idleness and persist the stopped state + // before checking it, without issuing repeated requests that can delay delivery. + await new Promise((resolve) => setTimeout(resolve, 8000)); + + const stateResponse = await fetch( + `${shortSleepSandbox.workerUrl}/api/state`, + { + method: 'GET', + headers: stateHeaders + } + ); + + expect(stateResponse.status).toBe(200); + + const state = (await stateResponse.json()) as SandboxStateResponse; + expect(['stopped', 'stopped_with_code']).toContain(state.status); + } finally { + await cleanupTestSandbox(shortSleepSandbox); + } + }, 30000); + test('should stream file contents', async () => { // Create a test file first const testPath = `/workspace/stream-test-${Date.now()}.txt`; diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index 5599b04fd..f9c0262f5 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -188,6 +188,7 @@ export default { // Check if keepAlive is requested const keepAliveHeader = request.headers.get('X-Sandbox-KeepAlive'); const keepAlive = keepAliveHeader === 'true'; + const sleepAfter = request.headers.get('X-Sandbox-Sleep-After'); // Select sandbox type based on X-Sandbox-Type header const sandboxType = request.headers.get('X-Sandbox-Type'); @@ -207,7 +208,8 @@ export default { } const sandbox = getSandbox(sandboxNamespace, sandboxId, { - keepAlive + keepAlive, + ...(sleepAfter !== null && { sleepAfter }) }); // Get session ID from header (optional) @@ -485,6 +487,13 @@ console.log('Terminal server on port ' + port); }); } + if (url.pathname === '/api/state' && request.method === 'GET') { + const state = await sandbox.getState(); + return new Response(JSON.stringify(state), { + headers: { 'Content-Type': 'application/json' } + }); + } + // Command execution if (url.pathname === '/api/execute' && request.method === 'POST') { const result = await executor.exec(body.command, {