fix(llm): a streaming llama response is bounded by idle time, not total time - #323
Merged
Conversation
…al time `localModels.requestTimeoutMs` (default 300s) was applied as a wall-clock deadline over an entire streaming generation. `createRequestController` armed one `setTimeout` when the request was sent and nothing refreshed it, so `completeStream` aborted its own healthy stream 300s in — a reasoning model on CPU, or a llama-server across a LAN, is killed mid-token with every byte already produced discarded. Nothing downstream recovers it: `isRetryableLlamaError` refuses to replay a `timedOut` error, and `timedOutOf` in `should-advance` deliberately keeps a self-inflicted timeout off the immediate-fallover path. The turn just dies. The cloud path never behaved this way. `openAiFetch` clears its identical timer in `finally` when the fetch promise settles, i.e. at response headers, so for OpenAI-compatible providers the same knob bounds only connect. Local and cloud read one config key two incompatible ways. `createRequestController` now returns `keepAlive()`, which re-arms the deadline and marks it an idle budget; `completeStream` calls it once at headers and again on every chunk. `complete()` is untouched — a unary request has exactly one event to wait for and no idle signal to refresh against, so a total budget is the only one it can have. `timedOut()` now reports which deadline fired so the two carry honest, opposite advice: "lower completionMaxTokens" is meaningless for a stall where nothing arrived at all. An idle stall still sets `LlamaServerError.timedOut`, leaving retry and fallover exactly where they were — llama.cpp sends headers before it evaluates the prompt, so minutes of silence during a long CPU prompt-eval is not evidence the provider is dead.
Making `requestTimeoutMs` an idle deadline removed the last cap on one local completion: a server emitting one byte every (budget - 1)ms refreshes the deadline forever, and nothing else on the turn path stops it — `src/agent` arms no timers, there is no `AbortSignal.timeout` anywhere on the path, and `ctx.signal` is user-driven only. A wedged or hostile llama-server could pin a slot, a session and, under headless `run`, the process indefinitely. Adds `localModels.streamTotalTimeoutMs` (env `ATOMIC_AGENT_LLAMA_STREAM_TOTAL_TIMEOUT_MS`, default 6 h) alongside the existing knob. It is a backstop, not a budget: 72x `REQUEST_TIMEOUT_MS`, and well clear of the worst honest local generation — the default `completionMaxTokens` of 8192 decoded at 0.4 tok/s is ~5.7 h.
Three follow-ups to the idle deadline, in one file:
1. Enforce `streamTotalTimeoutMs`. `createRequestController` now also
returns `startStreamDeadline()`, a second timer armed once at
response headers and never refreshed, reported as its own
`stream-total` kind with its own wording ("data kept arriving, so
this is the absolute cap … not a stall"). A live stream therefore
holds two pending timers; `cleanup()` clears both.
2. A stall *before the first token* no longer claims the server
"stopped responding after starting the reply". llama.cpp sends
headers and only then evaluates the prompt, so that silence is the
ordinary look of a long CPU prompt eval — exactly the population this
change exists to protect. `keepAlive()` now takes the kind to record:
`first-token` at headers, `idle` once a byte has actually arrived.
The new wording says the request was accepted but no first token
came, and points at `requestTimeoutMs` or the prompt/context size.
3. Tests for both, plus the two behaviours that were load-bearing but
unpinned: the `keepAlive()` at headers (deleting it now fails two
tests instead of none) and its no-op-once-aborted guard (a byte still
in the decode pipe when the abort lands must not re-arm the timer and
rewrite which deadline gets reported).
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
The defect
LlamaServerClient.completeStreamappliedlocalModels.requestTimeoutMs(default 300 000 ms,
ENV_DEFAULTS.REQUEST_TIMEOUT_MS) as a totalwall-clock deadline over the whole streaming generation.
createRequestControllerarmed exactly one timer when the request wasbuilt:
Nothing ever refreshed it.
completeStreamthen handed that controller tofetchand read the SSE body in awhile (true) { await reader.read() … }loop, with
cleanup()only in the outerfinally. So the abort fired 300 safter the request was sent — mid-stream, while the server was emitting
tokens perfectly healthily.
Who this hits: a reasoning model on CPU, a large model on a LAN box, any
generation whose honest duration exceeds five minutes.
The consequence chain
reader.read()throws,wrapTransportErrorseestimedOut === trueand reports"llama-server request exceeded requestTimeoutMs (300000ms) — raise
localModels.requestTimeoutMs or lower completionMaxTokens". Every
partial token already produced is discarded.
isRetryableLlamaErrorrefuses to replay atimedOuterror — correctly,by its own doc comment — so there is no retry.
timedOutOf()insrc/llm/fallback/should-advance.tsdeliberately makesa self-inflicted timeout non-immediate, so the fallback chain does not
step in either.
The local-vs-cloud asymmetry
The cloud path has never behaved this way. In
src/llm/provider/openai/openai-http.ts,openAiFetchsets the same kind oftimer but clears it in
finally { clearTimeout(timer) }when thefetchpromise settles — i.e. as soon as response headers arrive. For every
OpenAI-compatible provider
requestTimeoutMstherefore bounds only theconnect/headers phase and the stream body is unbounded.
One config key, two incompatible meanings depending on which provider is
serving. That is the part users cannot possibly reason about.
The fix
The streaming deadline becomes an idle deadline: it bounds time since
the last byte, not total generation time. A second, never-refreshed timer
keeps an absolute ceiling on the whole streaming body, so "not a total
budget" does not mean "no budget".
createRequestControllernow also returns:keepAlive(kind)— clears and re-arms the idle timer and records what alater expiry means (
first-tokenoridle). It is a no-op once therequest is already aborted or a deadline has already fired, so a late
call cannot resurrect a controller nor rewrite which deadline is
reported.
startStreamDeadline()— arms the absolute streaming cap(
streamTotalTimeoutMs, below). Idempotent, armed once at headers, neverrefreshed.
completeStreamcallskeepAlive("first-token")andstartStreamDeadline()immediately after headers, andkeepAlive("idle")after every
reader.read()that returns data.cleanup()clears both timers. A live stream holds exactly two pendingtimers and zero once the generator finishes, returns, or is broken out of
early.
complete()andfetchProps()are untouched. A unary request has exactlyone event to wait for and no idle signal to refresh against, so a total
budget is the only budget it can have.
Why
keepAlive()at headers, before the loop. llama.cpp answers withresponse headers immediately and only then evaluates the prompt. A long
CPU prompt-eval is legitimately silent for minutes before the first token.
Re-arming at headers gives the body a full budget of its own rather than
whatever the connect phase happened to leave over, and makes every
body-phase timeout report a body-phase wording.
Why not on
done.donebreaks straight out of the loop intofinally { cleanup() }with nothing awaited in between, so there is nowindow in which the timer could fire. Re-arming there would be dead code.
The new upper bound:
streamTotalTimeoutMsAn idle deadline on its own is not an upper bound. A server emitting one
byte every
requestTimeoutMs - 1refreshes it forever, and nothing else onthe turn path stops it:
src/agentarms no timers, there is noAbortSignal.timeoutanywhere on the turn path (every occurrence in thetree is a health probe, an HF/catalog fetch, embeddings, or browser spawn),
and
ctx.signalis user-driven only. Before this PR,requestTimeoutMsdidat least bound one local completion at 300 s; dropping that with nothing in
its place would let a wedged or hostile llama-server pin a slot, a session
and — under headless
runor a cron task — a process indefinitely. Thatwould be a capability regression introduced by this PR, not inherited cloud
behaviour, so it gets an explicit backstop rather than a note.
New key, following the existing pattern for local-model knobs:
localModels.streamTotalTimeoutMs(AtomicAgentConfig)ATOMIC_AGENT_LLAMA_STREAM_TOTAL_TIMEOUT_MSENV_DEFAULTS.STREAM_TOTAL_TIMEOUT_MS= 6 hLlamaServerClientOptions.streamTotalTimeoutMsIt is read in
src/config/load-config.tsnext torequestTimeoutMswith thesame
readInthelper. It is deliberately not added tosrc/config/llm-config.ts: that file validates per-provider user configfile entries, and
requestTimeoutMsthere is a provider-registry key —localModels.requestTimeoutMsitself has never been a user-config-filefield, only an env one, so a per-provider
streamTotalTimeoutMswould be anew surface with no sibling to be consistent with.
Why 6 h. It has to be a backstop, not a budget — it must never fire for
the long-CPU-prompt-eval population this PR exists to protect. The worst
honest local generation I can construct with default settings is
completionMaxTokens= 8 192 tokens decoded at 0.4 tok/s, slower than anyCPU setup people actually sit through, which is ≈ 5.7 h. 6 h clears that. It
is also 72×
REQUEST_TIMEOUT_MS, so the idle deadline gets dozens ofchances to fire first and this one only fires when the server was genuinely
streaming continuously for six hours without finishing. Anyone actually
running a 131 072-token completion on a slow box raises the env var; the
error message names it.
Honest messages
timedOut()now reports which deadline fired(
"total" | "first-token" | "idle" | "stream-total" | null) so each casekeeps accurate, non-contradictory advice. The union stays private:
LlamaServerError.timedOutis still a boolean and no consumer sees a string.complete()) — unchanged wording:llama-server request exceeded requestTimeoutMs (300000ms) — raise localModels.requestTimeoutMs or lower completionMaxTokensllama-server accepted the request but sent no first token within 300000ms — it may still be evaluating the prompt; raise localModels.requestTimeoutMs, or shorten the prompt/context if it is too large for this machine to evaluate in timellama-server sent no data for 300000ms mid-stream — the server stopped responding after starting the reply; check that llama-server is still running, or raise localModels.requestTimeoutMsllama-server streamed for longer than streamTotalTimeoutMs (21600000ms) without finishing — data kept arriving, so this is the absolute cap on one streaming reply, not a stall; raise localModels.streamTotalTimeoutMs (ATOMIC_AGENT_LLAMA_STREAM_TOTAL_TIMEOUT_MS) or lower completionMaxTokensThe split between first-token and idle matters as much as the
original fix. "The server stopped responding after starting the reply" is
false when the server never started the reply, and a silence before the
first token is precisely what a healthy llama-server looks like while
grinding a long context on CPU. Telling that user their server is dead would
have replaced one piece of bad advice with another for the same population.
"Lower completionMaxTokens" is likewise wrong for a stall: the answer was
not too long, it never came.
Known limitation: the idle deadline also measures consumer backpressure
keepAlive()runs only when the generator's read loop runs; betweenyieldsthe generator is suspended and the body applies backpressure, so a consumer
stalled for longer than
requestTimeoutMstrips the idle timer and the useris told the server went quiet when the stall was ours. This is not a
regression —
main's total budget had the same exposure, worse, since itcounted consumer time against a non-refreshing clock — and it needs a >300 s
stall inside the consuming loop to reach. It is called out because the new
wording asserts a specific server fault where the old one only asserted
that time had passed.
Decision: an idle stall still sets
timedOut = trueThis was the one genuinely arguable call, so spelling it out.
The tempting read is that silence is stronger evidence of a dead provider
than slowness, which would argue for
timedOut = false— that would makeisImmediateSignalinshould-advance.tstreat the null status as anunambiguous provider-down signal and fall over to the next chain link on the
first occurrence.
I did not do that, for one concrete reason: llama.cpp streams headers
before it evaluates the prompt. A 300 s silence is exactly what a healthy
llama-server looks like while grinding a long context on CPU — the very
population this PR is trying to stop breaking. Making that an immediate
fallover would trade one bad failure mode for another.
So the behaviour is unchanged on both axes:
isRetryableLlamaErrorstill refuses to replay it. (Note this isacademic for the stall itself: mid-body errors are thrown outside
runWithRetry, which only wraps the initial fetch. It matters for theconnect-phase timeout, which is still
"total"and still non-retryable.)shouldAdvancestill returns{ advance: true, immediate: false }— thestall counts toward the consecutive-failure threshold and never triggers a
first-occurrence switch.
The field's doc comment now records this reasoning so the next reader does
not "fix" it.
Sentry evidence
Stated carefully: this data is consistent with the diagnosis, not proof
of it. This project scrubs error messages by design, so the timeout string
itself is not visible in the events — the case rests on the code path plus
the shape of the tags.
CLI-BX— 38 events / 3 users, first seen 2026-09-01, exclusively onrelease 0.5.4,
category=transport,cause_type=LlamaServerError.transport_host:192.168.1.18:8080(25),127.0.0.1:8080(10),127.0.0.1:19091(3). The LAN host dominating the sample is the tell — aremote, slower llama-server is exactly the population that blows a total
wall-clock budget.
codetag at all. An abort leaves no errno, unlike a socketfailure. This is our own controller firing, not the network dying.
async meterStream←async run←async stampServedTransport←async replayPrimedStream←async completeStream. The failure happenswhile consuming an already-primed stream body — precisely the window
this timer covered, and the one window nothing retries.
CLI-BK— 6 events / 3 users,code = UND_ERR_BODY_TIMEOUT(3),ECONNRESET(2),UND_ERR_SOCKET(1), culpritFetch.onAborted, releases0.4.2 and 0.5.4.
UND_ERR_BODY_TIMEOUTis undici's own inter-chunk bodytimeout — a second, independent idle deadline living in the same window,
which this PR does not touch. Worth knowing it exists: if stalls persist
after this change, undici's
bodyTimeout(300 s default) is the next knob,and it is already an idle deadline, so it is the correct shape.
Tests
Seven tests in
src/llm/llama-server-client.test.ts, all on fake timers,driving a hand-pushed SSE body whose abort signal errors the stream
mid-read the way undici does.
mainrequestTimeoutMswhile chunks keep arrivingrequestTimeoutMsstreamTotalTimeoutMseven while chunks keep arrivingcomplete()pathVerified by restoring
src/llm/llama-server-client.tstoorigin/mainandre-running with the new tests in place:
Tests 5 failed | 27 passed (32),failing exactly the five rows above. The first is the defect reproducing
verbatim: six chunks 999 ms apart under a 1 000 ms budget — 5 994 ms of
healthy streaming, every gap comfortably inside the budget — and
mainstill kills it.
The two pins pass on
mainby construction; they exist so nobody widens theunary deadline or launders a user cancellation into a timeout later. Calling
them "regression tests" would be dishonest, so they are labelled as pins.
Three of the five are there to pin things that were previously asserted in
prose only:
keepAlive()at headers. Deleting that one line used to pass thewhole file, because the existing stall test pushes a chunk first and the
loop's
keepAlive()masked the deletion. It now fails two tests.if (expired !== null || controller.signal.aborted) return;used to passthe whole file too. The test drives the narrow window it actually
defends: the abort has landed but a byte already in the decode pipe is
still delivered, so the read loop calls
keepAlive()after the deadlinefired. Without the guard that re-arms the timer, which fires a second
time and overwrites the reason — the user is told the server stalled
mid-reply when it never produced a first token at all.
On the external-abort test: the
cancelledcategory is decided upstreamin
toLlmFailurefromctx.signal.aborted, not from the error's shape, andthis PR does not touch that. What the test pins at the client boundary is
the thing my change could plausibly have broken — that an Esc-abort is not
laundered into
timedOut: truewith an idle-timeout message.Verification
npm run lint(tsc --noEmit) — clean.npx vitest run src/llm/llama-server-client.test.ts src/llm/fallback src/llm/reliability src/llm/provider/openai— 20 files, 215 tests, all passing.npx vitest run src/config— 12 files, 271 tests, all passing.src/llm src/local-llm src/agent— 92 files, 1082 tests, all passing. No flakes in that slice.keepAlive()→ 2failures; deleting the loop
keepAlive("idle")→ 3 failures; deletingstartStreamDeadline()→ 1 failure; removing the aborted/expired guard →1 failure.
grepforrequestTimeoutMs/exceeded requestTimeoutMsacross tests: thestring was asserted nowhere outside this file, and no other test stubs
createRequestController(it is private and has no external callers).git merge-treeis clean and the two PRs share nofiles.