Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions loadtest/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
package-lock.json
24 changes: 24 additions & 0 deletions loadtest/alpn-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import tls from "node:tls";

Check failure on line 1 in loadtest/alpn-check.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"node:tls"` with `'node:tls'`

const BASE_URL = process.env.RUNLOOP_BASE_URL ?? "https://api.runloop.pro";

Check failure on line 3 in loadtest/alpn-check.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"https://api.runloop.pro"` with `'https://api.runloop.pro'`
const url = new URL(BASE_URL);

console.log(`Checking ALPN for ${url.hostname}:${url.port || 443}`);

const socket = tls.connect(
{
host: url.hostname,
port: parseInt(url.port || "443", 10),

Check failure on line 11 in loadtest/alpn-check.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"443"` with `'443'`
ALPNProtocols: ["h2", "http/1.1"],

Check failure on line 12 in loadtest/alpn-check.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"h2",·"http/1.1"` with `'h2',·'http/1.1'`
servername: url.hostname,
},
() => {
console.log(`Negotiated protocol: ${socket.alpnProtocol}`);
console.log(`TLS version: ${socket.getProtocol()}`);
socket.end();
},
);

socket.on("error", (err) => {

Check failure on line 22 in loadtest/alpn-check.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"error"` with `'error'`
console.error("TLS error:", err.message);

Check failure on line 23 in loadtest/alpn-check.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"TLS·error:"` with `'TLS·error:'`
});
61 changes: 61 additions & 0 deletions loadtest/h2-single-conn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import http2 from "node:http2";

Check failure on line 1 in loadtest/h2-single-conn.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"node:http2"` with `'node:http2'`

const BASE_URL = process.env.RUNLOOP_BASE_URL ?? "https://api.runloop.pro";

Check failure on line 3 in loadtest/h2-single-conn.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"https://api.runloop.pro"` with `'https://api.runloop.pro'`
const API_KEY = process.env.RUNLOOP_API_KEY!;

