DeepSeek Harness web_search hang: tool timeout not enforced
A 5.5-hour hang that started with one web_search: the tool/call event was written, tool/result never arrived, the session stayed in running state, and not one queued user message was ever read — only a process restart recovered it. The reporter's first diagnosis was "there is no timeout", and the community corrected it on the spot: the budget exists, it just cannot be enforced. Walk through declaration → propagation → IO and you can see exactly where the chain snaps (#11258, #11371).
DeepSeek Harness: Stage 1: the budget really is declared
Both web_search and web_fetch carry a default 30-second timeoutMs; this is a declaration baked into the code, not something the user forgot to configure.
- In
packages/web/tool-web/src/index.ts,applyWebSearchToolattachestimeoutMs = searchTimeoutMsto the tool definition, with a default of30_000. - The comment in
search.tsstates this parameter is a "cooperative tool-call budget" handed to@deepseek-ai/dsh-tool-call-timeout-policy;web_fetchhas the same shape infetch.ts. - You can confirm it at runtime: if no
tool/resultfollowstool/callwithin 30 seconds in the session events, the budget is not being enforced. - Remember the conclusion from this stage: the budget exists; it just never reaches IO.
DSH plugin: Stage 2: the policy only notifies, it does not race ahead
timeout-policy installs a deadline on exec.signal and then await next(); only after the tool promise settles does it replace the result with TOOL_TIMEOUT. If the promise never settles, it waits forever.
- Open
packages/guard/timeout-policy/src/index.ts; the self-description on line 4 is "turn my own expiry intoTOOL_TIMEOUT, do not race, do not abandon the tool promise". - That comment is not modesty, it is the design: the limit it provides constrains only how long the announcement of an answer may wait, not how long the step may actually run.
- The package README lists this gap as a known limitation: the wrapper always sits inside
await next(). - Compare with
idleWatchdog.nextinpackages/util/timeout: it explicitly guards itself withPromise.race, and its comment is blunt — the transport may leave an aborted read pending forever. - In the same repository, one component guards against it and the other does not. That is where the defect lives.
| Layer | 30s budget? | Does expiry force settlement? |
|---|---|---|
| Tool declaration | Yes | No, it is only a label |
timeout-policy | Yes | No, it only replaces the result text |
transport / fetch | No | No, a signal is not a deadline |
DeepSeek Harness: Stage 3: the IO layer does not read the signal
dispatchDeepSeekWeb directly await fetch(); the request carries only the caller's signal with no deadline merged in, and Node's fetch has no default timeout either.
- Look at
packages/web/web-deepseek-api/src/transport.ts: the request parameters are...signal !== undefined ? { signal } : {}, with no staged deadline anywhere. - All three stages are unbounded: credential resolution, the request being sent, and reading the response body.
- Once an abort lands on an in-flight body read and a half-open connection goes quiet, neither data nor error ever arrives; undici's default
headersTimeout/bodyTimeout(300 seconds) do not cover this shape either. - The shape the community observed:
ssshows the socket already gone while the promise is still pending. - Compare
packages/web/web-fetch-http/src/provider.ts: it already hasdeadline(signal, this.limits.timeoutMs, 'WEB_FETCH_TIMEOUT'), with a comment explaining that one signal stops both the request and the body read. The correct pattern already exists in the same repository.
DSH Plugin: Reproduction: one unresponsive endpoint is enough
You do not need a real network failure; a service that only accepts connections and never responds reproduces this reliably.
- Point
web-deepseek-api.baseURLat a service started withnet.createServer(() => {}). - Start a conversation turn that will call
web_search. - Watch the event stream:
tool/callappears,tool/resultnever does, the phase stays at turn 1 / step 1, state running. - Press Stop or call
agent.cancel(); the step still does not end. - Another real-world shape: the endpoint quickly returns
HTTP 402 Insufficient Balance, but the turn was already wedged, and the leftover call never disappears for the whole session.
Container environments make this easier to misread: when a connection is silently dropped by NAT or a proxy, the socket disappears first and the error never comes back, looking exactly like "a very slow endpoint".
DeepSeek Harness: Fix 1: a core patch that fuses a deadline into transport
The correct fix is to merge a deadline covering all stages into the provider request and settle immediately with a distinct error code on expiry, instead of continuing to wait.
- Add a
timeoutMssetting to@deepseek-ai/dsh-web-deepseek-api, default 30,000 ms, resolved per operation. - Use the existing
@deepseek-ai/dsh-timeoutto fuse the deadline with the caller's signal so the request and body read share one signal. - Return a new
WEB_PROVIDER_TIMEOUTon expiry: notWEB_ABORTED(nobody cancelled) and notWEB_PROVIDER_ERROR(the endpoint is not misconfigured, just slow). - A caller-initiated cancel still wins the race and is classified as
WEB_ABORTED; normally completed operations clear the timer automatically withusing. - The community patch is on the fork branch
fix/web-search-hang(commit203ae5e3): 8 files, +177/−14, with 132 package tests passing.
DeepSeek Harness: Fix 2: plugin mitigation to keep the turn moving
The plugin does not touch core; it races the tool call once more on the outside and hands TOOL_DEADLINE_EXCEEDED to the model on expiry so the step continues.
- Install:
dsh plugin --profile <profile> add "github:argszero/cordis-plugin-tool-deadline-guard#v0.2.2". - Set
applyTo: all. The default isundeclared, which deliberately skips tools that declare their own budget — andweb_searchis exactly on that skip list. - Set
onExpiry: replace; this is the branch that actually races ahead. - Set
perTool: { web_search: 30000, web_fetch: 30000 }. Inallmode the plugin enforces its own budget and does not inherit the tool's declared value. - The four community comparisons: without the plugin it is still pending after 800 ms; with default config it is still pending; with
all + replaceit settles asTOOL_DEADLINE_EXCEEDEDon expiry; addingperToolsettles per-tool budget.
The cost of this mitigation
replace ends the "call", not the "request": the abandoned fetch keeps running in the background, and a model retry may duplicate side effects on the same endpoint.
- This is double-wrapping: the base package already mounts
timeout-policy, so two wrappers compete for the same budget and can produce two contradictory result codes for one call. - That is why the default is
undeclaredrather thanall— choosingallis a conscious trade-off, not a second bug. - The plugin uses its own
TOOL_DEADLINE_EXCEEDEDand does not mix it with core'sTOOL_TIMEOUT, so you can tell which layer expired. - v0.2.1 under
applyTo: allemits an error saying the tool has no declared timeoutMs, which is false forweb_search; v0.2.2 fixed it, so install the new tag directly.
Cautions
Stop the bleeding without mistaking mitigation for repair.
- Do not write off "Stop does nothing" as a UI bug. In the current implementation no code races the promise that never settles; cancellation only changes the signal.
- Do not just raise the timeout. No number changes the fact that it only notifies and never enforces.
- Reproduce once after restarting. Confirm it is the search endpoint before deciding whether to attach the plugin.
- The plugin is not a fix. It keeps a deployment from losing whole turns, but the real fix still belongs in transport.
- The same family has other shapes. A gateway 413 misread as context overflow is the same class: getting a failure you cannot handle and taking a recovery path that cannot resolve it.
If your log does not even contain tool/call, this is not your case — check whether the tool was invoked at all.See DeepSeek Harness web panel hangs on keepAliveTimeout for the related case.
FAQ
The 30 seconds in DeepSeek Harness are a declaration, not enforcement. timeout-policy installs a deadline on exec.signal and then await next(); it replaces the result with TOOL_TIMEOUT only after the tool promise settles. When the transport swallows abort and the promise never settles, the wait has no end.
Yes, that is the current DeepSeek Harness behavior: cancellation only changes exec.signal; no code races the promise that never settles, so the step never finishes and queued user messages are never read. The community patch brings the deadline's own expiry into that race.
DeepSeek Harness does declare a timeout for web_search, but it does not work. In packages/web/tool-web/src/index.ts, timeoutMs defaults to 30_000, and search.ts notes this is a cooperative budget for dsh-tool-call-timeout-policy; meanwhile fetch in transport.ts only receives the caller's signal, with no deadline merged in.
No, the plugin only mitigates hung tools in DeepSeek Harness: with applyTo: all and onExpiry: replace it returns TOOL_DEADLINE_EXCEEDED to the model on expiry so the turn can continue, but the abandoned fetch keeps running in the background and a model retry may duplicate side effects.
To stop the bleeding in DeepSeek Harness, restart the process to clear the stuck step, then attach the deadline guard plugin to the search tool with perTool set to { web_search: 30000 }. The long-term fix is core merging a deadline into the provider transport and returning a distinct timeout error code.
Related Terms
- TOOL_TIMEOUT
- TOOL_TIMEOUT is the structured result code returned by DeepSeek Harness's tool-call timeout policy to tell the model a call exceeded its budget. The current implementation only replaces the result after the tool promise settles, so it cannot interrupt a call that never settles.— https://github.com/deepseek-ai/deepseek-harness/discussions/11258
- exec.signal
- What the DeepSeek Harness timeout policy sets is `exec.signal`, the cancellation signal passed to a tool executor so the policy can ask the tool to stop. It can only convey cancellation intent; it cannot force a fetch that never reads the signal to end.— https://github.com/deepseek-ai/deepseek-harness/discussions/11371
- idleWatchdog
- idleWatchdog is the idle watchdog in packages/util/timeout that uses Promise.race to keep a transport from leaving an aborted read pending forever. The repository comment records this failure mode explicitly, yet the tool timeout policy does not use the same pattern.— https://github.com/deepseek-ai/deepseek-harness/discussions/11258
- WEB_PROVIDER_TIMEOUT
- WEB_PROVIDER_TIMEOUT is an error code proposed by the community patch to separate 'the provider is too slow' from 'the caller cancelled' and 'the endpoint is misconfigured'. The point is to let the model choose retry or escalation instead of treating it as a config error and retrying forever.— https://github.com/deepseek-ai/deepseek-harness/discussions/11371
Sources
- #11258 — tool-call timeout is notify-only: a declared timeoutMs can hang the whole session· deepseek-ai (GitHub Discussions)
- #11371 — web_search can hang forever instead of failing: one stalled request wedges the whole turn· deepseek-ai (GitHub Discussions)
- deepseek-ai/deepseek-harness (source repository)· GitHub