Skip to content

Commit caa91bc

Browse files
author
Leonid Skorobogatyy
committed
fix: extract pure timeout logic, lazy import undici in SDK
Address review feedback: - Extract resolveTimeoutMs() as pure function so the mapping logic is testable under Bun without hitting the Node-only Agent guard - Test now verifies all mapping branches via resolveTimeoutMs + Agent instance check (toBeInstanceOf) instead of poking private fields - Restore browser-safety in SDK: lazy import('undici') inside guard instead of top-level static import that would pull node:* into bundles even when the runtime guard prevents execution
1 parent cd4a43b commit caa91bc

3 files changed

Lines changed: 83 additions & 38 deletions

File tree

packages/core/src/util/undici-dispatcher.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,27 @@ import { Agent } from "undici"
44
// This kills long-running provider requests — including SSE streams with gaps
55
// >300s between chunks — at the ~303s mark regardless of provider.timeout config.
66
//
7-
// createUndiciDispatcher maps the provider `timeout` option to undici's
8-
// headersTimeout AND bodyTimeout so that:
9-
// timeout: false → both set to 0 (disabled)
10-
// timeout: <number> → both set to that value (ms)
11-
// timeout: undefined → no dispatcher (undici defaults apply, i.e. 300s)
7+
// resolveTimeoutMs maps the provider `timeout` option to a millisecond value:
8+
// false → 0 (disabled)
9+
// <number> >0 → that value (ms)
10+
// anything else → undefined (no dispatcher, undici defaults apply)
1211
//
13-
// The dispatcher is only created under Node. Bun's fetch has no such defaults,
14-
// so the guard via process.versions.bun keeps Bun behavior unchanged.
12+
// createUndiciDispatcher wraps resolveTimeoutMs with a Node-only guard and
13+
// constructs the Agent. Bun's fetch has no such defaults, so the guard via
14+
// process.versions.bun keeps Bun behavior unchanged.
1515
//
1616
// This does not conflict with the existing chunkTimeout / headerTimeout abort
1717
// signals: those use AbortSignal/AbortController which are independent of the
1818
// undici dispatcher's socket-level timeouts.
19+
export function resolveTimeoutMs(timeout: unknown): number | undefined {
20+
if (timeout === false) return 0
21+
if (typeof timeout === "number" && timeout > 0) return timeout
22+
return undefined
23+
}
24+
1925
export function createUndiciDispatcher(timeout: unknown) {
2026
if (typeof process !== "object" || (process.versions as Record<string, string | undefined>).bun) return undefined
21-
const ms = timeout === false ? 0 : typeof timeout === "number" && timeout > 0 ? timeout : undefined
27+
const ms = resolveTimeoutMs(timeout)
2228
if (ms === undefined) return undefined
2329
return new Agent({ headersTimeout: ms, bodyTimeout: ms })
2430
}

packages/core/test/util/undici-dispatcher.test.ts

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,72 @@
11
import { describe, expect, test } from "bun:test"
2-
import { createUndiciDispatcher } from "@opencode-ai/core/util/undici-dispatcher"
2+
import { Agent } from "undici"
3+
import { resolveTimeoutMs, createUndiciDispatcher } from "@opencode-ai/core/util/undici-dispatcher"
34