const body = JSON.stringify({
blueprint_id: "bp_nonexistent_loadtest_00000",

Check failure on line 7 in loadtest/h2-single-conn.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"bp_nonexistent_loadtest_00000"` with `'bp_nonexistent_loadtest_00000'`
name: "loadtest-h2s-0",

Check failure on line 8 in loadtest/h2-single-conn.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `"loadtest-h2s-0"` with `'loadtest-h2s-0'`
environment_variables: { TEST_VAR_1: "value_one" },
launch_parameters: { resource_size_request: "SMALL" },
});

function sendRequest(client: http2.ClientHttp2Session): Promise<{ latencyMs: number; status: number }> {
return new Promise((resolve, reject) => {
const start = performance.now();
const req = client.request({
":method": "POST",
":path": "/v1/devboxes",
"content-type": "application/json",
authorization: `Bearer ${API_KEY}`,
});
req.on("response", (headers) => {
const status = headers[":status"] as number;
req.on("data", () => {});
req.on("end", () => resolve({ latencyMs: performance.now() - start, status }));
});
req.on("error", reject);
req.end(body);
});
}

async function main() {
const url = new URL(BASE_URL);
const client = http2.connect(url.origin);
await new Promise<void>((resolve, reject) => {
client.on("connect", resolve);
client.on("error", reject);
});

const maxStreams = client.remoteSettings?.maxConcurrentStreams;
console.log(`Server MAX_CONCURRENT_STREAMS: ${maxStreams}`);

// Warmup
const w = await sendRequest(client);
console.log(`Warmup: status=${w.status}, latency=${w.latencyMs.toFixed(0)}ms`);

// Burst 50 requests on single warmed connection
const count = 50;
console.log(`\nBursting ${count} requests on 1 warmed connection...`);
const wallStart = performance.now();
const results = await Promise.all(Array.from({ length: count }, () => sendRequest(client)));
const wallMs = performance.now() - wallStart;

const lats = results.map((r) => r.latencyMs).sort((a, b) => a - b);
console.log(`${count} requests in ${wallMs.toFixed(0)}ms (${(count / (wallMs / 1000)).toFixed(1)} req/s)`);
console.log(`Latency: min=${lats[0].toFixed(0)}ms p50=${lats[Math.floor(count / 2)].toFixed(0)}ms max=${lats[lats.length - 1].toFixed(0)}ms`);

client.close();
}

main().catch((e) => { console.error(e); process.exit(1); });
112 changes: 112 additions & 0 deletions loadtest/h2-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import http2 from "node:http2";

const REQUEST_COUNT = parseInt(process.env.REQUEST_COUNT ?? "10000", 10);
const NUM_CONNECTIONS = parseInt(process.env.NUM_CONNECTIONS ?? "10", 10);
const BASE_URL = process.env.RUNLOOP_BASE_URL ?? "https://api.runloop.pro";
const API_KEY = process.env.RUNLOOP_API_KEY!;

const body = JSON.stringify({
blueprint_id: "bp_nonexistent_loadtest_00000",
name: "loadtest-h2-0",
environment_variables: { TEST_VAR_1: "value_one", TEST_VAR_2: "value_two" },
metadata: { test_run: "h2", index: "0" },
launch_parameters: { resource_size_request: "SMALL", keep_alive_time_seconds: 300 },
});

function percentile(sorted: number[], p: number): number {
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, idx)];
}

function connectH2(origin: string): Promise<http2.ClientHttp2Session> {
return new Promise((resolve, reject) => {
const client = http2.connect(origin);
client.on("connect", () => resolve(client));
client.on("error", reject);
});
}

function sendRequest(
client: http2.ClientHttp2Session,
): Promise<{ latencyMs: number; status: number }> {
return new Promise((resolve, reject) => {
const start = performance.now();
const req = client.request({
":method": "POST",
":path": "/v1/devboxes",
"content-type": "application/json",
authorization: `Bearer ${API_KEY}`,
});
req.on("response", (headers) => {
const status = headers[":status"] as number;
req.on("data", () => {});
req.on("end", () => resolve({ latencyMs: performance.now() - start, status }));
});
req.on("error", reject);
req.end(body);
});
}

async function main() {
console.log(
`HTTP/2 test: ${REQUEST_COUNT} requests, ${NUM_CONNECTIONS} connections to ${BASE_URL}`,
);

const url = new URL(BASE_URL);
const clients = await Promise.all(
Array.from({ length: NUM_CONNECTIONS }, () => connectH2(url.origin)),
);

const maxStreams = clients[0].remoteSettings?.maxConcurrentStreams;
Comment on lines +56 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: NUM_CONNECTIONS is fully user-configurable, but zero is not validated; with 0, no clients are created and this access dereferences undefined, crashing before any test runs. Validate that connection count is at least 1 before building clients and reading clients[0]. [incorrect condition logic]

Severity Level: Major ⚠️
- ❌ HTTP/2 baseline script crashes with NUM_CONNECTIONS=0.
- ⚠️ Load testing workflow fails before sending any requests.
Steps of Reproduction ✅
1. In `/workspace/api-client-ts/loadtest/h2-test.ts`, `NUM_CONNECTIONS` is parsed from
`process.env.NUM_CONNECTIONS` at line 4 with default `"10"`.

2. From the `loadtest/` directory, run the HTTP/2 baseline script with zero connections,
e.g. `RUNLOOP_API_KEY=... NUM_CONNECTIONS=0 REQUEST_COUNT=10 npx tsx h2-test.ts`.

3. In `main()` (lines 50–57), `NUM_CONNECTIONS` is 0, so `Array.from({ length:
NUM_CONNECTIONS }, ...)` at line 57 produces an empty array and `Promise.all(...)`
resolves `clients` to `[]`.

4. At line 60, the code reads `clients[0].remoteSettings?.maxConcurrentStreams`; since
`clients[0]` is `undefined`, this throws a `TypeError` and the script crashes before any
HTTP/2 requests are sent or throughput is measured.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** loadtest/h2-test.ts
**Line:** 56:60
**Comment:**
	*Incorrect Condition Logic: `NUM_CONNECTIONS` is fully user-configurable, but zero is not validated; with `0`, no clients are created and this access dereferences `undefined`, crashing before any test runs. Validate that connection count is at least 1 before building clients and reading `clients[0]`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

console.log(`Server MAX_CONCURRENT_STREAMS: ${maxStreams ?? "unknown"}`);
console.log(`${NUM_CONNECTIONS} connections established\n`);

let completed = 0;
const progressTimer = setInterval(() => {
console.log(
` progress: ${completed}/${REQUEST_COUNT} (${((completed / REQUEST_COUNT) * 100).toFixed(1)}%)`,
);
}, 2000);

const wallStart = performance.now();

const promises = Array.from({ length: REQUEST_COUNT }, (_, i) => {
const client = clients[i % NUM_CONNECTIONS];
return sendRequest(client).then((r) => {
completed++;
return r;
});
});

const results = await Promise.all(promises);
const wallMs = performance.now() - wallStart;
clearInterval(progressTimer);

for (const c of clients) c.close();

const latencies = results.map((r) => r.latencyMs).sort((a, b) => a - b);
const statusCounts = new Map<number, number>();
for (const r of results) {
statusCounts.set(r.status, (statusCounts.get(r.status) ?? 0) + 1);
}

console.log(`\n=== HTTP/2 Results ===`);
console.log(`Requests: ${REQUEST_COUNT}`);
console.log(`Connections: ${NUM_CONNECTIONS}`);
console.log(`Wall clock: ${(wallMs / 1000).toFixed(2)}s`);
console.log(`Throughput: ${(REQUEST_COUNT / (wallMs / 1000)).toFixed(1)} req/s`);
console.log(`\nLatency (ms):`);
console.log(` min: ${latencies[0].toFixed(1)}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: If request count is zero, no latency values exist and this call throws because latencies[0] is undefined. Add an empty-result guard before printing latency statistics. [type error]

Severity Level: Major ⚠️
- ❌ HTTP/2 metrics crash when REQUEST_COUNT resolves to zero.
- ⚠️ No summary stats produced for zero-request dry runs.
Steps of Reproduction ✅
1. In `/workspace/api-client-ts/loadtest/h2-test.ts`, `REQUEST_COUNT` is parsed from
`process.env.REQUEST_COUNT` at line 3 with default `"10000"`.

2. From the `loadtest/` directory, run the script with zero requests, e.g.
`RUNLOOP_API_KEY=... REQUEST_COUNT=0 npx tsx h2-test.ts` (or set `REQUEST_COUNT` to a
non-numeric string so `parseInt` yields `NaN`, which produces an array of length 0).

3. In `main()` (lines 71–81), `Array.from({ length: REQUEST_COUNT }, ...)` produces an
empty `promises` array, so `results` resolves to `[]` and `latencies` at line 87 becomes
an empty array after mapping and sorting.

4. At line 99, the script executes `console.log(\` min: ${latencies[0].toFixed(1)}\`);`;
since `latencies[0]` is `undefined`, calling `.toFixed(1)` throws a `TypeError`, causing
the metrics reporting step to crash instead of cleanly handling the zero-request case.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** loadtest/h2-test.ts
**Line:** 99:99
**Comment:**
	*Type Error: If request count is zero, no latency values exist and this call throws because `latencies[0]` is `undefined`. Add an empty-result guard before printing latency statistics.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

console.log(` p50: ${percentile(latencies, 50).toFixed(1)}`);
console.log(` p90: ${percentile(latencies, 90).toFixed(1)}`);
console.log(` p95: ${percentile(latencies, 95).toFixed(1)}`);
console.log(` p99: ${percentile(latencies, 99).toFixed(1)}`);
console.log(` max: ${latencies[latencies.length - 1].toFixed(1)}`);
console.log(`\nStatus codes:`);
for (const [s, c] of [...statusCounts.entries()].sort()) console.log(` ${s}: ${c}`);
}

main().catch((e) => {
console.error(e);
process.exit(1);
});
159 changes: 159 additions & 0 deletions loadtest/loadtest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Runloop } from "@runloop/api-client";

