Compare commits

...

2 Commits

Author SHA1 Message Date
claude[bot]
30722c521a fix: update @intCast usage to match current Zig syntax
The @intCast function signature changed in newer versions of Zig.
Updated from @intCast(type, value) to @as(type, @intCast(value)) syntax.

Co-authored-by: Jarred-Sumner <Jarred-Sumner@users.noreply.github.com>
2025-05-19 18:56:06 +00:00
Jarred Sumner
b4937ead09 test: add fetch timeout option 2025-05-19 10:30:47 -07:00
3 changed files with 71 additions and 1 deletions

View File

@@ -12,6 +12,7 @@ const api = bun.api;
const StatWatcherScheduler = @import("../node/node_fs_stat_watcher.zig").StatWatcherScheduler;
const Timer = @This();
const DNSResolver = @import("./bun/dns_resolver.zig").DNSResolver;
const Fetch = JSC.WebCore.Fetch;
/// TimeoutMap is map of i32 to nullable Timeout structs
/// i32 is exposed to JavaScript and can be used with clearTimeout, clearInterval, etc.
@@ -1339,6 +1340,7 @@ pub const EventLoopTimer = struct {
ValkeyConnectionTimeout,
ValkeyConnectionReconnect,
SubprocessTimeout,
FetchTimeout,
DevServerSweepSourceMaps,
DevServerMemoryVisualizerTick,
@@ -1356,6 +1358,7 @@ pub const EventLoopTimer = struct {
.PostgresSQLConnectionTimeout => JSC.Postgres.PostgresSQLConnection,
.PostgresSQLConnectionMaxLifetime => JSC.Postgres.PostgresSQLConnection,
.SubprocessTimeout => JSC.Subprocess,
.FetchTimeout => Fetch.FetchTasklet,
.ValkeyConnectionReconnect => JSC.API.Valkey,
.ValkeyConnectionTimeout => JSC.API.Valkey,
.DevServerSweepSourceMaps,
@@ -1377,6 +1380,7 @@ pub const EventLoopTimer = struct {
ValkeyConnectionTimeout,
ValkeyConnectionReconnect,
SubprocessTimeout,
FetchTimeout,
DevServerSweepSourceMaps,
DevServerMemoryVisualizerTick,
@@ -1395,6 +1399,7 @@ pub const EventLoopTimer = struct {
.ValkeyConnectionTimeout => JSC.API.Valkey,
.ValkeyConnectionReconnect => JSC.API.Valkey,
.SubprocessTimeout => JSC.Subprocess,
.FetchTimeout => Fetch.FetchTasklet,
.DevServerSweepSourceMaps,
.DevServerMemoryVisualizerTick,
=> bun.bake.DevServer,
@@ -1457,6 +1462,7 @@ pub const EventLoopTimer = struct {
.PostgresSQLConnectionMaxLifetime => return @as(*api.Postgres.PostgresSQLConnection, @alignCast(@fieldParentPtr("max_lifetime_timer", this))).onMaxLifetimeTimeout(),
.ValkeyConnectionTimeout => return @as(*api.Valkey, @alignCast(@fieldParentPtr("timer", this))).onConnectionTimeout(),
.ValkeyConnectionReconnect => return @as(*api.Valkey, @alignCast(@fieldParentPtr("reconnect_timer", this))).onReconnectTimer(),
.FetchTimeout => return @as(*Fetch.FetchTasklet, @alignCast(@fieldParentPtr("timeout_timer", this))).onTimeout(),
.DevServerMemoryVisualizerTick => return bun.bake.DevServer.emitMemoryVisualizerMessageTimer(this, now),
.DevServerSweepSourceMaps => return bun.bake.DevServer.SourceMapStore.sweepWeakRefs(this, now),
inline else => |t| {

View File

@@ -90,6 +90,9 @@ pub const FetchTasklet = struct {
promise: JSC.JSPromise.Strong,
concurrent_task: JSC.ConcurrentTask = .{},
poll_ref: Async.KeepAlive = .{},
timeout_ms: u32 = 0,
timeout_timer: bun.api.Timer.EventLoopTimer = bun.api.Timer.EventLoopTimer.initPaused(.FetchTimeout),
timeout_timer_refd: bool = false,
memory_reporter: *bun.MemoryReportingAllocator,
/// For Http Client requests
/// when Content-Length is provided this represents the whole size of the request
@@ -312,6 +315,7 @@ pub const FetchTasklet = struct {
bun.assert(this.ref_count.load(.monotonic) == 0);
this.clearTimeoutTimer();
this.clearData();
var reporter = this.memory_reporter;
@@ -645,6 +649,7 @@ pub const FetchTasklet = struct {
var poll_ref = this.poll_ref;
this.poll_ref = .{};
poll_ref.unref(vm);
this.clearTimeoutTimer();
this.deref();
}
}
@@ -1084,6 +1089,7 @@ pub const FetchTasklet = struct {
// we should not keep the process alive if we are ignoring the body
const vm = this.javascript_vm;
this.poll_ref.unref(vm);
this.clearTimeoutTimer();
// clean any remaining refereces
this.readable_stream_ref.deinit();
this.response.deinit();
@@ -1173,6 +1179,9 @@ pub const FetchTasklet = struct {
.url_proxy_buffer = fetch_options.url_proxy_buffer,
.signal = fetch_options.signal,
.hostname = fetch_options.hostname,
.timeout_ms = fetch_options.timeout_ms,
.timeout_timer = bun.api.Timer.EventLoopTimer.initPaused(.FetchTimeout),
.timeout_timer_refd = false,
.tracker = JSC.Debugger.AsyncTaskTracker.init(jsc_vm),
.memory_reporter = fetch_options.memory_reporter,
.check_server_identity = fetch_options.check_server_identity,
@@ -1283,15 +1292,33 @@ pub const FetchTasklet = struct {
}
}
fn clearTimeoutTimer(this: *FetchTasklet) void {
if (this.timeout_timer.state == .ACTIVE) {
this.javascript_vm.timer.remove(&this.timeout_timer);
}
if (this.timeout_timer_refd) {
this.javascript_vm.timer.incrementTimerRef(-1);
this.timeout_timer_refd = false;
}
}
pub fn abortTask(this: *FetchTasklet) void {
this.signal_store.aborted.store(true, .monotonic);
this.tracker.didCancel(this.global_this);
this.clearTimeoutTimer();
if (this.http) |http_| {
http.http_thread.scheduleShutdown(http_);
}
}
pub fn onTimeout(this: *FetchTasklet) bun.api.Timer.EventLoopTimer.Arm {
this.clearTimeoutTimer();
const reason = JSC.CommonAbortReason.Timeout.toJS(this.global_this);
this.abortListener(reason);
return .disarm;
}
const FetchOptions = struct {
method: Method,
headers: Headers,
@@ -1307,6 +1334,7 @@ pub const FetchTasklet = struct {
url_proxy_buffer: []const u8 = "",
signal: ?*JSC.WebCore.AbortSignal = null,
globalThis: ?*JSGlobalObject,
timeout_ms: u32 = 0,
// Custom Hostname
hostname: ?[]u8 = null,
memory_reporter: *bun.MemoryReportingAllocator,
@@ -1335,6 +1363,12 @@ pub const FetchTasklet = struct {
// increment ref so we can keep it alive until the http client is done
node.ref();
if (fetch_options.timeout_ms > 0) {
node.timeout_timer.next = bun.timespec.msFromNow(fetch_options.timeout_ms);
global.bunVM().timer.insert(&node.timeout_timer);
global.bunVM().timer.incrementTimerRef(1);
node.timeout_timer_refd = true;
}
http.http_thread.schedule(batch);
return node;
@@ -1567,6 +1601,7 @@ pub fn Bun__fetch_(
var body: FetchTasklet.HTTPRequestBody = FetchTasklet.HTTPRequestBody.Empty;
var disable_timeout = false;
var timeout_ms: u32 = 0;
var disable_keepalive = false;
var disable_decompression = false;
var verbose: http.HTTPVerboseLevel = if (vm.log.level.atLeast(.debug)) .headers else .none;
@@ -1888,9 +1923,17 @@ pub fn Bun__fetch_(
if (objects_to_try[i] != .zero) {
if (try objects_to_try[i].get(globalThis, "timeout")) |timeout_value| {
if (timeout_value.isBoolean()) {
timeout_ms = 0;
break :extract_disable_timeout !timeout_value.asBoolean();
} else if (timeout_value.isNumber()) {
break :extract_disable_timeout timeout_value.to(i32) == 0;
const val = timeout_value.to(i32);
if (val <= 0) {
timeout_ms = 0;
break :extract_disable_timeout val == 0;
} else {
timeout_ms = @as(u32, @intCast(val));
break :extract_disable_timeout false;
}
}
}
@@ -2684,6 +2727,7 @@ pub fn Bun__fetch_(
.url_proxy_buffer = url_proxy_buffer,
.signal = signal,
.globalThis = globalThis,
.timeout_ms = timeout_ms,
.ssl_config = ssl_config,
.hostname = hostname,
.memory_reporter = memory_reporter,

View File

@@ -0,0 +1,20 @@
import { expect, test } from "bun:test";
// numeric timeout option should abort fetch when exceeded
test("fetch timeout option aborts request", async () => {
try {
using server = Bun.serve({
port: 0,
async fetch() {
await Bun.sleep(100);
return new Response("unreachable");
},
});
await fetch(server.url, { timeout: 10 });
expect.unreachable();
} catch (err: any) {
expect(err.name).toBe("TimeoutError");
}
});