Skip to content

AbortSignal.any() is unreliable and breaks timeouts #57736

@sholladay

Description

@sholladay

Version

v23.11.0

Platform

Darwin Seth-Laptop.local 24.3.0 Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:16 PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6000 arm64

Subsystem

No response

What steps will reproduce the bug?

The code below reliably reproduces the problem for me. It should throw a TimeoutError but instead shows the log message.

Simple example with external server:

const signal = AbortSignal.any([AbortSignal.timeout(9000)]);
await fetch('https://httpstat.us/200?sleep=10000', { signal });
console.log('❌ should have thrown timeout error but did not');

Multiple iterations with internal server to demonstrate flakiness:

import http from 'node:http';

const iterations = 10;
const timeout = 1000;
const responseDelay = 10_000;
const port = 3000;
// const url = `https://httpstat.us/200?sleep=${responseDelay}`;
const url = `http://localhost:${port}`;

const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    setTimeout(() => {
        res.end('Hello World!');
    }, responseDelay);
});
await new Promise((resolve) => {
    server.listen(port, resolve);
});

const test = async () => {
    const results = [];

    for (let i = 0; i < iterations; ++i) {
        const startTime = Date.now();
        let result;
        try {
            const signal = AbortSignal.any([AbortSignal.timeout(timeout)]);
            await fetch(url, { signal });
            result = '❌ no timeout';
        }
        catch (error) {
            result = error.name === 'TimeoutError' ? '✔ ' : '❗ ';
            result += error.name;
        }
        const endTime = Date.now();
        const duration = endTime - startTime;
        results.push({ result, startTime, endTime, duration });
    }

    return results;
};

console.table(await test());

server.close();

How often does it reproduce? Is there a required condition?

Reproduces 100% of the time for me with the specific examples above, but does not occur on every iteration in the multiple iterations example, in fact it seems to only ever occur once (at least for small iteration counts, I haven't tried more than 50). Interestingly, which timeout fails seems to change depending on the value of timeout (the timeout length), but is stable across runs given the same timeout. Moving code around within the file also seems to affect it sometimes.

What is the expected behavior? Why is that the expected behavior?

All timeouts should fire and cause fetch() to throw a TimeoutError.

What do you see instead?

At least one timeout does not fire and fetch() returns a response instead of throwing a TimeoutError.

Output for simple example:

❯ node fetch.js
❌ should have thrown timeout error but did not

Output for multiple iteration example:

❯ node fetch.js
┌─────────┬──────────────────┬───────────────┬───────────────┬──────────┐
│ (index) │ result           │ startTime     │ endTime       │ duration │
├─────────┼──────────────────┼───────────────┼───────────────┼──────────┤
│ 0       │ '✔ TimeoutError' │ 1743645479368 │ 1743645480377 │ 1009     │
│ 1       │ '✔ TimeoutError' │ 1743645480377 │ 1743645481380 │ 1003     │
│ 2       │ '✔ TimeoutError' │ 1743645481380 │ 1743645482385 │ 1005     │
│ 3       │ '✔ TimeoutError' │ 1743645482385 │ 1743645483388 │ 1003     │
│ 4       │ '✔ TimeoutError' │ 1743645483388 │ 1743645484390 │ 1002     │
│ 5       │ '✔ TimeoutError' │ 1743645484390 │ 1743645485393 │ 1003     │
│ 6       │ '✔ TimeoutError' │ 1743645485393 │ 1743645486395 │ 1002     │
│ 7       │ '✔ TimeoutError' │ 1743645486396 │ 1743645487399 │ 1003     │
│ 8       │ '❌ no timeout'  │ 1743645487399 │ 1743645497417 │ 10018    │
│ 9       │ '✔ TimeoutError' │ 1743645497417 │ 1743645498421 │ 1004     │
└─────────┴──────────────────┴───────────────┴───────────────┴──────────┘

Additional information

Notice that on the iteration where the timeout fails to fire, the duration of the iteration takes the full responseDelay time. This is because the request was not aborted, even though it should have been.

In my effort to make a minimum reproducible example, I found that doing so requires AbortSignal.any(). In the real world, I am passing multiple signals to it. But that doesn't seem to affect these examples. See also #54614 and #57584 which indicate that AbortSignal.any() has memory leak problems. However, it seems to be reproducible in much simpler scenarios.

Metadata

Metadata

Assignees

No one assigned

    Labels

    abortcontrollerIssues and PRs related to the AbortController APIconfirmed-bugIssues with confirmed bugs.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions