You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(cli): --load mode for load-testing a tool - #2743
A CLI load mode: --method tools/call --tool-name <name> --load calls one tool repeatedly and concurrently over the connection the CLI already builds (transport, headers, OAuth, catalog settings), then prints one latency / error-rate summary in place of the single result.
toolErrors vs errors.isError: true results (the tool ran and failed) are counted apart from calls that threw (protocol errors, timeouts, a dropped connection). Thrown messages are grouped by frequency. The first 10 distinct ones are kept and the rest fold into (other), so a server that puts a request id in every message cannot grow the tally once per call.
errorRate is rounded to four places, not two, so 23 failures in 41k calls doesn't read as 0.
The tool is resolved once, before the clock starts. A missing tool exits 5 (tool_not_found), like a plain tools/call, instead of reporting a 100% error rate. A completed run exits 0 whatever its error rate, because the summary is the result. Threshold-based exit codes for CI would be a natural follow-up, but they are not part of this PR.
One connection. All calls share one session, so this measures concurrency on a session, not many sessions (README says so). A multi-connection mode is a possible follow-up.
Validation runs before the short-circuit returns, matching --strict/--verify. --load is rejected with any method but tools/call, and with --app-info/--output/--output-format. Each --load-* flag is rejected without --load.
mcpdo: the engine (runLoad, runToolLoad, the flag parsers and resolveLoadOptions) lives in core/cli/handlers/load-test.ts, the surface the CLI shares with mcpdo, so mcpdo can adopt it later without a second copy. Only the one-shot CLI exposes it in this PR, as requested.
Engine unit tests on an injected fake clock: the concurrency cap, the request cap, deadline behavior, even rate spacing, no sleeping past the window, outcome classification, the error-kind cap, four-place rounding, and an empty run.
In-process CLI runs against real test servers:
stdio echo
an HTTP tool that fails on every other call (toolErrors)
the crashable server (errors after the transport dies)
a taskSupport: "required" tool, which goes through the task path
every rejection message
Coverage: load-test.ts 100 / 98.6 / 100 / 100. npm run local:gate is green.
`--load` drives one tool with concurrent, sustained tools/call traffic over
the connection the CLI already sets up (transport, headers, OAuth), then
prints a latency / error-rate summary in place of the single result.
Bounds: --load-concurrency (default 10), --load-duration (default 10s when
--load-requests is unset), --load-requests, and --load-rate (even spacing).
The summary separates isError results (toolErrors) from calls that threw
(errors), groups thrown messages (first ten kinds, rest as "(other)"), and
reports nearest-rank latency percentiles and throughput.
The engine lives in core/cli/handlers/load-test.ts so mcpdo can reuse it
later; only the one-shot CLI exposes it for now.
Closes#2439
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: cliffhall <[email protected]>
…r a wait
The rate limiter scheduled slot i at start + i/rate, so a stall (one slow
call at concurrency 1) left every missed slot owed and they all started at
once afterwards. It now keys off the last slot actually handed out, so a
missed slot is dropped and starts stay at least 1/rate apart.
The deadline is also checked again after the rate wait, since a timer that
wakes late under load would otherwise start a call past --load-duration.
--load is now rejected with --list-stored-auth and --print-handoff, which
return before the load runs. The README notes that a load run does not
prompt for OAuth step-up.
The engine tests' fake clock gains a timer queue so concurrent sleepers
wake in order, as real timers do.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: cliffhall <[email protected]>
Copilot round 1, answered in 9649799 (also replied in each thread):
Auth recovery at the invocation boundary: declined as out of scope. Step-up in the middle of a concurrent run is its own design question. Failures are already counted and their message shows in errorMessages, and the README now gives the workaround (run one plain tools/call first).
Reject with short-circuit modes: fixed. --load now refuses --list-stored-auth and --print-handoff.
Catch-up bursts: fixed. Missed rate slots are dropped, not owed; tests cover a slow call and concurrent workers.
Recheck the deadline after a wake: fixed. A regression test uses a late-firing clock.
Both scheduling fixes were mutation-checked: reverting either one fails only its own test. npm run local:gate is green.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Delayed concurrent timers can start calls together, violating the configured rate limit.
No open issues 🎉
1 previously missed issue
In code that hasn't changed since last review
Concurrent delayed timers can violate load-rate spacing
core/cli/handlers/load-test.ts:241
The single-worker catch-up case is fixed, but concurrent timers that wake late can still violate --load-rate. With concurrency 3, rate 5, three requests and a 2s window, delaying the queued timers until 1000ms produces starts at [0, 1000, 1000], rather than keeping them at least 200ms apart. Workers reserve slots before sleeping and only recheck the deadline afterward, so overdue calls start together. Recheck and claim the shared next-start time after each wake, as below, and add a regression for multiple delayed timers within the window.
Workers reserved their slot before sleeping, so when the event loop stalled
and several timers fired late together, every overdue worker started at once
and --load-rate's spacing collapsed. A slot is now claimed only by a worker
that is awake at its time, and each wake re-checks the bounds; the rest go
back to waiting.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: cliffhall <[email protected]>
Copilot round 2, answered in bc6db3e (the finding was in the "previously missed" block, so it has no thread to reply in):
Concurrent delayed timers can violate --load-rate spacing: fixed. Workers no longer reserve a slot before sleeping. A slot is claimed only by a worker that is awake at its time, and every wake re-checks the bounds, so when several timers fire late together the first one to run takes the slot and the rest go back to waiting. The claim is still synchronous, so no two workers can take the same slot. Your scenario is the regression test: concurrency 3, rate 5, three requests, a 2s window, and a clock whose timers can't fire before 1000ms. It now starts at [0, 1000, 1200]. Restoring reserve-before-sleep fails only that test.
Every idle worker armed a timer for the same next slot and all but one
re-armed on waking: 9,900 timers for 100 calls at concurrency 100. Workers
now queue on a FIFO turnstile and only the head waits on the clock, which
brings that to 99.
A claim also reset the schedule from the wake time, so ordinary timer
lateness accumulated and the run fell short of the rate (176 of 200 calls in
a 1s run at 200/s). A wake that is late by less than one interval now keeps
the slot grid; only a slot missed entirely is dropped, so there is still no
catch-up burst after a stall. The README notes the ~1ms timer ceiling that
limits rates in the high hundreds per second.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: cliffhall <[email protected]>
Copilot round 3, answered in 4e13034 (also replied in each thread):
One timer per slot: fixed. A FIFO turnstile means only the head of the queue waits on the clock: 99 timers for 100 calls at concurrency 100, down from 9,900. Mutation-checked.
Average rate under timer lateness: fixed too. I found this while measuring the change above. Small lateness no longer pushes the schedule back, and a slot missed entirely is still dropped. 50/s and 200/s now hit their targets exactly, and the README documents the ~1ms timer ceiling.
Recheck the deadline after the claim: declined. The scenario needs a tool call that blocks the event loop for 100ms, and callTool blocks for microseconds.
Copilot review loop closed: round 4 was clean on all three channels ("Approval recommended", no open, missed or suppressed findings, no inline comments). Across four rounds there were seven findings: five fixed, plus one more found while measuring a fix, and two declined as out of scope (in-run OAuth step-up, and a deadline recheck that only matters for a tool call that blocks the event loop synchronously).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2439
What
A CLI load mode:
--method tools/call --tool-name <name> --loadcalls one tool repeatedly and concurrently over the connection the CLI already builds (transport, headers, OAuth, catalog settings), then prints one latency / error-rate summary in place of the single result.--loadtools/callonly)--load-concurrency <n>--load-duration <sec>--load-requestsis unset)--load-requests <n>--load-rate <n>Real output against the stdio test server (
echo, 200 calls, concurrency 20):{ "tool": "echo", "concurrency": 20, "requestLimit": 200, "requests": 200, "ok": 200, "toolErrors": 0, "errors": 0, "errorRate": 0, "elapsedMs": 8.74, "throughput": 22887.99, "latencyMs": { "min": 0.4, "mean": 0.76, "p50": 0.62, "p90": 1.38, "p99": 2.35, "max": 2.39 }, "errorMessages": [] }Design notes
toolErrorsvserrors.isError: trueresults (the tool ran and failed) are counted apart from calls that threw (protocol errors, timeouts, a dropped connection). Thrown messages are grouped by frequency. The first 10 distinct ones are kept and the rest fold into(other), so a server that puts a request id in every message cannot grow the tally once per call.errorRateis rounded to four places, not two, so 23 failures in 41k calls doesn't read as0.5(tool_not_found), like a plaintools/call, instead of reporting a 100% error rate. A completed run exits0whatever its error rate, because the summary is the result. Threshold-based exit codes for CI would be a natural follow-up, but they are not part of this PR.--strict/--verify.--loadis rejected with any method buttools/call, and with--app-info/--output/--output-format. Each--load-*flag is rejected without--load.runLoad,runToolLoad, the flag parsers andresolveLoadOptions) lives incore/cli/handlers/load-test.ts, the surface the CLI shares with mcpdo, so mcpdo can adopt it later without a second copy. Only the one-shot CLI exposes it in this PR, as requested.Tests
clients/cli/__tests__/load-test.test.ts(30 tests):echotoolErrors)errorsafter the transport dies)taskSupport: "required"tool, which goes through the task pathCoverage:
load-test.ts100 / 98.6 / 100 / 100.npm run local:gateis green.No UI change, so no screenshots.
🤖 Generated with Claude Code