anthropics / anthropics/claude-agent-sdk-typescript
libc probe calls process.report.getReport() per query(), blocking the event loop 0.5–5s on Linux
- Lingua principale
- Shell
- Stelle
- 1.8k
- Fork
- 226
- Metriche di merge delle PR
- Nessuna PR unita negli ultimi 30g
Descrizione
## Summary
On Linux the SDK detects glibc-vs-musl by calling `process.report.getReport()` (to read `header.glibcVersionRuntime`) so it can pick the correct bundled `claude` binary. That probe is wired as a **default parameter of the binary-resolver**, so it runs **once per `query()`** — i.e. on *every* prompt/turn, not once per process.
`process.report.getReport()` is expensive: it synchronously walks the entire heap, walks every libuv handle (each open socket triggers `getpeername`/`getsockname`), and enumerates **all** network interfaces. On a busy Linux container this **blocks the single Node event-loop thread for 0.5–5 s per `query()`**, stalling every other in-flight request on that process (in our case concurrent SSE streams plus k8s liveness/readiness and `/metrics`).
It reproduces only on Linux — `getReport()` is Linux-gated in the probe, so macOS/Windows never pay it and it's easy to miss in local dev.
## Environment
- `@anthropic-ai/claude-agent-sdk` **0.3.198** (confirmed still present in **0.3.220** — the probe is unchanged)
- Node.js 20.x (glibc Linux container, multi-tenant pod)
- Long-lived server process issuing many `query()` calls
## Where it is
In the bundled `sdk.mjs` the probe is (minified) roughly:
```js
function D6() { // libc probe
if (process.platform !== "linux") return false;
let e = typeof process.report?.getReport === "function"
? process.report.getReport() // <-- expensive, synchronous, blocks the loop
: null;
return e != null && e.header?.glibcVersionRuntime === void 0; // true => musl
}
function cI(e, t = process.platform, r = process.arch, o = existsSync, n = D6()) {
// ^ D6() as a DEFAULT PARAM => re-evaluated on every call that omits `n`
// ...picks `${pkg}-linux-${arch}-musl` vs `${pkg}-linux-${arch}`
}
```
Because `n = D6()` is a default parameter, the resolver re-probes on each invocation. This is the resolver added to fix the wrong-binary bugs (#296 / #323 / #324) — the correctness fix is right; the problem is only that its libc probe is neither cached nor cheap, and runs on the request path per `query()`.
## Impact / evidence
On-pod CPU profile (flamegraph), stack `getReport ← ← ← query()`:
| pod state | `getReport` self-time |
|---|---|
| cold (first turn) | ~540 ms |
| warm | ~2.6 s |
| warm (later) | ~5.3 s |
Variance tracks heap size, number of libuv handles/sockets, number of network interfaces, and CPU contention. Every millisecond here is event-loop time denied to all other concurrent work in the process.
## Suggested fix
libc does not change during a process's lifetime, so the detection result should be computed **at most once per process**:
1. **Memoize** the libc-detection result (module-level cache) instead of re-probing per `query()` / evaluating it as a default parameter.
2. Prefer a **cheaper** detection than a full diagnostic report — e.g. `process.report.getReport()` is far more than is needed to read one header field; consider `detect-libc`, reading `process.report.header.glibcVersionRuntime` via a network-excluded report, or an `ldd --version` fallback.
3. If a report call is kept, pass **`excludeNetwork: true`** (or set `process.report.excludeNetwork = true`) so the slowest section (network-interface enumeration) is skipped — it isn't needed for libc detection.
Any one of (1) removes the per-`query()` repetition; (1)+(3) also shrinks the single remaining call.
## Workarounds (for anyone hitting this now)
- `process.report.excludeNetwork = true` at process startup — strips the network section from the report the SDK triggers (no effect on `header.glibcVersionRuntime`, which is what the probe reads).
Guida per i contributori
Nessuna guida per i contributori indicizzata per questo repository
Valutazione
Questa issue non è ancora stata valutata.