const REQUEST_COUNT = parseInt(process.env.REQUEST_COUNT ?? "100000", 10);
const RUNLOOP_BASE_URL = process.env.RUNLOOP_BASE_URL;
const USE_HTTP2 = process.env.USE_HTTP2 === "1";
const PROGRESS_INTERVAL_MS = 2000;

function buildClient(): Runloop {
const opts: ConstructorParameters<typeof Runloop>[0] = {
maxRetries: 0,
timeout: 120_000,
};
if (RUNLOOP_BASE_URL) {
opts.baseURL = RUNLOOP_BASE_URL;
}
if (USE_HTTP2) {
(opts as any).http2 = true;
}
return new Runloop(opts);
}

interface RequestResult {
index: number;
latencyMs: number;
status: number | null;
error: string | null;
}

async function sendRequest(
client: Runloop,
index: number,
runId: string,
): Promise<RequestResult> {
const start = performance.now();
try {
await client.devboxes.create({
blueprint_id: "bp_nonexistent_loadtest_00000",
name: `loadtest-${runId}-${index}`,
environment_variables: {
TEST_VAR_1: "value_one",
TEST_VAR_2: "value_two",
},
metadata: {
test_run: runId,
index: String(index),
},
launch_parameters: {
resource_size_request: "SMALL",
keep_alive_time_seconds: 300,
},
});
return {
index,
latencyMs: performance.now() - start,
status: 200,
error: null,
};
} catch (err: any) {
return {
index,
latencyMs: performance.now() - start,
status: err?.status ?? null,
error: err?.message ?? String(err),
};
}
}

function percentile(sorted: number[], p: number): number {
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, idx)];
}

