{"slug": "the-signal-nobody-heard-fixing-a-silent-abortsignal-bug-in-openclaw", "title": "The Signal Nobody Heard, Fixing a Silent AbortSignal Bug in OpenClaw", "summary": "A developer fixed a bug in OpenClaw, an open-source personal AI assistant, where the fetchWithTimeout utility silently discarded caller-provided AbortSignal objects, causing requests to continue running after cancellation. The fix chains the caller's signal to the internal timeout signal so that both can abort the request.", "body_md": "*This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.*\n\nEvery codebase has that one bug that looks tiny on paper and behaves like a small landmine underneath. This is the story of one of those bugs, and the fix I shipped for it in [OpenClaw](https://github.com/openclaw/openclaw), a large open source personal AI assistant project.\n\n`OpenClaw`\n\nis a cross platform personal AI assistant written mostly in TypeScript. It runs as a gateway process that connects large language models to real communication channels such as Telegram, Slack, Discord, and Apple Messages, while also handling sessions, memory, plugins, and tool calls for agents working on your behalf.\n\nBecause the gateway is the thing sitting between a user's message and a model's response, it lives and dies by how well it manages network requests. Every outbound call, whether it is talking to a model provider, a channel API, or an internal service, gets wrapped with a shared timeout utility so nothing hangs the event loop forever. That utility is called `fetchWithTimeout`\n\n, and it is used across dozens of call sites in the codebase.\n\nI am `aniruddhaadak80`\n\non GitHub, and this project is one of the places where I have been sending small, focused fixes rather than one giant rewrite. You can see the full list of my open source work on my [GitHub profile](https://github.com/aniruddhaadak80).\n\nHere is the problem in plain words.\n\n`fetchWithTimeout`\n\naccepts a normal `RequestInit`\n\nobject as one of its parameters, the same shape you would pass to the native `fetch`\n\nfunction. A caller can put anything in there, including their own `AbortSignal`\n\n, if they already have a reason to cancel the request early. Maybe the user pressed stop. Maybe the session ended. Maybe a parent operation was cancelled and everything underneath it should stop too.\n\nThe trouble was in how the utility built its own internal signal for the timeout. Internally it spread the caller's `init`\n\nobject first, and then added its own timeout signal on top of it. That ordering meant the caller's signal was silently thrown away every single time. Whatever cancellation logic the calling code thought it had wired up simply did not exist anymore once the request reached `fetchWithTimeout`\n\n.\n\nThe practical effect was not a crash. It was quieter and arguably worse. A request that the rest of the system believed had been cancelled would keep running in the background until either it finished naturally or the internal timeout eventually caught up with it. Under normal load you would not notice. Under real load, with many concurrent sessions and channels, this is exactly the kind of leak that piles up, ties up connections, and makes the gateway feel sluggish for reasons that are hard to trace back to a root cause.\n\nHere is the pull request with the actual change.\n\nIn `src/utils/fetch-timeout.ts`\n\n, the `fetchWithTimeout`\n\nwrapper is implemented as follows:\n\n```\nexport async function fetchWithTimeout(\n  url: string,\n  init: RequestInit,\n  timeoutMs: number,\n  fetchFn: typeof fetch = fetch,\n): Promise<Response> {\n  const { signal, cleanup } = buildTimeoutAbortSignal({\n    timeoutMs: Math.max(1, timeoutMs),\n    operation: \"fetchWithTimeout\",\n    url,\n  });\n  try {\n    return await fetchFn(url, { ...init, signal });\n  } finally {\n    cleanup();\n  }\n}\n```\n\nHowever, if the caller specifies a custom `AbortSignal`\n\nin `init.signal`\n\n(for instance, to cancel the request if a parent operation is aborted or the client disconnects), this signal is completely overridden by the new signal created in `buildTimeoutAbortSignal`\n\n.\n\nThe caller-provided `AbortSignal`\n\nis silently dropped and ignored, meaning that cancellations from the caller side will not abort the request during `fetchWithTimeout`\n\n.\n\nPass `init.signal`\n\nto `buildTimeoutAbortSignal`\n\nso that the timeout controller is chained to the parent signal:\n\n```\n  const { signal, cleanup } = buildTimeoutAbortSignal({\n    timeoutMs: Math.max(1, timeoutMs),\n    operation: \"fetchWithTimeout\",\n    url,\n    signal: init.signal,\n  });\n```\n\nTo show what the core problem looked like, here is a simplified, rewritten version of the pattern that was causing the issue. This is not a line for line copy of the original file, it is a clean illustration of the same bug shape.\n\nBefore the fix, the caller's signal never survives:\n\n```\nasync function fetchWithTimeout(\n  url: string,\n  init: RequestInit,\n  timeoutMs: number,\n  fetchFn: typeof fetch = fetch,\n) {\n  const { signal, cleanup } = buildTimeoutAbortSignal(timeoutMs)\n\n  try {\n    // init.signal, if the caller set one, gets overwritten right here\n    return await fetchFn(url, { ...init, signal })\n  } finally {\n    cleanup()\n  }\n}\n```\n\nAfter the fix, both signals get to matter:\n\n```\nasync function fetchWithTimeout(\n  url: string,\n  init: RequestInit,\n  timeoutMs: number,\n  fetchFn: typeof fetch = fetch,\n) {\n  const { signal: timeoutSignal, cleanup } = buildTimeoutAbortSignal(timeoutMs)\n\n  const signal = init.signal\n    ? AbortSignal.any([init.signal, timeoutSignal])\n    : timeoutSignal\n\n  try {\n    return await fetchFn(url, { ...init, signal })\n  } finally {\n    cleanup()\n  }\n}\n```\n\nThe change is small on the surface, but it changes the actual behavior of every call site that ever passes its own signal into this helper.\n\nMy first instinct when I read the code was to just fix the object spread order. That would have worked in the narrow sense, but it would have meant the caller's signal wins and the internal timeout signal gets dropped instead, which just moves the bug from one side to the other.\n\nWhat I actually wanted was a helper that respects both reasons to cancel a request at the same time. `AbortSignal.any()`\n\nis built exactly for this. It takes an array of signals and returns a new signal that fires the moment any one of them fires. So now:\n\n✅ If the caller cancels early, the request stops early, exactly as intended.\n\n✅ If the caller never sets a signal, the timeout still works exactly as it always did.\n\n✅ If both would eventually fire, whichever one fires first wins, and cleanup still runs through the existing `finally`\n\nblock.\n\nI kept the function signature untouched on purpose. Every existing call site across the codebase continues to work without any changes, since the fix lives entirely inside the helper. That mattered a lot in a project this size, where dozens of files call into this one function and nobody wants a fix that turns into a fifty file refactor.\n\nI also went back through related open issues in the repository describing symptoms like stalled polling loops and requests that outlived their expected lifetime, and confirmed this fix addresses the same underlying pattern rather than just one isolated call site.\n\n`Clear the Lineup`\n\nasks for one definitive fix, so the AbortSignal fix above is my main entry. For context on the kind of work I generally send upstream, here is a short table of other pull requests of mine that are currently merged into their respective projects.\n\n| Project | Pull Request | What I Fixed |\n|---|---|---|\n`openclaw/openclaw` |\n|\n\n`openclaw/openclaw`\n\n`topoteretes/cognee`\n\n`OS Error 3`\n\n) in the LanceDB integration`topoteretes/cognee`\n\n`topoteretes/cognee`\n\n`NousResearch/hermes-agent`\n\n`None`\n\nresponse from `request_permission`\n\ninstead of letting it fail silently`Tracer-Cloud/opensre`\n\n`Tracer-Cloud/opensre`\n\n`eks_*`\n\nkeys so the health checker actually evaluates EKS evidenceEvery one of these is a live, merged pull request, no drafts and nothing that was closed without merging. You can see the complete history on my [GitHub profile](https://github.com/aniruddhaadak80), along with my other projects like [Folio-Motion](https://github.com/aniruddhaadak80/Folio-Motion) and [SkillSphere](https://github.com/aniruddhaadak80/SkillSphere).\n\nBugs like this one do not announce themselves. Nothing throws, nothing logs an error, the request just quietly keeps running when everyone assumed it had stopped. Finding it meant reading the helper function carefully enough to notice that object spread order was doing something nobody intended, and fixing it meant choosing a solution that respects every caller's expectations instead of just patching the one symptom I happened to see.\n\nIf you want to see more of what I build and fix, I am `aniruddhaadak80`\n\non [GitHub](https://github.com/aniruddhaadak80), and you can find my portfolio at [aniruddha-adak.vercel.app](https://aniruddha-adak.vercel.app), my writing on [Dev.to](https://dev.to/aniruddhaadak) and [Medium](https://medium.com/@aniruddhaadak), and my day to day thoughts on [X](https://x.com/aniruddhadak).\n\nSmall fixes, applied to the right place, are still real engineering. You do not need a rewrite to make a system more honest about what it promises to do.\n\nThanks for reading, and good luck smashing your own bugs this challenge.", "url": "https://wpnews.pro/news/the-signal-nobody-heard-fixing-a-silent-abortsignal-bug-in-openclaw", "canonical_source": "https://dev.to/aniruddhaadak/the-signal-nobody-heard-fixing-a-silent-abortsignal-bug-in-openclaw-3ko7", "published_at": "2026-07-17 19:52:00+00:00", "updated_at": "2026-07-17 20:29:46.368233+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "large-language-models"], "entities": ["OpenClaw", "aniruddhaadak80", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/the-signal-nobody-heard-fixing-a-silent-abortsignal-bug-in-openclaw", "markdown": "https://wpnews.pro/news/the-signal-nobody-heard-fixing-a-silent-abortsignal-bug-in-openclaw.md", "text": "https://wpnews.pro/news/the-signal-nobody-heard-fixing-a-silent-abortsignal-bug-in-openclaw.txt", "jsonld": "https://wpnews.pro/news/the-signal-nobody-heard-fixing-a-silent-abortsignal-bug-in-openclaw.jsonld"}}