DEV Community

Cover image for The Signal Nobody Heard, Fixing a Silent AbortSignal Bug in OpenClaw
ANIRUDDHA  ADAK
ANIRUDDHA ADAK Subscriber

Posted on

The Signal Nobody Heard, Fixing a Silent AbortSignal Bug in OpenClaw

Summer Bug Smash: Clear the Lineup Submission 🐛🛹

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

Every 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, a large open source personal AI assistant project.

Project Overview

OpenClaw is 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.

Because 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, and it is used across dozens of call sites in the codebase.

I am aniruddhaadak80 on 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.

Bug Fix or Performance Improvement

Here is the problem in plain words.

fetchWithTimeout accepts a normal RequestInit object as one of its parameters, the same shape you would pass to the native fetch function. A caller can put anything in there, including their own AbortSignal, 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.

The trouble was in how the utility built its own internal signal for the timeout. Internally it spread the caller's init object 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.

The 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.

Code

Here is the pull request with the actual change.

fix(utils): fetchWithTimeout ignores caller-provided AbortSignal in RequestInit #102951

Description

In src/utils/fetch-timeout.ts, the fetchWithTimeout wrapper is implemented as follows:

export async function fetchWithTimeout(
  url: string,
  init: RequestInit,
  timeoutMs: number,
  fetchFn: typeof fetch = fetch,
): Promise<Response> {
  const { signal, cleanup } = buildTimeoutAbortSignal({
    timeoutMs: Math.max(1, timeoutMs),
    operation: "fetchWithTimeout",
    url,
  });
  try {
    return await fetchFn(url, { ...init, signal });
  } finally {
    cleanup();
  }
}
Enter fullscreen mode Exit fullscreen mode

However, if the caller specifies a custom AbortSignal in init.signal (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.

Impact

The caller-provided AbortSignal is silently dropped and ignored, meaning that cancellations from the caller side will not abort the request during fetchWithTimeout.

Suggested Fix

Pass init.signal to buildTimeoutAbortSignal so that the timeout controller is chained to the parent signal:

  const { signal, cleanup } = buildTimeoutAbortSignal({
    timeoutMs: Math.max(1, timeoutMs),
    operation: "fetchWithTimeout",
    url,
    signal: init.signal,
  });
Enter fullscreen mode Exit fullscreen mode

To 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.

Before the fix, the caller's signal never survives:

async function fetchWithTimeout(
  url: string,
  init: RequestInit,
  timeoutMs: number,
  fetchFn: typeof fetch = fetch,
) {
  const { signal, cleanup } = buildTimeoutAbortSignal(timeoutMs)

  try {
    // init.signal, if the caller set one, gets overwritten right here
    return await fetchFn(url, { ...init, signal })
  } finally {
    cleanup()
  }
}
Enter fullscreen mode Exit fullscreen mode

After the fix, both signals get to matter:

async function fetchWithTimeout(
  url: string,
  init: RequestInit,
  timeoutMs: number,
  fetchFn: typeof fetch = fetch,
) {
  const { signal: timeoutSignal, cleanup } = buildTimeoutAbortSignal(timeoutMs)

  const signal = init.signal
    ? AbortSignal.any([init.signal, timeoutSignal])
    : timeoutSignal

  try {
    return await fetchFn(url, { ...init, signal })
  } finally {
    cleanup()
  }
}
Enter fullscreen mode Exit fullscreen mode

The 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.

My Improvements

My 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.

What I actually wanted was a helper that respects both reasons to cancel a request at the same time. AbortSignal.any() is 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:

✅ If the caller cancels early, the request stops early, exactly as intended.
✅ If the caller never sets a signal, the timeout still works exactly as it always did.
✅ If both would eventually fire, whichever one fires first wins, and cleanup still runs through the existing finally block.

I 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.

I 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.

Other merged fixes across my open source work

Clear the Lineup asks 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.

Project Pull Request What I Fixed
openclaw/openclaw #90223 Replaced a hardcoded Windows test skip with a real symlink capability check
openclaw/openclaw #90275 Made install path symlink tests behave correctly on Windows runners
topoteretes/cognee #3123 Fixed a Windows long path failure (OS Error 3) in the LanceDB integration
topoteretes/cognee #3114 Resolved dataset context before the graph engine initializes, instead of after
topoteretes/cognee #3115 Restricted global settings and disabled public registration by default
NousResearch/hermes-agent #13457 Handled a None response from request_permission instead of letting it fail silently
Tracer-Cloud/opensre #618 Corrected how synthetic QA alerts were classified as healthy
Tracer-Cloud/opensre #617 Added missing eks_* keys so the health checker actually evaluates EKS evidence

Every 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, along with my other projects like Folio-Motion and SkillSphere.

Closing Thoughts

Bugs 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.

If you want to see more of what I build and fix, I am aniruddhaadak80 on GitHub, and you can find my portfolio at aniruddha-adak.vercel.app, my writing on Dev.to and Medium, and my day to day thoughts on X.

Small 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.

Thanks for reading, and good luck smashing your own bugs this challenge.

Top comments (0)