diff --git a/loadtest/cancel-test.ts b/loadtest/cancel-test.ts new file mode 100644 index 000000000..2871ad6c0 --- /dev/null +++ b/loadtest/cancel-test.ts @@ -0,0 +1,81 @@ +/** + * Reproduce the early-cancel SSE bug with HTTP/2: + * Break early from a large SSE stream and confirm no uncaught exceptions crash the process. + */ +import { Runloop } from '../src/sdk.ts'; + +const client = new Runloop({ + bearerToken: process.env['RUNLOOP_API_KEY'] ?? '', + baseURL: process.env['RUNLOOP_BASE_URL'] ?? 'https://api.runloop.pro', + timeout: 120_000, + maxRetries: 0, + http2: true, +}); + +const uncaughtErrors: string[] = []; +process.on('uncaughtException', (e) => { + uncaughtErrors.push(e.message); + console.error('UNCAUGHT:', e.message); +}); +process.on('unhandledRejection', (e) => { + uncaughtErrors.push(String(e)); + console.error('UNHANDLED REJECTION:', e); +}); + +async function main() { + console.log('Creating devbox...'); + const devbox = await client.devboxes.createAndAwaitRunning( + { + name: `cancel-test-${Date.now()}`, + launch_parameters: { resource_size_request: 'X_SMALL', keep_alive_time_seconds: 300 }, + }, + { longPoll: { timeoutMs: 5 * 60 * 1000 } }, + ); + console.log('Devbox:', devbox.id); + + try { + // Produce a large amount of output + const exec = await client.devboxes.executions.executeAsync(devbox.id, { + command: 'seq 1 50000', + }); + await client.devboxes.executions.awaitCompleted(devbox.id, exec.execution_id, { + longPoll: { timeoutMs: 30_000 }, + }); + + // Stream stdout but break immediately after the first event + const stream = await client.devboxes.executions.streamStdoutUpdates(devbox.id, exec.execution_id, {}); + let count = 0; + for await (const _chunk of stream) { + count++; + break; // Cancel early while lots of data remains + } + console.log(`Broke out after ${count} chunk(s)`); + + // Wait for any deferred errors to surface + await new Promise((r) => setTimeout(r, 1000)); + console.log(`After 1s wait, uncaught errors so far: ${uncaughtErrors.length}`); + + // Confirm the h2 pool is still healthy after the cancel + const result = await client.devboxes.executeAndAwaitCompletion(devbox.id, { + command: 'echo still-alive', + }); + console.log('Pool health check after cancel:', result.stdout?.trim()); + + // Wait once more to be sure + await new Promise((r) => setTimeout(r, 500)); + + if (uncaughtErrors.length > 0) { + console.error('FAIL: got uncaught errors after SSE cancel:', uncaughtErrors); + process.exitCode = 1; + } else { + console.log('PASS: no uncaught errors from early SSE cancel'); + } + } finally { + await client.devboxes.shutdown(devbox.id); + } +} + +main().catch((err) => { + console.error('Unhandled error in main:', err); + process.exit(1); +}); diff --git a/loadtest/h2-stress-test.ts b/loadtest/h2-stress-test.ts new file mode 100644 index 000000000..50bdb1c57 --- /dev/null +++ b/loadtest/h2-stress-test.ts @@ -0,0 +1,146 @@ +/** + * H2 stress test: 10 concurrent 20 MB downloads over HTTP/2. + * Verifies the h2-transport pool handles high concurrency without data corruption. + * + * Usage: + * source ~/env && SMOKE_HTTP2=1 npx tsx loadtest/h2-stress-test.ts + */ + +import { createHash } from 'node:crypto'; +import { Runloop } from '../src/sdk.ts'; + +const API_KEY = process.env['RUNLOOP_API_KEY'] ?? ''; +const BASE_URL = process.env['RUNLOOP_BASE_URL'] ?? 'https://api.runloop.pro'; +const USE_HTTP2 = ['1', 'true'].includes((process.env['SMOKE_HTTP2'] ?? '').toLowerCase()); +const CONCURRENCY = parseInt(process.env['CONCURRENCY'] ?? '10', 10); +const FILE_SIZE_MB = parseInt(process.env['FILE_SIZE_MB'] ?? '20', 10); + +if (!API_KEY) { + console.error('RUNLOOP_API_KEY is required'); + process.exit(1); +} + +const client = new Runloop({ + bearerToken: API_KEY, + baseURL: BASE_URL, + timeout: 300_000, + maxRetries: 1, + http2: USE_HTTP2, +}); + +function md5(data: Buffer): string { + return createHash('md5').update(data).digest('hex'); +} + +async function main() { + const transport = USE_HTTP2 ? 'HTTP/2 (h2-transport)' : 'HTTP/1.1 (node-fetch)'; + console.log(`\n=== H2 Stress Test ===`); + console.log(`Transport: ${transport}`); + console.log(`Concurrency: ${CONCURRENCY} simultaneous downloads`); + console.log(`File size: ${FILE_SIZE_MB} MB each`); + console.log(`Total data: ${CONCURRENCY * FILE_SIZE_MB} MB\n`); + + console.log('Creating devbox...'); + const devbox = await client.devboxes.createAndAwaitRunning( + { + name: `h2-stress-${Date.now()}`, + launch_parameters: { + resource_size_request: 'SMALL', + keep_alive_time_seconds: 60 * 15, + }, + }, + { longPoll: { timeoutMs: 10 * 60 * 1000 } }, + ); + const id = devbox.id; + console.log(`Devbox: ${id}\n`); + + try { + // Create test files + console.log(`Creating ${CONCURRENCY} × ${FILE_SIZE_MB} MB files on devbox...`); + await client.devboxes.executeAndAwaitCompletion(id, { + command: `for i in $(seq 1 ${CONCURRENCY}); do dd if=/dev/urandom bs=1M count=${FILE_SIZE_MB} of=/tmp/stress-$i.dat 2>/dev/null; done`, + }); + + const hashResult = await client.devboxes.executeAndAwaitCompletion(id, { + command: 'md5sum /tmp/stress-*.dat', + last_n: String(CONCURRENCY + 5), + }); + + const remoteHashes: Record = {}; + for (const line of (hashResult.stdout ?? '').trim().split('\n')) { + const [hash, path] = line.split(/\s+/); + const name = path?.split('/').pop(); + if (hash && name) remoteHashes[name] = hash; + } + + if (Object.keys(remoteHashes).length !== CONCURRENCY) { + throw new Error( + `Expected ${CONCURRENCY} hashes, got ${Object.keys(remoteHashes).length}:\n${hashResult.stdout}`, + ); + } + console.log(`Files created. Remote hashes verified for ${Object.keys(remoteHashes).length} files.\n`); + + // Concurrent downloads + console.log(`Downloading ${CONCURRENCY} files concurrently...`); + const t0 = Date.now(); + + const downloads = await Promise.all( + Array.from({ length: CONCURRENCY }, (_, i) => i + 1).map((n) => + client.devboxes + .downloadFile(id, { path: `/tmp/stress-${n}.dat` }) + .then((r) => r.arrayBuffer()) + .then((ab) => ({ n, buf: Buffer.from(ab) })), + ), + ); + + const elapsed = (Date.now() - t0) / 1000; + + // Verify each download + let allOk = true; + for (const { n, buf } of downloads) { + const fname = `stress-${n}.dat`; + const expectedSize = FILE_SIZE_MB * 1024 * 1024; + const remoteHash = remoteHashes[fname]; + + if (buf.length !== expectedSize) { + console.error(` ✗ File ${n}: size mismatch — expected ${expectedSize}, got ${buf.length}`); + allOk = false; + continue; + } + + const localHash = md5(buf); + if (localHash !== remoteHash) { + console.error(` ✗ File ${n}: md5 mismatch — remote=${remoteHash} local=${localHash} DATA CORRUPTED`); + allOk = false; + } else { + console.log(` ✓ File ${n}: ${FILE_SIZE_MB} MB, md5 OK`); + } + } + + const totalMB = CONCURRENCY * FILE_SIZE_MB; + const throughput = (totalMB / elapsed).toFixed(1); + console.log( + `\n${CONCURRENCY} downloads completed in ${elapsed.toFixed(1)}s (${throughput} MB/s effective throughput)`, + ); + + if (allOk) { + console.log(`\n=== PASS: all data intact (transport: ${transport}) ===`); + process.exit(0); + } else { + console.error(`\n=== FAIL: data corruption detected (transport: ${transport}) ===`); + process.exit(1); + } + } finally { + console.log('\nShutting down devbox...'); + try { + await client.devboxes.shutdown(id); + } catch { + // ignore + } + } +} + +main().catch((err) => { + console.error('Unhandled error:', err); + process.exit(1); +}); diff --git a/loadtest/large-response-test.ts b/loadtest/large-response-test.ts new file mode 100644 index 000000000..6a795cfdf --- /dev/null +++ b/loadtest/large-response-test.ts @@ -0,0 +1,459 @@ +/** + * Large-response loadtest for the Runloop TypeScript SDK. + * + * Exercises paths stressed by the recent h2-transport changes: + * - Large exec stdout / stderr (multi-MB) + * - Large file round-trip via writeFileContents + readFileContents + * - Binary file download via downloadFile (streaming body path) + * - Concurrent large downloads (exercises h2 pool stream slots) + * - SSE streaming with high-volume output + * - Early SSE cancellation (exercises the ReadableStream cancel path that + * previously caused uncaught exceptions with the h2-transport) + * + * Usage: + * source ~/env && npx tsx loadtest/large-response-test.ts + * # Force http2: + * source ~/env && SMOKE_HTTP2=1 npx tsx loadtest/large-response-test.ts + */ + +import { createHash } from 'node:crypto'; +import { Runloop } from '../src/sdk.ts'; + +const API_KEY = process.env['RUNLOOP_API_KEY'] ?? ''; +const BASE_URL = process.env['RUNLOOP_BASE_URL'] ?? 'https://api.runloop.pro'; +const USE_HTTP2 = ['1', 'true'].includes((process.env['SMOKE_HTTP2'] ?? '').toLowerCase()); + +if (!API_KEY) { + console.error('RUNLOOP_API_KEY is required'); + process.exit(1); +} + +const client = new Runloop({ + bearerToken: API_KEY, + baseURL: BASE_URL, + timeout: 300_000, + maxRetries: 2, + http2: USE_HTTP2, +}); + +// --------------------------------------------------------------------------- +// Failure tracking +// --------------------------------------------------------------------------- + +const failures: string[] = []; + +function pass(name: string, detail = '') { + console.log(` ✓ PASS ${name}${detail ? ` (${detail})` : ''}`); +} + +function fail(name: string, err: unknown) { + failures.push(name); + const msg = err instanceof Error ? err.message : String(err); + console.error(` ✗ FAIL ${name}: ${msg}`); + if (err instanceof Error && err.stack) { + console.error(err.stack.split('\n').slice(1, 4).join('\n')); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function md5(data: string | Buffer): string { + return createHash('md5').update(data).digest('hex'); +} + +/** Read the entire body of a binary Response into a Buffer. */ +async function readBinaryResponse(response: Response): Promise { + const ab = await response.arrayBuffer(); + return Buffer.from(ab); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +async function testLargeExecStdout(devboxId: string) { + const name = 'large exec stdout (~1 MB of base64)'; + try { + // 768 KiB of random bytes → base64 → ~1.02 MB text, no wrapping (-w 0) + const result = await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: 'dd if=/dev/urandom bs=768K count=1 2>/dev/null | base64 -w 0', + }); + + if (result.status !== 'completed') throw new Error(`status=${result.status}`); + const stdout = result.stdout ?? ''; + if (stdout.length < 900_000) throw new Error(`stdout too short: ${stdout.length} bytes (expected ~1 MB)`); + if (/\s/.test(stdout.trim())) throw new Error('stdout contains unexpected whitespace (base64 wrap bug?)'); + + pass(name, `${(stdout.length / 1024).toFixed(0)} KB received`); + } catch (err) { + fail(name, err); + } +} + +async function testVeryLargeExecStdout(devboxId: string) { + const name = 'very large exec stdout (~10 MB of base64)'; + try { + const result = await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: 'dd if=/dev/urandom bs=7680K count=1 2>/dev/null | base64 -w 0', + }); + + if (result.status !== 'completed') throw new Error(`status=${result.status}`); + const stdout = result.stdout ?? ''; + if (stdout.length < 9_000_000) + throw new Error(`stdout too short: ${stdout.length} bytes (expected ~10 MB)`); + + pass(name, `${(stdout.length / 1024 / 1024).toFixed(1)} MB received`); + } catch (err) { + fail(name, err); + } +} + +async function testLargeExecManyLines(devboxId: string) { + // The execute endpoint defaults to returning the last 100 lines (last_n=100). + // We set last_n to 1000 so we get all lines from a 500-line sequence. + const name = 'large exec stdout (500 lines, last_n=1000)'; + try { + const result = await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: 'seq 1 500', + last_n: '1000', + }); + + if (result.status !== 'completed') throw new Error(`status=${result.status}`); + const stdout = result.stdout ?? ''; + const lines = stdout.trim().split('\n'); + if (lines.length !== 500) throw new Error(`expected 500 lines, got ${lines.length}`); + if (lines[0] !== '1') throw new Error(`first line wrong: ${lines[0]}`); + if (lines[499] !== '500') throw new Error(`last line wrong: ${lines[499]}`); + + pass(name, `${lines.length} lines, ${(stdout.length / 1024).toFixed(0)} KB`); + } catch (err) { + fail(name, err); + } +} + +async function testExecLargeStderr(devboxId: string) { + const name = 'large exec stderr (~1 MB)'; + try { + const result = await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: 'dd if=/dev/urandom bs=768K count=1 2>/dev/null | base64 -w 0 >&2', + }); + + if (result.status !== 'completed') throw new Error(`status=${result.status}`); + const stderr = result.stderr ?? ''; + if (stderr.length < 900_000) throw new Error(`stderr too short: ${stderr.length} bytes (expected ~1 MB)`); + + pass(name, `${(stderr.length / 1024).toFixed(0)} KB received on stderr`); + } catch (err) { + fail(name, err); + } +} + +async function testFileRoundTripModerate(devboxId: string) { + const name = 'file round-trip via writeFileContents + readFileContents (512 KB)'; + try { + const line = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\n'; // 63 chars + const content = line.repeat(Math.ceil((512 * 1024) / line.length)).slice(0, 512 * 1024); + const expectedHash = md5(content); + + await client.devboxes.writeFileContents(devboxId, { + file_path: '/tmp/loadtest-roundtrip.txt', + contents: content, + }); + + const readBack = await client.devboxes.readFileContents(devboxId, { + file_path: '/tmp/loadtest-roundtrip.txt', + }); + + if (readBack.length !== content.length) + throw new Error(`length mismatch: wrote ${content.length}, got ${readBack.length}`); + if (md5(readBack) !== expectedHash) throw new Error(`md5 mismatch: data corrupted in transit`); + + pass(name, `${(readBack.length / 1024).toFixed(0)} KB intact`); + } catch (err) { + fail(name, err); + } +} + +async function testLargeReadFileContents(devboxId: string) { + const name = 'readFileContents large file (2 MB text)'; + try { + // Write a 2 MB deterministic text file on the devbox + await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: + 'python3 -c "import sys; [sys.stdout.write(str(i % 10000).zfill(5) + chr(10)) for i in range(400000)]" > /tmp/loadtest-large-read.txt', + }); + const sizeResult = await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: 'wc -c /tmp/loadtest-large-read.txt && md5sum /tmp/loadtest-large-read.txt', + last_n: '5', + }); + const [sizeLine, hashLine] = (sizeResult.stdout ?? '').trim().split('\n'); + const expectedSize = parseInt(sizeLine?.trim().split(/\s+/)[0] ?? '0', 10); + const remoteHash = hashLine?.trim().split(/\s+/)[0] ?? ''; + + if (expectedSize < 2_000_000) throw new Error(`file too small: ${expectedSize}`); + + const content = await client.devboxes.readFileContents(devboxId, { + file_path: '/tmp/loadtest-large-read.txt', + }); + + if (content.length !== expectedSize) + throw new Error(`length mismatch: server=${expectedSize}, received=${content.length}`); + if (md5(content) !== remoteHash) throw new Error(`md5 mismatch: remote=${remoteHash} — DATA CORRUPTED`); + + pass(name, `${(content.length / 1024 / 1024).toFixed(1)} MB received, md5 verified`); + } catch (err) { + fail(name, err); + } +} + +async function testBinaryDownload(devboxId: string) { + const name = 'binary file download via downloadFile (5 MB)'; + try { + const writeResult = await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: + 'dd if=/dev/urandom bs=1M count=5 of=/tmp/loadtest-bin.dat 2>/dev/null && md5sum /tmp/loadtest-bin.dat', + }); + if (writeResult.status !== 'completed') throw new Error(`write status=${writeResult.status}`); + const remoteHash = (writeResult.stdout ?? '').split(/\s+/)[0]; + if (!remoteHash || remoteHash.length !== 32) + throw new Error(`bad md5 from devbox: ${writeResult.stdout}`); + + const response = await client.devboxes.downloadFile(devboxId, { + path: '/tmp/loadtest-bin.dat', + }); + const buf = await readBinaryResponse(response); + + if (buf.length !== 5 * 1024 * 1024) + throw new Error(`size mismatch: expected ${5 * 1024 * 1024}, got ${buf.length}`); + const localHash = md5(buf); + if (localHash !== remoteHash) + throw new Error(`md5 mismatch: remote=${remoteHash} local=${localHash} — DATA CORRUPTED`); + + pass(name, `${(buf.length / 1024 / 1024).toFixed(0)} MB, md5 verified`); + } catch (err) { + fail(name, err); + } +} + +async function testLargeBinaryDownload(devboxId: string) { + const name = 'large binary file download via downloadFile (50 MB)'; + try { + const writeResult = await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: + 'dd if=/dev/urandom bs=1M count=50 of=/tmp/loadtest-large.dat 2>/dev/null && md5sum /tmp/loadtest-large.dat', + }); + if (writeResult.status !== 'completed') throw new Error(`write status=${writeResult.status}`); + const remoteHash = (writeResult.stdout ?? '').split(/\s+/)[0]; + if (!remoteHash || remoteHash.length !== 32) + throw new Error(`bad md5 from devbox: ${writeResult.stdout}`); + + const response = await client.devboxes.downloadFile(devboxId, { + path: '/tmp/loadtest-large.dat', + }); + const buf = await readBinaryResponse(response); + + if (buf.length !== 50 * 1024 * 1024) + throw new Error(`size mismatch: expected ${50 * 1024 * 1024}, got ${buf.length}`); + const localHash = md5(buf); + if (localHash !== remoteHash) + throw new Error(`md5 mismatch: remote=${remoteHash} local=${localHash} — DATA CORRUPTED`); + + pass(name, `${(buf.length / 1024 / 1024).toFixed(0)} MB, md5 verified`); + } catch (err) { + fail(name, err); + } +} + +async function testConcurrentLargeDownloads(devboxId: string) { + const name = 'concurrent large downloads (5 × 5 MB in parallel)'; + try { + await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: + 'for i in $(seq 1 5); do dd if=/dev/urandom bs=1M count=5 of=/tmp/loadtest-concurrent-$i.dat 2>/dev/null; done', + }); + + const hashResult = await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: 'md5sum /tmp/loadtest-concurrent-*.dat', + last_n: '10', + }); + if (hashResult.status !== 'completed') throw new Error('md5sum failed'); + + const remoteHashes: Record = {}; + for (const line of (hashResult.stdout ?? '').trim().split('\n')) { + const [hash, path] = line.split(/\s+/); + const fileName = path?.split('/').pop(); + if (hash && fileName) remoteHashes[fileName] = hash; + } + if (Object.keys(remoteHashes).length !== 5) + throw new Error(`expected 5 hashes, got ${Object.keys(remoteHashes).length}`); + + const downloads = await Promise.all( + [1, 2, 3, 4, 5].map((i) => + client.devboxes + .downloadFile(devboxId, { path: `/tmp/loadtest-concurrent-${i}.dat` }) + .then(readBinaryResponse) + .then((buf) => ({ i, buf })), + ), + ); + + for (const { i, buf } of downloads) { + const fileName = `loadtest-concurrent-${i}.dat`; + const expectedHash = remoteHashes[fileName]; + if (!expectedHash) throw new Error(`no hash for ${fileName}`); + if (buf.length !== 5 * 1024 * 1024) throw new Error(`file ${i}: size mismatch (got ${buf.length})`); + const localHash = md5(buf); + if (localHash !== expectedHash) + throw new Error(`file ${i}: md5 mismatch remote=${expectedHash} local=${localHash} — DATA CORRUPTED`); + } + + pass(name, `5 × 5 MB downloaded and verified`); + } catch (err) { + fail(name, err); + } +} + +async function testSseStreamingLargeOutput(devboxId: string) { + const name = 'SSE streaming with large output (10k lines)'; + try { + const started = await client.devboxes.executions.executeAsync(devboxId, { + command: 'seq 1 10000', + }); + const execId = started.execution_id; + await client.devboxes.executions.awaitCompleted(devboxId, execId, { + longPoll: { timeoutMs: 60_000 }, + }); + + const stream = await client.devboxes.executions.streamStdoutUpdates(devboxId, execId, {}); + let received = ''; + for await (const chunk of stream) { + received += chunk.output ?? ''; + } + + const lines = received.trim().split('\n'); + if (lines.length !== 10000) throw new Error(`expected 10000 lines via SSE, got ${lines.length}`); + if (lines[0] !== '1') throw new Error(`first SSE line wrong: ${JSON.stringify(lines[0])}`); + if (lines[9999] !== '10000') throw new Error(`last SSE line wrong: ${JSON.stringify(lines[9999])}`); + + pass(name, `${lines.length} lines streamed via SSE`); + } catch (err) { + fail(name, err); + } +} + +async function testSseEarlyCancel(devboxId: string) { + // Exercises the ReadableStream cancel() path: break out of the for-await loop + // early while lots of SSE data is still in flight. Before the h2-transport fix, + // this caused controller.enqueue() to throw on a cancelled ReadableStream, + // producing uncaught exceptions that could crash the process. + const name = 'SSE early cancel does not crash the process or poison the h2 pool'; + const uncaughtDuringTest: string[] = []; + const uncaughtHandler = (e: Error) => uncaughtDuringTest.push(e.message); + process.on('uncaughtException', uncaughtHandler); + + try { + const started = await client.devboxes.executions.executeAsync(devboxId, { + command: 'seq 1 50000', // large output — many SSE events in flight when we cancel + }); + await client.devboxes.executions.awaitCompleted(devboxId, started.execution_id, { + longPoll: { timeoutMs: 30_000 }, + }); + + const stream = await client.devboxes.executions.streamStdoutUpdates(devboxId, started.execution_id, {}); + + // Break after the very first event — cancels the ReadableStream immediately + // while the h2 session still has a large backlog of DATA frames buffered. + let count = 0; + for await (const _chunk of stream) { + count++; + break; + } + + // Give the event loop a chance to process any residual data/close events. + await new Promise((r) => setTimeout(r, 500)); + + if (uncaughtDuringTest.length > 0) { + throw new Error( + `${uncaughtDuringTest.length} uncaught exception(s) after cancel: ${uncaughtDuringTest.join('; ')}`, + ); + } + + // Confirm the h2 pool is still usable after the cancel. + const healthCheck = await client.devboxes.executeAndAwaitCompletion(devboxId, { + command: 'echo pool-still-alive', + }); + if (!healthCheck.stdout?.includes('pool-still-alive')) + throw new Error('pool health check failed after SSE cancel'); + + pass(name, `broke after ${count} chunk(s), pool healthy`); + } catch (err) { + fail(name, err); + } finally { + process.off('uncaughtException', uncaughtHandler); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const transport = USE_HTTP2 ? 'HTTP/2 (h2-transport)' : 'HTTP/1.1 (node-fetch)'; + console.log(`\n=== Runloop Large-Response Loadtest ===`); + console.log(`Transport: ${transport}`); + console.log(`API: ${BASE_URL}\n`); + + console.log('Creating devbox...'); + let devboxId: string | undefined; + try { + const devbox = await client.devboxes.createAndAwaitRunning( + { + name: `loadtest-large-${Date.now()}`, + launch_parameters: { resource_size_request: 'SMALL', keep_alive_time_seconds: 60 * 15 }, + }, + { longPoll: { timeoutMs: 10 * 60 * 1000 } }, + ); + devboxId = devbox.id; + console.log(`Devbox ready: ${devboxId}\n`); + } catch (err) { + console.error('Failed to create devbox:', err); + process.exit(1); + } + + try { + await testLargeExecStdout(devboxId); + await testLargeExecManyLines(devboxId); + await testExecLargeStderr(devboxId); + await testFileRoundTripModerate(devboxId); + await testLargeReadFileContents(devboxId); + await testBinaryDownload(devboxId); + await testLargeBinaryDownload(devboxId); + await testConcurrentLargeDownloads(devboxId); + await testSseStreamingLargeOutput(devboxId); + await testSseEarlyCancel(devboxId); + await testVeryLargeExecStdout(devboxId); + } finally { + console.log('\nShutting down devbox...'); + try { + await client.devboxes.shutdown(devboxId); + console.log('Devbox shut down.'); + } catch { + console.warn('Shutdown failed (may already be gone)'); + } + } + + console.log('\n=== Summary ==='); + if (failures.length === 0) { + console.log(`All tests PASSED (transport: ${transport})`); + process.exit(0); + } else { + console.error(`${failures.length} test(s) FAILED (transport: ${transport}): ${failures.join(', ')}`); + process.exit(1); + } +} + +main().catch((err) => { + console.error('Unhandled error:', err); + process.exit(1); +}); diff --git a/src/lib/h2-transport/response.ts b/src/lib/h2-transport/response.ts index 343bcbbd8..1174b2a96 100644 --- a/src/lib/h2-transport/response.ts +++ b/src/lib/h2-transport/response.ts @@ -12,8 +12,7 @@ export class H2Response { readonly headers: H2Headers; readonly body: ReadableStream; - private _bodyConsumed = false; - private _bodyBytes: Buffer | null = null; + private _bodyPromise: Promise | null = null; constructor(status: number, headers: H2Headers, body: ReadableStream, url: string) { this.status = status; @@ -23,21 +22,23 @@ export class H2Response { this.body = body; } - private async _consumeBody(): Promise { - if (this._bodyBytes !== null) return this._bodyBytes; - if (this._bodyConsumed) throw new Error('Body already consumed'); - this._bodyConsumed = true; - - const chunks: Buffer[] = []; - const reader = this.body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(Buffer.isBuffer(value) ? value : Buffer.from(value)); - } - reader.releaseLock(); - this._bodyBytes = Buffer.concat(chunks); - return this._bodyBytes; + private _consumeBody(): Promise { + if (this._bodyPromise) return this._bodyPromise; + this._bodyPromise = (async () => { + const chunks: Buffer[] = []; + const reader = this.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(Buffer.isBuffer(value) ? value : Buffer.from(value)); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks); + })(); + return this._bodyPromise; } async text(): Promise { @@ -52,6 +53,12 @@ export class H2Response { async arrayBuffer(): Promise { const buf = await this._consumeBody(); + // Buffer.concat() always returns a fresh buffer with byteOffset=0 whose + // backing ArrayBuffer is exactly buf.byteLength bytes — no copy needed. + // Cast is safe: Node.js Buffers are always backed by a regular ArrayBuffer. + if (buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength) { + return buf.buffer as ArrayBuffer; + } return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; } diff --git a/src/lib/h2-transport/session.ts b/src/lib/h2-transport/session.ts index eae39a1f4..77e69b996 100644 --- a/src/lib/h2-transport/session.ts +++ b/src/lib/h2-transport/session.ts @@ -19,6 +19,11 @@ export interface H2SessionOptions { const DEFAULT_CONNECT_TIMEOUT = 30_000; +// HTTP/2 default flow-control window is 64 KB per stream. At a typical RTT of +// ~60 ms that caps throughput at ~1 MB/s. Set a much larger window so large +// downloads aren't bottlenecked by round-trip-limited WINDOW_UPDATE cycles. +const DEFAULT_INITIAL_WINDOW_SIZE = 16 * 1024 * 1024; // 16 MB + const createAbortError = (): Error => Object.assign(new Error('Request aborted'), { name: 'AbortError' }); export class H2Session { @@ -56,7 +61,10 @@ export class H2Session { if (this._connectPromise) return this._connectPromise; this._connectPromise = new Promise((resolve, reject) => { - const session = http2.connect(this.origin, this._opts.tlsOptions); + const session = http2.connect(this.origin, { + ...this._opts.tlsOptions, + settings: { initialWindowSize: DEFAULT_INITIAL_WINDOW_SIZE }, + }); this._session = session; const timeout = setTimeout(() => { @@ -68,6 +76,9 @@ export class H2Session { session.on('connect', () => { clearTimeout(timeout); this._state = SessionState.READY; + // Enlarge the session-level flow-control window so the server can push + // large responses without stalling on WINDOW_UPDATE round trips. + session.setLocalWindowSize(DEFAULT_INITIAL_WINDOW_SIZE); resolve(); }); @@ -181,10 +192,20 @@ export class H2Session { const responseBody = new ReadableStream({ start(controller) { stream.on('data', (chunk: Buffer) => { - controller.enqueue(chunk); + try { + controller.enqueue(chunk); + } catch { + // ReadableStream was cancelled while data was still in flight + // (consumer broke out of a for-await loop). Stop the h2 stream. + stream.destroy(); + } }); stream.on('end', () => { - controller.close(); + try { + controller.close(); + } catch { + // Already closed/cancelled — ignore + } cleanup(); }); stream.on('error', (err) => {