4-
describe("createUndiciDispatcher", () => {
5-
test("positive number returns an Agent with matching headersTimeout and bodyTimeout", () => {
6-
const agent = createUndiciDispatcher(600_000) as InstanceType<typeof import("undici").Agent> | undefined
7-
if (!agent) return // Bun — dispatcher is always undefined
8-
expect((agent as unknown as { headersTimeout: number }).headersTimeout).toBe(600_000)
9-
expect((agent as unknown as { bodyTimeout: number }).bodyTimeout).toBe(600_000)
5+
describe("resolveTimeoutMs", () => {
6+
test("false → 0 (disabled)", () => {
7+
expect(resolveTimeoutMs(false)).toBe(0)
108
})
119

12-
test("false returns an Agent with headersTimeout and bodyTimeout set to 0", () => {
13-
const agent = createUndiciDispatcher(false) as InstanceType<typeof import("undici").Agent> | undefined
14-
if (!agent) return // Bun
15-
expect((agent as unknown as { headersTimeout: number }).headersTimeout).toBe(0)
16-
expect((agent as unknown as { bodyTimeout: number }).bodyTimeout).toBe(0)
10+
test("positive number → that value in ms", () => {
11+
expect(resolveTimeoutMs(600_000)).toBe(600_000)
1712
})
1813

19-
test("undefined returns undefined (undici defaults apply)", () => {
20-
expect(createUndiciDispatcher(undefined)).toBeUndefined()
14+
test("undefined undefined (undici defaults apply)", () => {
15+
expect(resolveTimeoutMs(undefined)).toBeUndefined()
2116
})
2217

23-
test("0 returns undefined (not a positive number)", () => {
24-
expect(createUndiciDispatcher(0)).toBeUndefined()
18+
test("0 undefined (not a positive number)", () => {
19+
expect(resolveTimeoutMs(0)).toBeUndefined()
2520
})
2621

27-
test("negative number returns undefined", () => {
28-
expect(createUndiciDispatcher(-1)).toBeUndefined()
22+
test("negative number undefined", () => {
23+
expect(resolveTimeoutMs(-1)).toBeUndefined()
2924
})
3025

31-
test("null returns undefined", () => {
32-
expect(createUndiciDispatcher(null)).toBeUndefined()
26+
test("null undefined", () => {
27+
expect(resolveTimeoutMs(null)).toBeUndefined()
3328
})
3429

35-
test("string returns undefined", () => {
36-
expect(createUndiciDispatcher("300000")).toBeUndefined()
30+
test("string undefined", () => {
31+
expect(resolveTimeoutMs("300000")).toBeUndefined()
3732
})
33+
})
3834

35+
describe("createUndiciDispatcher", () => {
3936
test("Bun guard: returns undefined when process.versions.bun is set", () => {
4037
const original = (process.versions as Record<string, string | undefined>).bun
4138
;(process.versions as Record<string, string | undefined>).bun = "1.0.0"
4239
try {
4340
expect(createUndiciDispatcher(600_000)).toBeUndefined()
41+
expect(createUndiciDispatcher(false)).toBeUndefined()
42+
} finally {
43+
if (original === undefined) delete (process.versions as Record<string, string | undefined>).bun
44+
else (process.versions as Record<string, string | undefined>).bun = original
45+
}
46+
})
47+
48+
test("returns undefined when no bun version and timeout resolves to undefined", () => {
49+
const original = (process.versions as Record<string, string | undefined>).bun
50+
;(process.versions as Record<string, string | undefined>).bun = undefined
51+
try {
52+
expect(createUndiciDispatcher(undefined)).toBeUndefined()
53+
expect(createUndiciDispatcher(0)).toBeUndefined()
54+
} finally {
55+
if (original === undefined) delete (process.versions as Record<string, string | undefined>).bun
56+
else (process.versions as Record<string, string | undefined>).bun = original
57+
}
58+
})
59+
60+
// Under Bun, createUndiciDispatcher always returns undefined because of the
61+
// guard. The Agent construction path only runs under Node. We verify it
62+
// returns an Agent instance (not private fields) when the bun guard is
63+
// removed and a valid timeout is provided.
64+
test("returns an Agent instance under Node (bun guard bypassed)", () => {
65+
const original = (process.versions as Record<string, string | undefined>).bun
66+
;(process.versions as Record<string, string | undefined>).bun = undefined
67+
try {
68+
const agent = createUndiciDispatcher(600_000)
69+
expect(agent).toBeInstanceOf(Agent)
4470
} finally {
4571
if (original === undefined) delete (process.versions as Record<string, string | undefined>).bun
4672
else (process.versions as Record<string, string | undefined>).bun = original

packages/sdk/js/src/node-fetch.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
1-
import { Agent } from "undici"
2-
31
// undici's fetch defaults headersTimeout and bodyTimeout to 300s, which kills
42
// long-running sessions at the ~303s mark. The SDK client→server connection
53
// (blocking POST /session/:id/message) needs both disabled so the request can
64
// survive past 5 minutes. Bun is unaffected — its fetch has no such defaults.
7-
const nodeDispatcher =
8-
typeof process === "undefined" || !process.versions || process.versions.bun
9-
? undefined
10-
: new Agent({ headersTimeout: 0, bodyTimeout: 0 })
5+
//
6+
// undici is a hard dependency, but we still import it lazily inside the guard
7+
// so browser bundlers don't pull undici (and its node:* deps) into the bundle
8+
// when the SDK is loaded in a non-Node environment.
9+
type UndiciAgent = InstanceType<typeof import("undici").Agent>
10+
11+
let nodeDispatcher: UndiciAgent | undefined | null
12+
13+
async function ensureDispatcher() {
14+
if (nodeDispatcher !== undefined) return nodeDispatcher
15+
if (typeof process === "undefined" || !process.versions || process.versions.bun) {
16+
nodeDispatcher = null
17+
return null
18+
}
19+
const { Agent } = await import("undici")
20+
nodeDispatcher = new Agent({ headersTimeout: 0, bodyTimeout: 0 })
21+
return nodeDispatcher
22+
}
1123

1224
export async function nodeFetchWithDispatcher(req: Request) {
13-
if (!nodeDispatcher) return fetch(req)
14-
return fetch(req, { dispatcher: nodeDispatcher } as RequestInit)
25+
const dispatcher = await ensureDispatcher()
26+
if (!dispatcher) return fetch(req)
27+
return fetch(req, { dispatcher } as RequestInit)
1528
}

0 commit comments

Comments
 (0)