Bound the stdio close wait, cutting 5.2s off every call #344

Merged
coilysiren merged 2 commits from stdio-close into main 2026-08-30 05:43:58 +00:00
Owner

Closes #338.

#336 deferred the keep-alive daemon on the condition that per-invocation cold start be measured first. Measured. The daemon is not warranted, because almost all of the cost was ours.

The finding

The SDK's CommandTransport waits TerminateDuration for the child to exit after closing its stdin, defaulting to 5 seconds. A server that does not exit on stdin close makes the caller pay that in full on every invocation - and the MCP reference server does not exit. So every stdio call cost 5.2s, of which 5.0s was this wait.

Setting a 100ms grace takes it to 271ms.

Measurements

p50 of ten runs each, M-series mac:

case before after
--help, contacting nothing 45 ms 45 ms
http upstream on the LAN, no spawn 100 ms 100 ms
stdio, node server.js, package installed 5234 ms 271 ms
stdio, npx -y <package> 6540 ms 1559 ms

The decision

stdio costs roughly 170 ms over the binary's own floor, most of it the node runtime starting. That is well inside the "close this, no daemon" bar #338 set.

The npx figure is npx re-resolving the package on every run, not umbra. The answer there is to name an installed binary in the guardfile rather than npx -y, which is now documented instead of engineered around.

Neither number justifies a supervised background process inside a policy engine.

Why 100ms and not less

The child does not exit when stdin closes, so the grace is paid in full rather than acting as an upper bound. Total is roughly 180ms + grace, measured at 1ms/50ms/200ms. 100ms is the trade: short enough not to dominate, long enough for a well-behaved server to exit cleanly before SIGTERM.

A measurement caveat worth reading

My first before/after run showed no change, which looked like a refuted hypothesis and nearly ended the investigation. It was a stale cache: the specgen build stamp does not hash a --umbra-replace checkout's source, so build reused the old binary. Filed separately as #344, because it silently misleads anyone iterating on umbra itself.

Verified with go test ./..., golangci-lint run ./..., and pre-commit run --all-files.

Closes #338. #336 deferred the keep-alive daemon **on the condition** that per-invocation cold start be measured first. Measured. The daemon is not warranted, because almost all of the cost was ours. ## The finding The SDK's `CommandTransport` waits `TerminateDuration` for the child to exit after closing its stdin, defaulting to **5 seconds**. A server that does not exit on stdin close makes the caller pay that in full on every invocation - and the MCP reference server does not exit. So every stdio call cost 5.2s, of which 5.0s was this wait. Setting a 100ms grace takes it to 271ms. ## Measurements p50 of ten runs each, M-series mac: | case | before | after | | --- | --- | --- | | `--help`, contacting nothing | 45 ms | 45 ms | | http upstream on the LAN, no spawn | 100 ms | 100 ms | | stdio, `node server.js`, package installed | 5234 ms | **271 ms** | | stdio, `npx -y <package>` | 6540 ms | **1559 ms** | ## The decision stdio costs roughly **170 ms over the binary's own floor**, most of it the node runtime starting. That is well inside the "close this, no daemon" bar #338 set. The npx figure is npx re-resolving the package on every run, not umbra. The answer there is to name an installed binary in the guardfile rather than `npx -y`, which is now documented instead of engineered around. Neither number justifies a supervised background process inside a policy engine. ## Why 100ms and not less The child does **not** exit when stdin closes, so the grace is paid in full rather than acting as an upper bound. Total is roughly `180ms + grace`, measured at 1ms/50ms/200ms. 100ms is the trade: short enough not to dominate, long enough for a well-behaved server to exit cleanly before SIGTERM. ## A measurement caveat worth reading My first before/after run showed **no change**, which looked like a refuted hypothesis and nearly ended the investigation. It was a stale cache: the specgen build stamp does not hash a `--umbra-replace` checkout's source, so `build` reused the old binary. Filed separately as #344, because it silently misleads anyone iterating on umbra itself. Verified with `go test ./...`, `golangci-lint run ./...`, and `pre-commit run --all-files`.
perf(mcpclient): bound the stdio close wait, cutting 5.2s off every call
All checks were successful
ci / secrets (pull_request) Successful in 10s
ci / lint (pull_request) Successful in 38s
ci / test (pull_request) Successful in 51s
44f8320d8b
closes #338

#336 deferred the keep-alive daemon on the condition that per-invocation
cold start be measured first. Measured, and the daemon is not warranted:
almost all of the cost was ours.

The SDK's CommandTransport waits TerminateDuration for the child to exit
after closing its stdin, defaulting to 5s. A server that does not exit on
stdin close makes the caller pay that in full, and the MCP reference
server does not exit. Every stdio invocation cost 5.2s of which 5s was
this wait. Setting a 100ms grace takes it to 271ms.

p50 of ten runs each, M-series mac:

  --help, contacting nothing            45 ms
  http upstream on the LAN             100 ms
  stdio, node, package installed       271 ms   (was 5234 ms)
  stdio, npx -y <package>             1559 ms   (was 6540 ms)

So stdio costs ~170ms over the binary's own floor, most of it the node
runtime starting. The npx figure is npx re-resolving the package on every
run: name an installed binary in the guardfile and it goes away. Neither
is a case for a supervised background process inside a policy engine.

The child does not exit on stdin close, so the grace is paid in full
rather than being an upper bound. 100ms is the trade: short enough not to
dominate the call, long enough for a well-behaved server to exit before
SIGTERM.

One measurement note. The first before/after run showed no change, which
looked like a refuted hypothesis. It was a stale cache: the specgen build
stamp does not hash a --umbra-replace checkout's source, so it reused the
old binary. Filed separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
Author
Owner

Correction to the body: the stale-cache defect is #345, not #344. I wrote the number before filing the issue, and #344 turned out to be this pull request itself.

Correction to the body: the stale-cache defect is **#345**, not #344. I wrote the number before filing the issue, and #344 turned out to be this pull request itself.
docs(mcpverb): give the cost measurements their own page
All checks were successful
ci / secrets (pull_request) Successful in 10s
ci / lint (pull_request) Successful in 37s
ci / test (pull_request) Successful in 51s
af8eaf2911
docs/mcpverb.md would breach the 8000-char cap once this branch, mcp-auth,
and served-surface all landed, though each is comfortably under on its own.
Found by merging all five branches into a throwaway and running the hooks
on the result.

The measurements and the no-daemon reasoning move to docs/mcpverb-cost.md,
where the number to beat is written down rather than living in a commit
message nobody re-reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
coilysiren deleted branch stdio-close 2026-08-30 05:43:58 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/umbra!344
No description provided.