Skip to content

Commit d8509ff

Browse files
icecrasher321claude
andcommitted
fix(tools): disarm Bun's fetch idle timer instead of passing a numeric deadline
Bun 1.3.14 ignores a positive numeric `timeout` on fetch and honors only the boolean/zero form, so passing the plan deadline through changed nothing and internal tool calls still died at the 300s default. Verified against the pinned runtime: `{ timeout: 1000 }` does not abort a request that takes 3s to answer, and `BUN_CONFIG_HTTP_IDLE_TIMEOUT` has no effect either — both are `main`-only. The caller on this path already arms an AbortController with the plan timeout, so the transport timer is disarmed rather than re-negotiated, leaving one enforcement point instead of two that disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0f421b1 commit d8509ff

3 files changed

Lines changed: 53 additions & 53 deletions

File tree

apps/sim/lib/core/utils/fetch-deadline.test.ts

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,30 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { isTransportTimeoutError, withFetchDeadline } from '@/lib/core/utils/fetch-deadline'
5+
import { isTransportTimeoutError, withCallerOwnedDeadline } from '@/lib/core/utils/fetch-deadline'
66

7-
describe('withFetchDeadline', () => {
8-
it('states the caller deadline as the transport deadline', () => {
9-
expect(withFetchDeadline({ method: 'POST' }, 3_000_000).timeout).toBe(3_000_000)
7+
describe('withCallerOwnedDeadline', () => {
8+
/*
9+
* The pinned Bun ignores a positive numeric `timeout` and honors only the
10+
* boolean/zero form, so anything other than `false` here silently leaves the
11+
* 300s default in force — which is the outage this module exists to prevent.
12+
*/
13+
it('disarms the transport timer rather than negotiating a value', () => {
14+
expect(withCallerOwnedDeadline({}).timeout).toBe(false)
1015
})
1116

1217
it('preserves the init the caller already built', () => {
1318
const signal = new AbortController().signal
14-
const init = withFetchDeadline({ method: 'POST', body: 'x', signal }, 1000)
19+
const init = withCallerOwnedDeadline({ method: 'POST', body: 'x', signal })
1520
expect(init.method).toBe('POST')
1621
expect(init.body).toBe('x')
1722
expect(init.signal).toBe(signal)
1823
})
1924

20-
it('rounds a fractional deadline up rather than down', () => {
21-
expect(withFetchDeadline({}, 1500.2).timeout).toBe(1501)
22-
})
23-
24-
/*
25-
* The bug this module exists for: an absent application deadline must disable
26-
* the transport timer, never fall back to the runtime's 300s default.
27-
*/
28-
it.each([
29-
['undefined', undefined],
30-
['zero', 0],
31-
['negative', -1],
32-
['Infinity', Number.POSITIVE_INFINITY],
33-
['NaN', Number.NaN],
34-
])('disables the transport timer when the deadline is %s', (_label, deadline) => {
35-
expect(withFetchDeadline({}, deadline as number | undefined).timeout).toBe(false)
25+
it('does not mutate the caller’s init', () => {
26+
const original: RequestInit = { method: 'POST' }
27+
withCallerOwnedDeadline(original)
28+
expect('timeout' in original).toBe(false)
3629
})
3730
})
3831

apps/sim/lib/core/utils/fetch-deadline.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,54 @@
11
/**
22
* Keeps the transport deadline from undercutting the application deadline.
33
*
4-
* Bun's HTTP client arms an idle timer defaulting to 300s. It re-arms on writes
5-
* and body-phase reads, but *not* on response-header reads — so it is an
6-
* absolute deadline for the peer to begin answering. An `AbortSignal` cannot
7-
* raise it, which makes it invisible to every caller that believes it owns the
8-
* deadline: a request whose peer legitimately works before it replies dies at
9-
* five minutes no matter what timeout was computed for it.
4+
* Bun's HTTP client arms an idle timer defaulting to 300s. It is not raised by
5+
* an `AbortSignal`, and it does not re-arm while awaiting response headers, so
6+
* it acts as an absolute deadline for the peer to begin answering. Any request
7+
* whose peer legitimately works before it replies dies at five minutes no
8+
* matter what deadline the caller computed for it.
109
*
1110
* This bit production. Workflow function blocks are bounded by a plan deadline
1211
* (50 minutes on enterprise), but the executor's call into the internal
1312
* function route inherited Bun's default instead, so every sandbox run longer
1413
* than five minutes failed with a bare `fetch failed` that read as user-code
1514
* failure rather than a transport cap.
1615
*
16+
* The timer is therefore disarmed rather than re-negotiated: callers on this
17+
* path already own an in-process deadline (an `AbortController` armed with the
18+
* plan timeout), and a second, shorter, invisible deadline underneath it is
19+
* exactly the bug. Disarming leaves one enforcement point instead of two that
20+
* disagree.
21+
*
22+
* Note the pinned runtime accepts only the boolean/zero form. Bun 1.3.14
23+
* ignores a positive numeric `timeout` — verified against the pinned version by
24+
* observing that `{ timeout: 1000 }` does not abort a request that takes 3s to
25+
* answer — so passing the deadline as a number silently changes nothing. The
26+
* numeric idle-deadline form exists only on Bun's `main`. Do not "improve" this
27+
* to pass the deadline through until the pinned version supports it, and
28+
* re-verify with that probe if you do.
29+
*
1730
* Node's undici has no equivalent default and ignores the option, so this is
1831
* safe on both runtimes.
1932
*/
2033

2134
/**
2235
* `RequestInit` plus Bun's idle-timeout control, which the DOM lib does not
23-
* declare. `false` disables the timer entirely; a positive number is the idle
24-
* deadline in milliseconds.
36+
* declare. `false` disarms the timer; `true` or omitted keeps the default.
2537
*/
2638
export interface DeadlineRequestInit extends RequestInit {
2739
timeout?: number | boolean
2840
}
2941

3042
/**
31-
* Applies `deadlineMs` as the transport idle deadline alongside whatever
32-
* `AbortSignal` the caller already set, so both layers express one number.
43+
* Disarms the transport idle timer so the caller's own deadline is the only one
44+
* in force.
3345
*
34-
* Pass the same deadline the caller enforces in-process. A non-finite or
35-
* non-positive deadline means "no application bound", which disables the
36-
* transport timer rather than silently falling back to Bun's 300s default —
37-
* falling back is what produced the bug this exists to prevent.
46+
* Only use this where the caller genuinely enforces a deadline in-process —
47+
* an `AbortSignal` wired to a timer or an execution budget. Without one, a
48+
* request to a peer that never answers would hang until the socket dies.
3849
*/
39-
export function withFetchDeadline(
40-
init: RequestInit,
41-
deadlineMs: number | undefined
42-
): DeadlineRequestInit {
43-
if (deadlineMs === undefined || !Number.isFinite(deadlineMs) || deadlineMs <= 0) {
44-
return { ...init, timeout: false }
45-
}
46-
return { ...init, timeout: Math.ceil(deadlineMs) }
50+
export function withCallerOwnedDeadline(init: RequestInit): DeadlineRequestInit {
51+
return { ...init, timeout: false }
4752
}
4853

4954
/**

apps/sim/tools/index.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
validateUrlWithDNS,
2525
} from '@/lib/core/security/input-validation.server'
2626
import { PlatformEvents } from '@/lib/core/telemetry'
27-
import { isTransportTimeoutError, withFetchDeadline } from '@/lib/core/utils/fetch-deadline'
27+
import { isTransportTimeoutError, withCallerOwnedDeadline } from '@/lib/core/utils/fetch-deadline'
2828
import { HttpError } from '@/lib/core/utils/http-error'
2929
import { generateRequestId } from '@/lib/core/utils/request'
3030
import {
@@ -2444,17 +2444,19 @@ async function executeToolRequest(
24442444

24452445
const attemptStartedAt = Date.now()
24462446
try {
2447+
/*
2448+
* `controller` above is armed with `timeout`, so the plan deadline is
2449+
* already enforced in-process; the transport timer is disarmed so its
2450+
* 300s default cannot undercut it.
2451+
*/
24472452
const internalResponse = await fetch(
24482453
fullUrl,
2449-
withFetchDeadline(
2450-
{
2451-
method: requestParams.method,
2452-
headers: headers,
2453-
body: requestParams.body,
2454-
signal: controller.signal,
2455-
},
2456-
timeout
2457-
)
2454+
withCallerOwnedDeadline({
2455+
method: requestParams.method,
2456+
headers: headers,
2457+
body: requestParams.body,
2458+
signal: controller.signal,
2459+
})
24582460
)
24592461
if (
24602462
nullBodyStatuses.has(internalResponse.status) ||

0 commit comments

Comments
 (0)