ChatGPTClaudeGrok / X / CursorGemini

DeepSeek Harness web_search hang: tool timeout not enforced

TroubleshootingPublished 2026-10-30Author: DeepSeek Plugin Market
DeepSeek HarnessDSHweb_searchtimeoutTOOL_TIMEOUTaborttool call
web_search declares a 30-second timeout in DeepSeek Harness but can hang a turn for hours, and Stop does nothing. The budget is notify-only; abort is ignored.

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.

  1. In packages/web/tool-web/src/index.ts, applyWebSearchTool attaches timeoutMs = searchTimeoutMs to the tool definition, with a default of 30_000.
  2. The comment in search.ts states this parameter is a "cooperative tool-call budget" handed to @deepseek-ai/dsh-tool-call-timeout-policy; web_fetch has the same shape in fetch.ts.
  3. You can confirm it at runtime: if no tool/result follows tool/call within 30 seconds in the session events, the budget is not being enforced.
  4. 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.

  1. Open packages/guard/timeout-policy/src/index.ts; the self-description on line 4 is "turn my own expiry into TOOL_TIMEOUT, do not race, do not abandon the tool promise".
  2. 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.
  3. The package README lists this gap as a known limitation: the wrapper always sits inside await next().
  4. Compare with idleWatchdog.next in packages/util/timeout: it explicitly guards itself with Promise.race, and its comment is blunt — the transport may leave an aborted read pending forever.
  5. In the same repository, one component guards against it and the other does not. That is where the defect lives.
Layer30s budget?Does expiry force settlement?
Tool declarationYesNo, it is only a label
timeout-policyYesNo, it only replaces the result text
transport / fetchNoNo, 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.

  1. Look at packages/web/web-deepseek-api/src/transport.ts: the request parameters are ...signal !== undefined ? { signal } : {}, with no staged deadline anywhere.
  2. All three stages are unbounded: credential resolution, the request being sent, and reading the response body.
  3. 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.
  4. The shape the community observed: ss shows the socket already gone while the promise is still pending.
  5. Compare packages/web/web-fetch-http/src/provider.ts: it already has deadline(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.

  1. Point web-deepseek-api.baseURL at a service started with net.createServer(() => {}).
  2. Start a conversation turn that will call web_search.
  3. Watch the event stream: tool/call appears, tool/result never does, the phase stays at turn 1 / step 1, state running.
  4. Press Stop or call agent.cancel(); the step still does not end.
  5. 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.

  1. Add a timeoutMs setting to @deepseek-ai/dsh-web-deepseek-api, default 30,000 ms, resolved per operation.
  2. Use the existing @deepseek-ai/dsh-timeout to fuse the deadline with the caller's signal so the request and body read share one signal.
  3. Return a new WEB_PROVIDER_TIMEOUT on expiry: not WEB_ABORTED (nobody cancelled) and not WEB_PROVIDER_ERROR (the endpoint is not misconfigured, just slow).
  4. A caller-initiated cancel still wins the race and is classified as WEB_ABORTED; normally completed operations clear the timer automatically with using.
  5. The community patch is on the fork branch fix/web-search-hang (commit 203ae5e3): 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.

  1. Install: dsh plugin --profile <profile> add "github:argszero/cordis-plugin-tool-deadline-guard#v0.2.2".
  2. Set applyTo: all. The default is undeclared, which deliberately skips tools that declare their own budget — and web_search is exactly on that skip list.
  3. Set onExpiry: replace; this is the branch that actually races ahead.
  4. Set perTool: { web_search: 30000, web_fetch: 30000 }. In all mode the plugin enforces its own budget and does not inherit the tool's declared value.
  5. The four community comparisons: without the plugin it is still pending after 800 ms; with default config it is still pending; with all + replace it settles as TOOL_DEADLINE_EXCEEDED on expiry; adding perTool settles 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.

  1. 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.
  2. That is why the default is undeclared rather than all — choosing all is a conscious trade-off, not a second bug.
  3. The plugin uses its own TOOL_DEADLINE_EXCEEDED and does not mix it with core's TOOL_TIMEOUT, so you can tell which layer expired.
  4. v0.2.1 under applyTo: all emits an error saying the tool has no declared timeoutMs, which is false for web_search; v0.2.2 fixed it, so install the new tag directly.

Cautions

Stop the bleeding without mistaking mitigation for repair.

  1. 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.
  2. Do not just raise the timeout. No number changes the fact that it only notifies and never enforces.
  3. Reproduce once after restarting. Confirm it is the search endpoint before deciding whether to attach the plugin.
  4. The plugin is not a fix. It keeps a deployment from losing whole turns, but the real fix still belongs in transport.
  5. 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.

ChatGPTClaudeGrok / X / CursorGemini

FAQ

web_search in DeepSeek Harness declares a 30-second timeout, so why can it hang a whole turn for hours?

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.

Pressing Stop while a tool is stuck does nothing and I have to restart the process — is that expected in DeepSeek Harness?

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.

Does web_search in DeepSeek Harness have no timeout at all, or a timeout that does not work?

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.

Does the dsh-tool-deadline-guard plugin fully solve hung tools in DeepSeek Harness?

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.

How do I stop the bleeding in DeepSeek Harness so the session can keep working?

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