function printMetrics(results: RequestResult[], wallClockMs: number): void {
const latencies = results.map((r) => r.latencyMs).sort((a, b) => a - b);

const statusCounts = new Map<string, number>();
for (const r of results) {
const key = r.status != null ? String(r.status) : "network_error";
statusCounts.set(key, (statusCounts.get(key) ?? 0) + 1);
}

console.log("\n=== Load Test Results ===");
console.log(`Requests: ${results.length}`);
console.log(`Wall clock: ${(wallClockMs / 1000).toFixed(2)}s`);
console.log(
`Throughput: ${(results.length / (wallClockMs / 1000)).toFixed(1)} req/s`,
);
console.log("");
console.log("Latency (ms):");
console.log(` min: ${latencies[0].toFixed(1)}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: When REQUEST_COUNT resolves to 0 (or an invalid value that yields zero requests), latencies is empty and this toFixed call throws at runtime because latencies[0] is undefined. Add a guard for empty results before computing percentile/min/max metrics. [type error]

Severity Level: Major ⚠️
- ❌ SDK-level load test crashes for REQUEST_COUNT resolving to zero.
- ⚠️ No metrics summary available for zero-request configuration runs.
Steps of Reproduction ✅
1. In `/workspace/api-client-ts/loadtest/loadtest.ts`, `REQUEST_COUNT` is parsed from
`process.env.REQUEST_COUNT` at line 3 with default `"100000"`.

2. From the `loadtest/` directory, run the SDK-level load test with zero requests, e.g.
`RUNLOOP_API_KEY=... REQUEST_COUNT=0 npm test` (which runs `tsx loadtest.ts`), or set
`REQUEST_COUNT` to a non-numeric string so `parseInt` yields `NaN`.

3. In `main()` at lines 130–139, `Array.from({ length: REQUEST_COUNT }, ...)` evaluates to
an empty array when `REQUEST_COUNT` is 0/NaN, so no `sendRequest()` calls are made and
`results` resolves to `[]`.

4. `printMetrics(results, wallClockMs)` is called at line 144; inside `printMetrics`
(lines 73–100), `latencies` becomes an empty array at line 74, and the log statement at
line 90 executes `latencies[0].toFixed(1)`, which throws a `TypeError` because
`latencies[0]` is `undefined`, causing the load test to crash instead of gracefully
handling the zero-request scenario.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** loadtest/loadtest.ts
**Line:** 90:90
**Comment:**
	*Type Error: When `REQUEST_COUNT` resolves to `0` (or an invalid value that yields zero requests), `latencies` is empty and this `toFixed` call throws at runtime because `latencies[0]` is `undefined`. Add a guard for empty results before computing percentile/min/max metrics.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

console.log(` p50: ${percentile(latencies, 50).toFixed(1)}`);
console.log(` p90: ${percentile(latencies, 90).toFixed(1)}`);
console.log(` p95: ${percentile(latencies, 95).toFixed(1)}`);
console.log(` p99: ${percentile(latencies, 99).toFixed(1)}`);
console.log(` max: ${latencies[latencies.length - 1].toFixed(1)}`);
console.log("");
console.log("Status codes:");
for (const [status, count] of [...statusCounts.entries()].sort()) {
console.log(` ${status}: ${count}`);
}
}

async function main(): Promise<void> {
const fdLimit = await checkFileDescriptorLimit();
if (!USE_HTTP2 && fdLimit < 10000) {
console.warn(
`\nWARNING: File descriptor limit is ${fdLimit}. For 100k HTTP/1.1 requests, run:\n` +
` ulimit -n 65536\n` +
`Or use HTTP/2 multiplexing: USE_HTTP2=1\n`,
);
}

const client = buildClient();
const runId = `run-${Date.now()}`;

console.log(`Starting load test: ${REQUEST_COUNT} concurrent requests`);
console.log(`Run ID: ${runId}`);
console.log(`HTTP mode: ${USE_HTTP2 ? "HTTP/2 (undici)" : "HTTP/1.1 (node-fetch)"}`);
console.log(`Base URL: ${RUNLOOP_BASE_URL ?? "(SDK default)"}`);
console.log(`File descriptor limit: ${fdLimit}`);
console.log("");

let completed = 0;
const progressTimer = setInterval(() => {
console.log(
` progress: ${completed}/${REQUEST_COUNT} (${((completed / REQUEST_COUNT) * 100).toFixed(1)}%)`,
);
}, PROGRESS_INTERVAL_MS);

const wallStart = performance.now();

const promises = Array.from({ length: REQUEST_COUNT }, (_, i) =>
sendRequest(client, i, runId).then((result) => {
completed++;
return result;
}),
);

const results = await Promise.all(promises);

const wallClockMs = performance.now() - wallStart;
clearInterval(progressTimer);

printMetrics(results, wallClockMs);
}

async function checkFileDescriptorLimit(): Promise<number> {
try {
const { execSync } = await import("child_process");
return parseInt(execSync("ulimit -n", { encoding: "utf-8" }).trim(), 10);
} catch {
return -1;
}
}

main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
16 changes: 16 additions & 0 deletions loadtest/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "runloop-loadtest",
"private": true,
"type": "module",
"scripts": {
"test": "tsx loadtest.ts"
},
"dependencies": {
"@runloop/api-client": "file:..",
"tsx": "^4.0.0",
"undici": "^7.26.0"
},
"devDependencies": {
"@types/node": "^25.9.3"
}
}
Loading