AbortSignal.timeout() throws uncaught TimeoutError after fetch already completed
- Dominant language
- Rust
- Stars
- 108k
- Forks
- 6.4k
- Avg merge
- 2d 20h
- Merged PRs (30d)
- 58
Description
## Summary
`AbortSignal.timeout(ms)` still delivers an **uncaught** `TimeoutError` after the abortable consumer (`fetch`) has already completed successfully. The timer is not tied to the lifetime of the request: when it later fires, Deno treats `controller.abort(TimeoutError)` as an unhandled rejection.
This is distinct from [#20663](https://github.com/denoland/deno/issues/20663) (sanitizeOps leak of the sleep op, fixed in [#23842](https://github.com/denoland/deno/pull/23842)). Ops sanitizer is not involved here — the process dies on an uncaught `TimeoutError` even with `sanitizeOps: false`.
## Environment
- Deno 2.9.5+a883d13 (canary, x86_64-pc-windows-msvc)
- OS: Windows 10/11 (NT 10.0.26200)
- Also expected on any platform; the abort-signal timer is in `ext:deno_web/03_abort_signal.js`
## Reproduction
```js
const r = await fetch("https://example.com", { signal: AbortSignal.timeout(50) });
await r.arrayBuffer();
await new Promise(r => setTimeout(r, 200));
console.log("still alive");
```
```
deno eval --allow-all ""
```
## Observed
Process exits 1 before "still alive"`:
```
error: Uncaught (in promise) TimeoutError: The operation was aborted due to timeout
at Object. (ext:deno_web/03_abort_signal.js:1:2081)
at listOnTimeout (ext:core/02_timers.js:307:17)
at Object.processTimers (ext:core/02_timers.js:250:7)
at __processTimers (ext:core/01_core.js:448:21)
```
Inside `deno test`, the same rejection is wrapped as:
```
Uncaught error from ./some.test.mjs FAILED
error: (in promise) TimeoutError: The operation was aborted due to timeout
This error was not caught from a test and caused the test runner to fail on the referenced module.
It most likely originated from a dangling promise, event/timeout handler or top-level code.
```
A `try/catch` around `fetch` does **not** help: the rejection is from the timeout timer, after `fetch` has already settled.
## Expected
After `fetch` completes, aborting the unused timeout signal is a no-op (WHATWG: abort with no listeners). The snippet should print `still alive` and exit 0.
Alternatively, `AbortSignal.timeout` should cancel its timer when the last consumer is done, same as the intent of #23842.
## Downstream (fount)
Repo: https://github.com/steve02081504/fount
We pass `AbortSignal.timeout(...)` into `fetch` in the test hub (module-check ready, GitHub-issue probe, kernel health) and in long-lived product paths (log viewer ping, WeChat API). A 15s module-check ready fetch that returns in milliseconds still explodes ~15s later. In a multi-file `deno test` process that is attributed to whichever file is current (`testkit:kernel` → `display.test.mjs` in our last wave).
**When fixed:** no fount workaround will be applied (rewriting every call site to `AbortController` + `clearTimeout` is the wrong layer). Re-run `fount test testkit:kernel` and confirm the uncaught `TimeoutError` is gone.
Contributor guide
Research direction
Reproduce the failure with the provided `deno eval` snippet, then inspect `ext:deno_web/03_abort_signal.js` and the timer behavior in `ext:core/02_timers.js`. Verify that a completed fetch does not later produce an uncaught `TimeoutError`; confirm the fix with `fount test testkit:kernel` and that the snippet prints `still alive`.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- tooling
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100