Skip to main content

On This Page

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

3 min read
Share

These articles are AI-generated summaries. Please check the original sources for full details.

The Signal Nobody Heard

OpenClaw developer Aniruddha Adak discovered a silent bug in its fetchWithTimeout utility. Caller-provided AbortSignals were overwritten by internal timeout signals, leaving cancellation logic ineffective across dozens of call sites.

Why This Matters

In distributed systems like an AI assistant gateway, every unseen leak compounds under load—requests that should have been cancelled continue running until timeout or completion, tying up connections and degrading performance without throwing any error. This makes root-cause analysis elusive and turns a small ordering mistake into a systemic reliability issue.

Key Insights

  • PR #102951 fixed fetchWithTimeout ignoring caller’s AbortSignal (2026) by using AbortSignal.any() to chain both signals.
  • Concept of chaining abort signals via AbortSignal.any() ensures whichever trigger fires first cancels the request—timeout OR external cancellation.
  • Tool fetchWithTimeout used across OpenClaw’s integrations (Telegram, Slack, Discord) means this fix propagates improved behavior everywhere without changing call sites.
  • Object spread order ({ ...init, signal }) silently overrides existing properties—a common anti-pattern that hides bugs until careful code review.

Working Examples

Original implementation where spreading init then adding a new signal discards any caller-provided AbortSignal.

// Before fix – 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()
  }
}

Fixed implementation using AbortSignal.any() to combine caller signal with internal timeout signal.

// After fix – both signals respected
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()
  }
}

Practical Applications

    • Use case: Any wrapper function spreading options then overriding a key property can silently drop caller intent.
  • Pitfall: Not validating that spread order preserves existing values leads to lost functionality like cancellation hooks.
    • Use case: Systems requiring both timeout-based cancellation and external cancellation (e.g., user hitting stop).
  • Pitfall: Using only one abort mechanism forces developers to choose which takes precedence; combining via AbortSignal.any() avoids trade-offs.
    • Use case: Large multi-channel gateways where many concurrent requests rely on consistent timeout behavior.
  • Pitfall: Silent leaks accumulate under load making performance degradation hard to trace to a single root cause.

References:

Continue reading

Next article

The Web Emergence (1996-1998): How TCP/IP Hardening and the Browser Veil Reshaped the Internet

Related Content