diff --git a/src/bun.js/ConsoleObject.zig b/src/bun.js/ConsoleObject.zig index bc02ed107a..4d8f836932 100644 --- a/src/bun.js/ConsoleObject.zig +++ b/src/bun.js/ConsoleObject.zig @@ -2589,19 +2589,26 @@ pub const Formatter = struct { // this case should never happen return try this.printAs(.Undefined, Writer, writer_, .undefined, .Cell, enable_ansi_colors); - } else if (value.as(JSC.API.Bun.Timer.TimerObject)) |timer| { - this.addForNewLine("Timeout(# ) ".len + bun.fmt.fastDigitCount(@as(u64, @intCast(@max(timer.id, 0))))); - if (timer.kind == .setInterval) { - this.addForNewLine("repeats ".len + bun.fmt.fastDigitCount(@as(u64, @intCast(@max(timer.id, 0))))); + } else if (value.as(JSC.API.Bun.Timer.TimeoutObject)) |timer| { + this.addForNewLine("Timeout(# ) ".len + bun.fmt.fastDigitCount(@as(u64, @intCast(@max(timer.internals.id, 0))))); + if (timer.internals.kind == .setInterval) { + this.addForNewLine("repeats ".len + bun.fmt.fastDigitCount(@as(u64, @intCast(@max(timer.internals.id, 0))))); writer.print(comptime Output.prettyFmt("Timeout (#{d}, repeats)", enable_ansi_colors), .{ - timer.id, + timer.internals.id, }); } else { writer.print(comptime Output.prettyFmt("Timeout (#{d})", enable_ansi_colors), .{ - timer.id, + timer.internals.id, }); } + return; + } else if (value.as(JSC.API.Bun.Timer.ImmediateObject)) |immediate| { + this.addForNewLine("Immediate(# ) ".len + bun.fmt.fastDigitCount(@as(u64, @intCast(@max(immediate.internals.id, 0))))); + writer.print(comptime Output.prettyFmt("Immediate (#{d})", enable_ansi_colors), .{ + immediate.internals.id, + }); + return; } else if (value.as(JSC.BuildMessage)) |build_log| { build_log.msg.writeFormat(writer_, enable_ansi_colors) catch {}; diff --git a/src/bun.js/api/Timer.zig b/src/bun.js/api/Timer.zig index bc8e5a7d34..0ac80c4c21 100644 --- a/src/bun.js/api/Timer.zig +++ b/src/bun.js/api/Timer.zig @@ -3,6 +3,7 @@ const bun = @import("root").bun; const JSC = bun.JSC; const VirtualMachine = JSC.VirtualMachine; const JSValue = JSC.JSValue; +const JSError = bun.JSError; const JSGlobalObject = JSC.JSGlobalObject; const Debugger = JSC.Debugger; const Environment = bun.Environment; @@ -23,17 +24,117 @@ pub const TimeoutMap = std.AutoArrayHashMapUnmanaged( *EventLoopTimer, ); -const TimerHeap = heap.Intrusive(EventLoopTimer, void, EventLoopTimer.less); +/// Array of linked lists of EventLoopTimers. Each list holds all the timers that will fire in the +/// same millisecond, in the order they will fire. +const TimerList = struct { + const log = bun.Output.scoped(.TimerList, false); + // there might be a better data structure we could use here (the current one has some O(n) cases + // to remove and add new lists), but cursory testing showed similar performance to the old + // heap implementation + lists: std.ArrayListUnmanaged(List), + + pub const empty: TimerList = .{ .lists = .empty }; + + /// Add a new timer into the list + pub fn insert(self: *TimerList, timer: *EventLoopTimer.Node) void { + log("insert {*}", .{timer}); + const target_list_index = std.sort.lowerBound(List, self.lists.items, &timer.data.next, List.compare); + if (target_list_index == self.lists.items.len) { + // suitable insertion point not found so insert at the end + log("new list at end", .{}); + self.lists.append(bun.default_allocator, .init(timer.data.next)) catch bun.outOfMemory(); + // now target_list_index is a valid index and points to the right list + } else if (List.compare(&timer.data.next, self.lists.items[target_list_index]) != .eq) { + // lowerBound did not find an exact match, so target_list_index is really the index of + // the first list *after* the one we want to use. + // so we need to add a new list in the middle, before the list currently at target_list_index + log("new list in middle", .{}); + self.lists.insert(bun.default_allocator, target_list_index, .init(timer.data.next)) catch bun.outOfMemory(); + // now target_list_index points to the list we just inserted + } + const list = &self.lists.items[target_list_index]; + list.timers.append(timer); + } + + /// Remove the given timer + pub fn remove(self: *TimerList, timer: *EventLoopTimer.Node) void { + log("remove {*}", .{timer}); + const maybe_list_containing_index = std.sort.binarySearch(List, self.lists.items, &timer.data.next, List.compare); + // in safe builds, assert we found the list. in unsafe builds, do not remove anything + assert(maybe_list_containing_index != null); + const list_containing_index = maybe_list_containing_index orelse return; + const list_containing = &self.lists.items[list_containing_index].timers; + list_containing.remove(timer); + if (list_containing.len == 0) { + log("delete list", .{}); + _ = self.lists.orderedRemove(list_containing_index); + } + } + + /// Get the timer that will fire next, but don't remove it + pub fn peek(self: *const TimerList) ?*EventLoopTimer.Node { + if (self.lists.items.len == 0) { + return null; + } else { + assert(self.lists.items[0].timers.len > 0); + return self.lists.items[0].timers.first; + } + } + + /// Remove and return the next timer to fire + pub fn deleteMin(self: *TimerList) ?*EventLoopTimer.Node { + if (self.lists.items.len == 0) { + // empty + return null; + } else { + const list = &self.lists.items[0].timers; + const timer = list.popFirst(); + // if this list contains no timers it should have been removed + assert(timer != null); + // if it is now empty then we remove it from the list of lists + if (list.len == 0) { + _ = self.lists.orderedRemove(0); + } + return timer; + } + } + + const List = struct { + absolute_time: struct { + sec: isize, + msec: i16, + }, + timers: std.DoublyLinkedList(EventLoopTimer), + + pub fn init(time: bun.timespec) List { + return .{ + .absolute_time = .{ + .sec = time.sec, + .msec = @intCast(@divTrunc(time.nsec, std.time.ns_per_ms)), + }, + .timers = .{}, + }; + } + + pub fn compare(context: *const bun.timespec, item: List) std.math.Order { + const sec_order = std.math.order(context.sec, item.absolute_time.sec); + if (sec_order != .eq) return sec_order; + return std.math.order(@divTrunc(context.nsec, std.time.ns_per_ms), item.absolute_time.msec); + } + }; +}; pub const All = struct { last_id: i32 = 1, lock: bun.Mutex = .{}, thread_id: std.Thread.Id, - timers: TimerHeap = .{ - .context = {}, - }, + timers: TimerList = .empty, active_timer_count: i32 = 0, uv_timer: if (Environment.isWindows) uv.Timer else void = if (Environment.isWindows) std.mem.zeroes(uv.Timer), + /// Whether we have emitted a warning for passing a negative timeout duration + warned_negative_number: bool = false, + /// Whether we have emitted a warning for passing NaN for the timeout duration + warned_not_number: bool = false, // We split up the map here to avoid storing an extra "repeat" boolean maps: struct { @@ -56,41 +157,40 @@ pub const All = struct { }; } - pub fn insert(this: *All, timer: *EventLoopTimer) void { + pub fn insert(this: *All, timer: *EventLoopTimer.Node) void { this.lock.lock(); defer this.lock.unlock(); this.timers.insert(timer); - timer.state = .ACTIVE; + timer.data.state = .ACTIVE; if (Environment.isWindows) { this.ensureUVTimer(@alignCast(@fieldParentPtr("timer", this))); } } - pub fn remove(this: *All, timer: *EventLoopTimer) void { + pub fn remove(this: *All, timer: *EventLoopTimer.Node) void { this.lock.lock(); defer this.lock.unlock(); this.timers.remove(timer); - timer.state = .CANCELLED; - timer.heap = .{}; + timer.data.state = .CANCELLED; } /// Remove the EventLoopTimer if necessary. - pub fn update(this: *All, timer: *EventLoopTimer, time: *const timespec) void { + pub fn update(this: *All, timer: *EventLoopTimer.Node, time: *const timespec) void { this.lock.lock(); defer this.lock.unlock(); - if (timer.state == .ACTIVE) { + if (timer.data.state == .ACTIVE) { this.timers.remove(timer); } - timer.state = .ACTIVE; + timer.data.state = .ACTIVE; if (comptime Environment.isDebug) { - if (&timer.next == time) { + if (&timer.data.next == time) { @panic("timer.next == time. For threadsafety reasons, time and timer.next must always be a different pointer."); } } - timer.next = time.*; + timer.data.next = time.*; this.timers.insert(timer); if (Environment.isWindows) { @@ -108,8 +208,8 @@ pub const All = struct { if (this.timers.peek()) |timer| { uv.uv_update_time(vm.uvLoop()); const now = timespec.now(); - const wait = if (timer.next.greater(&now)) - timer.next.duration(&now) + const wait = if (timer.data.next.greater(&now)) + timer.data.next.duration(&now) else timespec{ .nsec = 0, .sec = 0 }; @@ -169,11 +269,12 @@ pub const All = struct { var now: timespec = undefined; var has_set_now: bool = false; - while (this.timers.peek()) |min| { + while (this.timers.peek()) |min_node| { if (!has_set_now) { now = timespec.now(); has_set_now = true; } + const min = &min_node.data; switch (now.order(&min.next)) { .gt, .eq => { @@ -212,7 +313,7 @@ pub const All = struct { // And when we do call it, we want to be sure we only call it once. // and we do NOT want to hold the lock while the timer is running it's code. // This function has to be thread-safe. - fn next(this: *All, has_set_now: *bool, now: *timespec) ?*EventLoopTimer { + fn next(this: *All, has_set_now: *bool, now: *timespec) ?*EventLoopTimer.Node { this.lock.lock(); defer this.lock.unlock(); @@ -221,7 +322,7 @@ pub const All = struct { now.* = timespec.now(); has_set_now.* = true; } - if (timer.next.greater(now)) { + if (timer.data.next.greater(now)) { return null; } @@ -239,7 +340,7 @@ pub const All = struct { var has_set_now: bool = false; while (this.next(&has_set_now, &now)) |t| { - switch (t.fire( + switch (t.data.fire( &now, vm, )) { @@ -249,38 +350,37 @@ pub const All = struct { } } + const SetRequest = union(Kind) { + setTimeout: u31, + setInterval: u31, + setImmediate, + }; + fn set( id: i32, globalThis: *JSGlobalObject, callback: JSValue, - interval: i32, + request: SetRequest, arguments_array_or_zero: JSValue, - repeat: bool, - ) !JSC.JSValue { + ) JSC.JSValue { JSC.markBinding(@src()); var vm = globalThis.bunVM(); + const kind: Kind = request; - const kind: Kind = if (repeat) .setInterval else .setTimeout; - - // setImmediate(foo) - if (kind == .setTimeout and interval == 0) { - const timer_object, const timer_js = TimerObject.init(globalThis, vm, id, .setImmediate, 0, callback, arguments_array_or_zero); - timer_object.ref(); - vm.enqueueImmediateTask(JSC.Task.init(timer_object)); - if (vm.isInspectorEnabled()) { - Debugger.didScheduleAsyncCall(globalThis, .DOMTimer, ID.asyncID(.{ .id = id, .kind = kind }), !repeat); - } - return timer_js; - } - - const timer_object, const timer_js = TimerObject.init(globalThis, vm, id, kind, interval, callback, arguments_array_or_zero); - _ = timer_object; // autofix + const js = switch (request) { + .setImmediate => ImmediateObject.init(globalThis, id, callback, arguments_array_or_zero), + .setTimeout, .setInterval => |countdown| TimeoutObject.init(globalThis, id, kind, countdown, callback, arguments_array_or_zero), + }; if (vm.isInspectorEnabled()) { - Debugger.didScheduleAsyncCall(globalThis, .DOMTimer, ID.asyncID(.{ .id = id, .kind = kind }), !repeat); + Debugger.didScheduleAsyncCall( + globalThis, + .DOMTimer, + ID.asyncID(.{ .id = id, .kind = kind }), + kind != .setInterval, // single_shot + ); } - - return timer_js; + return js; } pub fn setImmediate( @@ -292,16 +392,88 @@ pub const All = struct { const id = globalThis.bunVM().timer.last_id; globalThis.bunVM().timer.last_id +%= 1; - const interval: i32 = 0; - const wrappedCallback = callback.withAsyncContextIfNeeded(globalThis); - return set(id, globalThis, wrappedCallback, interval, arguments, false) catch - return JSValue.jsUndefined(); + return set(id, globalThis, wrappedCallback, .setImmediate, arguments); } - comptime { - @export(&setImmediate, .{ .name = "Bun__Timer__setImmediate" }); + const TimeoutWarning = enum { + TimeoutOverflowWarning, + TimeoutNegativeWarning, + TimeoutNaNWarning, + }; + + fn warnInvalidCountdown(globalThis: *JSGlobalObject, countdown: f64, warning_type: TimeoutWarning) void { + const suffix = ".\nTimeout duration was set to 1."; + + var warning_string = switch (warning_type) { + .TimeoutOverflowWarning => if (std.math.isFinite(countdown)) + bun.String.createFormat( + "{d} does not fit into a 32-bit signed integer" ++ suffix, + .{countdown}, + ) catch bun.outOfMemory() + else + // -Infinity is handled by TimeoutNegativeWarning + bun.String.ascii("Infinity does not fit into a 32-bit signed integer" ++ suffix), + .TimeoutNegativeWarning => if (std.math.isFinite(countdown)) + bun.String.createFormat( + "{d} is a negative number" ++ suffix, + .{countdown}, + ) catch bun.outOfMemory() + else + bun.String.ascii("-Infinity is a negative number" ++ suffix), + // std.fmt gives us "nan" but Node.js wants "NaN". + .TimeoutNaNWarning => nan_warning: { + assert(std.math.isNan(countdown)); + break :nan_warning bun.String.ascii("NaN is not a number" ++ suffix); + }, + }; + var warning_type_string = bun.String.createAtomIfPossible(@tagName(warning_type)); + // these arguments are valid so emitWarning won't throw + globalThis.emitWarning( + warning_string.transferToJS(globalThis), + warning_type_string.transferToJS(globalThis), + .undefined, + .undefined, + ) catch unreachable; + } + + const CountdownOverflowBehavior = enum(u8) { + /// If the countdown overflows the range of int32_t, use a countdown of 1ms instead. Behavior of `setTimeout` and friends. + one_ms, + /// If the countdown overflows the range of int32_t, clamp to the nearest value within the range. Behavior of `Bun.sleep`. + clamp, + }; + + /// Convert an arbitrary JavaScript value to a number of milliseconds used to schedule a timer. + fn jsValueToCountdown( + this: *All, + globalThis: *JSGlobalObject, + countdown: JSValue, + overflow_behavior: CountdownOverflowBehavior, + ) u31 { + // We don't deal with nesting levels directly + // but we do set the minimum timeout to be 1ms for repeating timers + // TODO: this is wrong as it clears exceptions (e.g `setTimeout(()=>{}, { [Symbol.toPrimitive]() { throw 'oops'; } })`) + const countdown_double = countdown.coerceToDouble(globalThis); + + const countdown_int: u31 = switch (overflow_behavior) { + .clamp => std.math.lossyCast(u31, countdown_double), + .one_ms => if (!(countdown_double >= 1 and countdown_double <= std.math.maxInt(u31))) one: { + if (countdown_double > std.math.maxInt(u31)) { + warnInvalidCountdown(globalThis, countdown_double, .TimeoutOverflowWarning); + } else if (countdown_double < 0 and !this.warned_negative_number) { + this.warned_negative_number = true; + warnInvalidCountdown(globalThis, countdown_double, .TimeoutNegativeWarning); + } else if (std.math.isNan(countdown_double) and !this.warned_not_number) { + this.warned_not_number = true; + warnInvalidCountdown(globalThis, countdown_double, .TimeoutNaNWarning); + } + break :one 1; + } else @intFromFloat(countdown_double), + }; + + return countdown_int; } pub fn setTimeout( @@ -309,21 +481,17 @@ pub const All = struct { callback: JSValue, countdown: JSValue, arguments: JSValue, + overflow_behavior: CountdownOverflowBehavior, ) callconv(.C) JSValue { JSC.markBinding(@src()); const id = globalThis.bunVM().timer.last_id; globalThis.bunVM().timer.last_id +%= 1; - const interval: i32 = @max( - countdown.coerce(i32, globalThis), - // It must be 1 at minimum or setTimeout(cb, 0) will seemingly hang - 1, - ); + const countdown_int = globalThis.bunVM().timer.jsValueToCountdown(globalThis, countdown, overflow_behavior); const wrappedCallback = callback.withAsyncContextIfNeeded(globalThis); - return set(id, globalThis, wrappedCallback, interval, arguments, false) catch - return JSValue.jsUndefined(); + return set(id, globalThis, wrappedCallback, .{ .setTimeout = countdown_int }, arguments); } pub fn setInterval( globalThis: *JSGlobalObject, @@ -337,61 +505,105 @@ pub const All = struct { const wrappedCallback = callback.withAsyncContextIfNeeded(globalThis); - // We don't deal with nesting levels directly - // but we do set the minimum timeout to be 1ms for repeating timers - const interval: i32 = @max( - countdown.coerce(i32, globalThis), - 1, - ); - return set(id, globalThis, wrappedCallback, interval, arguments, true) catch - return JSValue.jsUndefined(); + const countdown_int = globalThis.bunVM().timer.jsValueToCountdown(globalThis, countdown, .one_ms); + + return set(id, globalThis, wrappedCallback, .{ .setInterval = countdown_int }, arguments); } - pub fn clearTimer(timer_id_value: JSValue, globalThis: *JSGlobalObject, repeats: bool) void { + fn removeTimerById(this: *All, id: i32) ?*TimeoutObject { + if (this.maps.setTimeout.fetchSwapRemove(id)) |entry| { + bun.assert(entry.value.tag == .TimeoutObject); + const node: *EventLoopTimer.Node = @fieldParentPtr("data", entry.value); + return @fieldParentPtr("event_loop_timer", node); + } else if (this.maps.setInterval.fetchSwapRemove(id)) |entry| { + bun.assert(entry.value.tag == .TimeoutObject); + const node: *EventLoopTimer.Node = @fieldParentPtr("data", entry.value); + return @fieldParentPtr("event_loop_timer", node); + } else return null; + } + + pub fn clearTimer(timer_id_value: JSValue, globalThis: *JSGlobalObject, kind: Kind) !void { JSC.markBinding(@src()); - const kind: Kind = if (repeats) .setInterval else .setTimeout; - var vm = globalThis.bunVM(); - var map = vm.timer.maps.get(kind); + const vm = globalThis.bunVM(); - const timer: *TimerObject = brk: { - if (timer_id_value.isAnyInt()) { - if (map.fetchSwapRemove(timer_id_value.coerce(i32, globalThis))) |entry| { - // Don't forget to check the type tag. - // When we start using this list of timers for more things - // It would be a weird situation, security-wise, if we were to let - // the user cancel a timer that was of a different type. - if (entry.value.tag == .TimerObject) { - break :brk @as( - *TimerObject, - @fieldParentPtr("event_loop_timer", entry.value), - ); + const timer: *TimerObjectInternals = brk: { + if (timer_id_value.isInt32()) { + // Immediates don't have numeric IDs in Node.js so we only have to look up timeouts and intervals + break :brk &(vm.timer.removeTimerById(timer_id_value.asInt32()) orelse return).internals; + } else if (timer_id_value.isStringLiteral()) { + const string = try timer_id_value.toBunString(globalThis); + defer string.deref(); + // Custom parseInt logic. I've done this because Node.js is very strict about string + // parameters to this function: they can't have leading whitespace, trailing + // characters, signs, or even leading zeroes. None of the readily-available string + // parsing functions are this strict. The error case is to just do nothing (not + // clear any timer). + // + // The reason is that in Node.js this function's parameter is used for an array + // lookup, and array[0] is the same as array['0'] in JS but not the same as array['00']. + const parsed = parsed: { + var accumulator: i32 = 0; + switch (string.encoding()) { + // We can handle all encodings the same way since the only permitted characters + // are ASCII. + inline else => |encoding| { + // Call the function named for this encoding (.latin1(), etc.) + const slice = @field(bun.String, @tagName(encoding))(string); + for (slice, 0..) |c, i| { + if (c < '0' or c > '9') { + // Non-digit characters are not allowed + return; + } else if (i == 0 and c == '0') { + // Leading zeroes are not allowed + return; + } + // Fail on overflow + accumulator = std.math.mul(i32, 10, accumulator) catch return; + accumulator = std.math.add(i32, accumulator, c - '0') catch return; + } + }, } - } - - break :brk null; + break :parsed accumulator; + }; + break :brk &(vm.timer.removeTimerById(parsed) orelse return).internals; } - break :brk TimerObject.fromJS(timer_id_value); + break :brk if (TimeoutObject.fromJS(timer_id_value)) |timeout| + &timeout.internals + else if (ImmediateObject.fromJS(timer_id_value)) |immediate| + // setImmediate can only be cleared by clearImmediate, not by clearTimeout or clearInterval. + // setTimeout and setInterval can be cleared by any of the 3 clear functions. + if (kind == .setImmediate) &immediate.internals else return + else + null; } orelse return; timer.cancel(vm); } + pub fn clearImmediate( + globalThis: *JSGlobalObject, + id: JSValue, + ) callconv(.c) JSValue { + JSC.markBinding(@src()); + clearTimer(id, globalThis, .setImmediate) catch {}; + return JSValue.jsUndefined(); + } pub fn clearTimeout( globalThis: *JSGlobalObject, id: JSValue, - ) callconv(.C) JSValue { + ) callconv(.c) JSValue { JSC.markBinding(@src()); - clearTimer(id, globalThis, false); + clearTimer(id, globalThis, .setTimeout) catch {}; return JSValue.jsUndefined(); } pub fn clearInterval( globalThis: *JSGlobalObject, id: JSValue, - ) callconv(.C) JSValue { + ) callconv(.c) JSValue { JSC.markBinding(@src()); - clearTimer(id, globalThis, true); + clearTimer(id, globalThis, .setInterval) catch {}; return JSValue.jsUndefined(); } @@ -403,28 +615,192 @@ pub const All = struct { pub const namespace = shim.namespace; pub const Export = shim.exportFunctions(.{ + .setImmediate = setImmediate, .setTimeout = setTimeout, .setInterval = setInterval, + .clearImmediate = clearImmediate, .clearTimeout = clearTimeout, .clearInterval = clearInterval, .getNextID = getNextID, }); comptime { - @export(&setTimeout, .{ .name = Export[0].symbol_name }); - @export(&setInterval, .{ .name = Export[1].symbol_name }); - @export(&clearTimeout, .{ .name = Export[2].symbol_name }); - @export(&clearInterval, .{ .name = Export[3].symbol_name }); - @export(&getNextID, .{ .name = Export[4].symbol_name }); + for (Export) |e| { + @export(&@field(e.Parent, e.local_name), .{ .name = e.symbol_name }); + } } }; const uws = bun.uws; -pub const TimerObject = struct { +pub const TimeoutObject = struct { + event_loop_timer: EventLoopTimer.Node = .{ .data = .{ + .next = .{}, + .tag = .TimeoutObject, + } }, + internals: TimerObjectInternals, + ref_count: u32 = 1, + + pub usingnamespace JSC.Codegen.JSTimeout; + pub usingnamespace bun.NewRefCounted(@This(), deinit, null); + + pub fn init( + globalThis: *JSGlobalObject, + id: i32, + kind: Kind, + interval: u31, + callback: JSValue, + arguments_array_or_zero: JSValue, + ) JSValue { + // internals are initialized by init() + const timeout = TimeoutObject.new(.{ .internals = undefined }); + const js = timeout.toJS(globalThis); + defer js.ensureStillAlive(); + timeout.internals.init( + js, + globalThis, + id, + kind, + interval, + callback, + arguments_array_or_zero, + ); + return js; + } + + pub fn deinit(this: *TimeoutObject) void { + this.internals.deinit(); + } + + pub fn constructor(globalObject: *JSC.JSGlobalObject, callFrame: *JSC.CallFrame) !*TimeoutObject { + _ = callFrame; + return globalObject.throw("Timeout is not constructible", .{}); + } + + pub fn runImmediateTask(this: *TimeoutObject, vm: *VirtualMachine) void { + this.internals.runImmediateTask(vm); + } + + pub fn toPrimitive(this: *TimeoutObject, globalThis: *JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + return this.internals.toPrimitive(globalThis, callFrame); + } + + pub fn doRef(this: *TimeoutObject, globalThis: *JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + return this.internals.doRef(globalThis, callFrame); + } + + pub fn doUnref(this: *TimeoutObject, globalThis: *JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + return this.internals.doUnref(globalThis, callFrame); + } + + pub fn doRefresh(this: *TimeoutObject, globalThis: *JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + return this.internals.doRefresh(globalThis, callFrame); + } + + pub fn hasRef(this: *TimeoutObject, globalThis: *JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + return this.internals.hasRef(globalThis, callFrame); + } + + pub fn finalize(this: *TimeoutObject) void { + this.internals.finalize(); + } + + pub fn getDestroyed(this: *TimeoutObject, globalThis: *JSGlobalObject) JSValue { + _ = globalThis; + return .jsBoolean(this.internals.getDestroyed()); + } + + pub fn dispose(this: *TimeoutObject, globalThis: *JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + _ = this; + // clearTimeout works on both timeouts and intervals + _ = Timer.All.clearTimeout(globalThis, callFrame.this()); + return .undefined; + } +}; + +pub const ImmediateObject = struct { + event_loop_timer: EventLoopTimer.Node = .{ .data = .{ + .next = .{}, + .tag = .ImmediateObject, + } }, + internals: TimerObjectInternals, + ref_count: u32 = 1, + + pub usingnamespace JSC.Codegen.JSImmediate; + pub usingnamespace bun.NewRefCounted(@This(), deinit, null); + + pub fn init( + globalThis: *JSGlobalObject, + id: i32, + callback: JSValue, + arguments_array_or_zero: JSValue, + ) JSValue { + // internals are initialized by init() + const immediate = ImmediateObject.new(.{ .internals = undefined }); + const js = immediate.toJS(globalThis); + defer js.ensureStillAlive(); + immediate.internals.init( + js, + globalThis, + id, + .setImmediate, + 0, + callback, + arguments_array_or_zero, + ); + return js; + } + + pub fn deinit(this: *ImmediateObject) void { + this.internals.deinit(); + } + + pub fn constructor(globalObject: *JSC.JSGlobalObject, callFrame: *JSC.CallFrame) !*ImmediateObject { + _ = callFrame; + return globalObject.throw("Immediate is not constructible", .{}); + } + + pub fn runImmediateTask(this: *ImmediateObject, vm: *VirtualMachine) void { + this.internals.runImmediateTask(vm); + } + + pub fn toPrimitive(this: *ImmediateObject, globalThis: *JSC.JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + return this.internals.toPrimitive(globalThis, callFrame); + } + + pub fn doRef(this: *ImmediateObject, globalThis: *JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + return this.internals.doRef(globalThis, callFrame); + } + + pub fn doUnref(this: *ImmediateObject, globalThis: *JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + return this.internals.doUnref(globalThis, callFrame); + } + + pub fn hasRef(this: *ImmediateObject, globalThis: *JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + return this.internals.hasRef(globalThis, callFrame); + } + + pub fn finalize(this: *ImmediateObject) void { + this.internals.finalize(); + } + + pub fn getDestroyed(this: *ImmediateObject, globalThis: *JSGlobalObject) JSValue { + _ = globalThis; + return .jsBoolean(this.internals.getDestroyed()); + } + + pub fn dispose(this: *ImmediateObject, globalThis: *JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + _ = this; + _ = Timer.All.clearImmediate(globalThis, callFrame.this()); + return .undefined; + } +}; + +/// Data that TimerObject and ImmediateObject have in common +const TimerObjectInternals = struct { id: i32 = -1, kind: Kind = .setTimeout, - interval: i32 = 0, + interval: u31 = 0, // we do not allow the timer to be refreshed after we call clearInterval/clearTimeout has_cleared_timer: bool = false, is_keeping_event_loop_alive: bool = false, @@ -435,59 +811,91 @@ pub const TimerObject = struct { strong_this: JSC.Strong = .{}, has_js_ref: bool = true, - ref_count: u32 = 1, - event_loop_timer: EventLoopTimer = .{ - .next = .{}, - .tag = .TimerObject, - }, + /// Set to `true` only during execution of the JavaScript function so that `_destroyed` can be + /// false during the callback, even though the `state` will be `FIRED`. + in_callback: bool = false, - pub usingnamespace JSC.Codegen.JSTimeout; - pub usingnamespace bun.NewRefCounted(@This(), deinit, null); + fn eventLoopTimer(this: *TimerObjectInternals) *EventLoopTimer { + return &this.node().data; + } + + fn node(this: *TimerObjectInternals) *EventLoopTimer.Node { + switch (this.kind) { + .setImmediate => { + const parent: *ImmediateObject = @fieldParentPtr("internals", this); + assert(parent.event_loop_timer.data.tag == .ImmediateObject); + return &parent.event_loop_timer; + }, + .setTimeout, .setInterval => { + const parent: *TimeoutObject = @fieldParentPtr("internals", this); + assert(parent.event_loop_timer.data.tag == .TimeoutObject); + return &parent.event_loop_timer; + }, + } + } + + fn ref(this: *TimerObjectInternals) void { + switch (this.kind) { + .setImmediate => @as(*ImmediateObject, @fieldParentPtr("internals", this)).ref(), + .setTimeout, .setInterval => @as(*TimeoutObject, @fieldParentPtr("internals", this)).ref(), + } + } + + fn deref(this: *TimerObjectInternals) void { + switch (this.kind) { + .setImmediate => @as(*ImmediateObject, @fieldParentPtr("internals", this)).deref(), + .setTimeout, .setInterval => @as(*TimeoutObject, @fieldParentPtr("internals", this)).deref(), + } + } extern "c" fn Bun__JSTimeout__call(encodedTimeoutValue: JSValue, globalObject: *JSC.JSGlobalObject) void; - pub fn runImmediateTask(this: *TimerObject, vm: *VirtualMachine) void { - if (this.has_cleared_timer) { + pub fn runImmediateTask(this: *TimerObjectInternals, vm: *VirtualMachine) void { + if (this.has_cleared_timer or + // unref'd setImmediate callbacks should only run if there are things keeping the event + // loop alive other than setImmediates + (!this.is_keeping_event_loop_alive and !vm.isEventLoopAliveExcludingImmediates())) + { this.deref(); return; } const this_object = this.strong_this.get() orelse { if (Environment.isDebug) { - @panic("TimerObject.runImmediateTask: this_object is null"); + @panic("TimerObjectInternals.runImmediateTask: this_object is null"); } return; }; const globalThis = this.strong_this.globalThis.?; this.strong_this.deinit(); - this.event_loop_timer.state = .FIRED; + this.eventLoopTimer().state = .FIRED; + this.setEnableKeepingEventLoopAlive(vm, false); vm.eventLoop().enter(); { this.ref(); defer this.deref(); - run(this_object, globalThis, this.asyncID(), vm); + this.run(this_object, globalThis, this.asyncID(), vm); - if (this.event_loop_timer.state == .FIRED) { + if (this.eventLoopTimer().state == .FIRED) { this.deref(); } } vm.eventLoop().exit(); } - pub fn asyncID(this: *const TimerObject) u64 { + pub fn asyncID(this: *const TimerObjectInternals) u64 { return ID.asyncID(.{ .id = this.id, .kind = this.kind }); } - pub fn fire(this: *TimerObject, _: *const timespec, vm: *JSC.VirtualMachine) EventLoopTimer.Arm { + pub fn fire(this: *TimerObjectInternals, _: *const timespec, vm: *JSC.VirtualMachine) EventLoopTimer.Arm { const id = this.id; const kind = this.kind; - const has_been_cleared = this.event_loop_timer.state == .CANCELLED or this.has_cleared_timer or vm.scriptExecutionStatus() != .running; + const has_been_cleared = this.eventLoopTimer().state == .CANCELLED or this.has_cleared_timer or vm.scriptExecutionStatus() != .running; - this.event_loop_timer.state = .FIRED; - this.event_loop_timer.heap = .{}; + this.eventLoopTimer().state = .FIRED; if (has_been_cleared) { if (vm.isInspectorEnabled()) { @@ -520,16 +928,16 @@ pub const TimerObject = struct { this.ref(); defer this.deref(); - run(this_object, globalThis, ID.asyncID(.{ .id = id, .kind = kind }), vm); + this.run(this_object, globalThis, ID.asyncID(.{ .id = id, .kind = kind }), vm); var is_timer_done = false; // Node doesn't drain microtasks after each timer callback. if (kind == .setInterval) { - switch (this.event_loop_timer.state) { + switch (this.eventLoopTimer().state) { .FIRED => { // If we didn't clear the setInterval, reschedule it starting from - vm.timer.update(&this.event_loop_timer, &time_before_call); + vm.timer.update(this.node(), &time_before_call); if (this.has_js_ref) { this.setEnableKeepingEventLoopAlive(vm, true); @@ -539,7 +947,7 @@ pub const TimerObject = struct { }, .ACTIVE => { // The developer called timer.refresh() synchronously in the callback. - vm.timer.update(&this.event_loop_timer, &time_before_call); + vm.timer.update(this.node(), &time_before_call); // Balance out the ref count. // the transition from "FIRED" -> "ACTIVE" caused it to increment. @@ -549,22 +957,12 @@ pub const TimerObject = struct { is_timer_done = true; }, } - } else if (this.event_loop_timer.state == .FIRED) { + } else if (this.eventLoopTimer().state == .FIRED) { is_timer_done = true; } if (is_timer_done) { - if (this.is_keeping_event_loop_alive) { - this.is_keeping_event_loop_alive = false; - - switch (this.kind) { - .setTimeout, .setInterval => { - vm.timer.incrementTimerRef(-1); - }, - else => {}, - } - } - + this.setEnableKeepingEventLoopAlive(vm, false); // The timer will not be re-entered into the event loop at this point. this.deref(); } @@ -574,7 +972,7 @@ pub const TimerObject = struct { return .disarm; } - pub fn run(this_object: JSC.JSValue, globalThis: *JSC.JSGlobalObject, async_id: u64, vm: *JSC.VirtualMachine) void { + pub fn run(this: *TimerObjectInternals, this_object: JSC.JSValue, globalThis: *JSC.JSGlobalObject, async_id: u64, vm: *JSC.VirtualMachine) void { if (vm.isInspectorEnabled()) { Debugger.willDispatchAsyncCall(globalThis, .DOMTimer, async_id); } @@ -586,29 +984,48 @@ pub const TimerObject = struct { } // Bun__JSTimeout__call handles exceptions. + this.in_callback = true; + defer this.in_callback = false; Bun__JSTimeout__call(this_object, globalThis); } - pub fn init(globalThis: *JSGlobalObject, vm: *VirtualMachine, id: i32, kind: Kind, interval: i32, callback: JSValue, arguments: JSValue) struct { *TimerObject, JSValue } { - var timer = TimerObject.new(.{ + pub fn init( + this: *TimerObjectInternals, + timer_js: JSValue, + globalThis: *JSGlobalObject, + id: i32, + kind: Kind, + interval: u31, + callback: JSValue, + arguments: JSValue, + ) void { + this.* = .{ .id = id, .kind = kind, .interval = interval, - }); - var timer_js = timer.toJS(globalThis); - timer_js.ensureStillAlive(); - if (arguments != .zero) - TimerObject.argumentsSetCached(timer_js, globalThis, arguments); - TimerObject.callbackSetCached(timer_js, globalThis, callback); - timer_js.ensureStillAlive(); - timer.strong_this.set(globalThis, timer_js); - if (kind != .setImmediate) { - timer.reschedule(vm); + }; + + if (kind == .setImmediate) { + if (arguments != .zero) + ImmediateObject.argumentsSetCached(timer_js, globalThis, arguments); + ImmediateObject.callbackSetCached(timer_js, globalThis, callback); + const parent: *ImmediateObject = @fieldParentPtr("internals", this); + globalThis.bunVM().enqueueImmediateTask(JSC.Task.init(parent)); + this.setEnableKeepingEventLoopAlive(globalThis.bunVM(), true); + // ref'd by event loop + parent.ref(); + } else { + if (arguments != .zero) + TimeoutObject.argumentsSetCached(timer_js, globalThis, arguments); + TimeoutObject.callbackSetCached(timer_js, globalThis, callback); + // this increments the refcount + this.reschedule(globalThis.bunVM()); } - return .{ timer, timer_js }; + + this.strong_this.set(globalThis, timer_js); } - pub fn doRef(this: *TimerObject, _: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { + pub fn doRef(this: *TimerObjectInternals, _: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { const this_value = callframe.this(); this_value.ensureStillAlive(); @@ -622,8 +1039,12 @@ pub const TimerObject = struct { return this_value; } - pub fn doRefresh(this: *TimerObject, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { + pub fn doRefresh(this: *TimerObjectInternals, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { const this_value = callframe.this(); + // Immediates do not have a refresh function, and our binding generator should not let this + // function be reached even if you override the `this` value calling a Timeout object's + // `refresh` method + assert(this.kind != .setImmediate); // setImmediate does not support refreshing and we do not support refreshing after cleanup if (this.id == -1 or this.kind == .setImmediate or this.has_cleared_timer) { @@ -636,7 +1057,7 @@ pub const TimerObject = struct { return this_value; } - pub fn doUnref(this: *TimerObject, _: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { + pub fn doUnref(this: *TimerObjectInternals, _: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSValue { const this_value = callframe.this(); this_value.ensureStillAlive(); @@ -650,35 +1071,35 @@ pub const TimerObject = struct { return this_value; } - pub fn cancel(this: *TimerObject, vm: *VirtualMachine) void { + pub fn cancel(this: *TimerObjectInternals, vm: *VirtualMachine) void { this.setEnableKeepingEventLoopAlive(vm, false); this.has_cleared_timer = true; if (this.kind == .setImmediate) return; - const was_active = this.event_loop_timer.state == .ACTIVE; + const was_active = this.eventLoopTimer().state == .ACTIVE; - this.event_loop_timer.state = .CANCELLED; + this.eventLoopTimer().state = .CANCELLED; this.strong_this.deinit(); if (was_active) { - vm.timer.remove(&this.event_loop_timer); + vm.timer.remove(this.node()); this.deref(); } } - pub fn reschedule(this: *TimerObject, vm: *VirtualMachine) void { + pub fn reschedule(this: *TimerObjectInternals, vm: *VirtualMachine) void { if (this.kind == .setImmediate) return; const now = timespec.msFromNow(this.interval); - const was_active = this.event_loop_timer.state == .ACTIVE; + const was_active = this.eventLoopTimer().state == .ACTIVE; if (was_active) { - vm.timer.remove(&this.event_loop_timer); + vm.timer.remove(this.node()); } else { this.ref(); } - vm.timer.update(&this.event_loop_timer, &now); + vm.timer.update(this.node(), &now); this.has_cleared_timer = false; if (this.has_js_ref) { @@ -686,43 +1107,58 @@ pub const TimerObject = struct { } } - fn setEnableKeepingEventLoopAlive(this: *TimerObject, vm: *VirtualMachine, enable: bool) void { + fn setEnableKeepingEventLoopAlive(this: *TimerObjectInternals, vm: *VirtualMachine, enable: bool) void { if (this.is_keeping_event_loop_alive == enable) { return; } this.is_keeping_event_loop_alive = enable; - switch (this.kind) { - .setTimeout, .setInterval => { - vm.timer.incrementTimerRef(if (enable) 1 else -1); - }, - else => {}, + .setTimeout, .setInterval => vm.timer.incrementTimerRef(if (enable) 1 else -1), + // If setImmediate calls ref the event loop, then when the only pending tasks are + // immediate callbacks we will still try to check for I/O activity, when really we only + // want to run immediate callbacks. + .setImmediate => {}, } } - pub fn hasRef(this: *TimerObject, _: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSValue { + pub fn hasRef(this: *TimerObjectInternals, _: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSValue { return JSValue.jsBoolean(this.is_keeping_event_loop_alive); } - pub fn toPrimitive(this: *TimerObject, _: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSValue { + + pub fn toPrimitive(this: *TimerObjectInternals, _: *JSC.JSGlobalObject, _: *JSC.CallFrame) bun.JSError!JSValue { if (!this.has_accessed_primitive) { this.has_accessed_primitive = true; const vm = VirtualMachine.get(); - vm.timer.maps.get(this.kind).put(bun.default_allocator, this.id, &this.event_loop_timer) catch bun.outOfMemory(); + vm.timer.maps.get(this.kind).put(bun.default_allocator, this.id, this.eventLoopTimer()) catch bun.outOfMemory(); } return JSValue.jsNumber(this.id); } - pub fn finalize(this: *TimerObject) void { + /// This is the getter for `_destroyed` on JS Timeout and Immediate objects + pub fn getDestroyed(this: *TimerObjectInternals) bool { + if (this.has_cleared_timer) { + return true; + } + if (this.in_callback) { + return false; + } + return switch (this.eventLoopTimer().state) { + .ACTIVE, .PENDING => false, + .FIRED, .CANCELLED => true, + }; + } + + pub fn finalize(this: *TimerObjectInternals) void { this.strong_this.deinit(); this.deref(); } - pub fn deinit(this: *TimerObject) void { + pub fn deinit(this: *TimerObjectInternals) void { this.strong_this.deinit(); const vm = VirtualMachine.get(); - if (this.event_loop_timer.state == .ACTIVE) { - vm.timer.remove(&this.event_loop_timer); + if (this.eventLoopTimer().state == .ACTIVE) { + vm.timer.remove(this.node()); } if (this.has_accessed_primitive) { @@ -742,7 +1178,10 @@ pub const TimerObject = struct { } this.setEnableKeepingEventLoopAlive(vm, false); - this.destroy(); + switch (this.kind) { + .setImmediate => @as(*ImmediateObject, @fieldParentPtr("internals", this)).destroy(), + .setTimeout, .setInterval => @as(*TimeoutObject, @fieldParentPtr("internals", this)).destroy(), + } } }; @@ -773,17 +1212,17 @@ const heap = bun.io.heap; pub const EventLoopTimer = struct { /// The absolute time to fire this timer next. next: timespec, - - /// Internal heap fields. - heap: heap.IntrusiveField(EventLoopTimer) = .{}, - state: State = .PENDING, - tag: Tag, + /// A linked list node containing an EventLoopTimer. This is the type that specific kinds of + /// timers should store, as these timers need to be stoerd in linked lists. + pub const Node = std.DoublyLinkedList(EventLoopTimer).Node; + pub const Tag = if (Environment.isWindows) enum { TimerCallback, - TimerObject, + TimeoutObject, + ImmediateObject, TestRunner, StatWatcherScheduler, UpgradedDuplex, @@ -796,7 +1235,8 @@ pub const EventLoopTimer = struct { pub fn Type(comptime T: Tag) type { return switch (T) { .TimerCallback => TimerCallback, - .TimerObject => TimerObject, + .TimeoutObject => TimeoutObject, + .ImmediateObject => ImmediateObject, .TestRunner => JSC.Jest.TestRunner, .StatWatcherScheduler => StatWatcherScheduler, .UpgradedDuplex => uws.UpgradedDuplex, @@ -809,7 +1249,8 @@ pub const EventLoopTimer = struct { } } else enum { TimerCallback, - TimerObject, + TimeoutObject, + ImmediateObject, TestRunner, StatWatcherScheduler, UpgradedDuplex, @@ -821,7 +1262,8 @@ pub const EventLoopTimer = struct { pub fn Type(comptime T: Tag) type { return switch (T) { .TimerCallback => TimerCallback, - .TimerObject => TimerObject, + .TimeoutObject => TimeoutObject, + .ImmediateObject => ImmediateObject, .TestRunner => JSC.Jest.TestRunner, .StatWatcherScheduler => StatWatcherScheduler, .UpgradedDuplex => uws.UpgradedDuplex, @@ -836,7 +1278,7 @@ pub const EventLoopTimer = struct { const TimerCallback = struct { callback: *const fn (*TimerCallback) Arm, ctx: *anyopaque, - event_loop_timer: EventLoopTimer, + event_loop_timer: EventLoopTimer.Node, }; pub const State = enum { @@ -853,21 +1295,16 @@ pub const EventLoopTimer = struct { FIRED, }; - fn less(_: void, a: *const EventLoopTimer, b: *const EventLoopTimer) bool { - const order = a.next.order(&b.next); - if (order == .eq) { - if (a.tag == .TimerObject and b.tag == .TimerObject) { - const a_timer: *const TimerObject = @fieldParentPtr("event_loop_timer", a); - const b_timer: *const TimerObject = @fieldParentPtr("event_loop_timer", b); - return a_timer.id < b_timer.id; - } - - if (b.tag == .TimerObject) { - return false; - } + /// If self was created by set{Immediate,Timeout,Interval}, get a pointer to the common data + /// for all those kinds of timers + fn jsTimerInternals(self: *const EventLoopTimer) ?*const TimerObjectInternals { + switch (self.tag) { + inline .TimeoutObject, .ImmediateObject => |tag| { + const parent: *const tag.Type() = @fieldParentPtr("event_loop_timer", self); + return &parent.internals; + }, + else => return null, } - - return order == .lt; } fn ns(self: *const EventLoopTimer) u64 { @@ -880,16 +1317,20 @@ pub const EventLoopTimer = struct { }; pub fn fire(this: *EventLoopTimer, now: *const timespec, vm: *VirtualMachine) Arm { + const node: *EventLoopTimer.Node = @fieldParentPtr("data", this); switch (this.tag) { - .PostgresSQLConnectionTimeout => return @as(*JSC.Postgres.PostgresSQLConnection, @alignCast(@fieldParentPtr("timer", this))).onConnectionTimeout(), - .PostgresSQLConnectionMaxLifetime => return @as(*JSC.Postgres.PostgresSQLConnection, @alignCast(@fieldParentPtr("max_lifetime_timer", this))).onMaxLifetimeTimeout(), + .PostgresSQLConnectionTimeout => return @as(*JSC.Postgres.PostgresSQLConnection, @alignCast(@fieldParentPtr("timer", node))).onConnectionTimeout(), + .PostgresSQLConnectionMaxLifetime => return @as(*JSC.Postgres.PostgresSQLConnection, @alignCast(@fieldParentPtr("max_lifetime_timer", node))).onMaxLifetimeTimeout(), inline else => |t| { - var container: *t.Type() = @alignCast(@fieldParentPtr("event_loop_timer", this)); - if (comptime t.Type() == WTFTimer) { - return container.fire(now, vm); + if (@FieldType(t.Type(), "event_loop_timer") != EventLoopTimer.Node) { + @compileError(@typeName(t.Type()) ++ " has wrong type for 'event_loop_timer'"); + } + var container: *t.Type() = @alignCast(@fieldParentPtr("event_loop_timer", node)); + if (comptime t.Type() == TimeoutObject or t.Type() == ImmediateObject) { + return container.internals.fire(now, vm); } - if (comptime t.Type() == TimerObject) { + if (comptime t.Type() == WTFTimer) { return container.fire(now, vm); } @@ -931,7 +1372,7 @@ pub const WTFTimer = struct { vm: *VirtualMachine, run_loop_timer: *RunLoopTimer, - event_loop_timer: EventLoopTimer, + event_loop_timer: EventLoopTimer.Node, imminent: *std.atomic.Value(?*WTFTimer), repeat: bool, lock: bun.Mutex = .{}, @@ -943,12 +1384,14 @@ pub const WTFTimer = struct { .vm = js_vm, .imminent = &js_vm.eventLoop().imminent_gc_timer, .event_loop_timer = .{ - .next = .{ - .sec = std.math.maxInt(i64), - .nsec = 0, + .data = .{ + .next = .{ + .sec = std.math.maxInt(i64), + .nsec = 0, + }, + .tag = .WTFTimer, + .state = .CANCELLED, }, - .tag = .WTFTimer, - .state = .CANCELLED, }, .run_loop_timer = run_loop_timer, .repeat = false, @@ -962,7 +1405,7 @@ pub const WTFTimer = struct { } pub fn run(this: *WTFTimer, vm: *VirtualMachine) void { - if (this.event_loop_timer.state == .ACTIVE) { + if (this.event_loop_timer.data.state == .ACTIVE) { vm.timer.remove(&this.event_loop_timer); } this.runWithoutRemoving(); @@ -996,18 +1439,18 @@ pub const WTFTimer = struct { pub fn cancel(this: *WTFTimer) void { this.lock.lock(); defer this.lock.unlock(); - this.imminent.store(null, .monotonic); - if (this.event_loop_timer.state == .ACTIVE) { + this.imminent.store(null, .seq_cst); + if (this.event_loop_timer.data.state == .ACTIVE) { this.vm.timer.remove(&this.event_loop_timer); } } pub fn fire(this: *WTFTimer, _: *const bun.timespec, _: *VirtualMachine) EventLoopTimer.Arm { - this.event_loop_timer.state = .FIRED; - this.imminent.store(null, .monotonic); + this.event_loop_timer.data.state = .FIRED; + this.imminent.store(null, .seq_cst); this.runWithoutRemoving(); return if (this.repeat) - .{ .rearm = this.event_loop_timer.next } + .{ .rearm = this.event_loop_timer.data.next } else .disarm; } @@ -1034,7 +1477,7 @@ pub const WTFTimer = struct { } export fn WTFTimer__isActive(this: *const WTFTimer) bool { - return this.event_loop_timer.state == .ACTIVE or (this.imminent.load(.monotonic) orelse return false) == this; + return this.event_loop_timer.data.state == .ACTIVE or (this.imminent.load(.seq_cst) orelse return false) == this; } export fn WTFTimer__cancel(this: *WTFTimer) void { @@ -1044,8 +1487,8 @@ pub const WTFTimer = struct { export fn WTFTimer__secondsUntilTimer(this: *WTFTimer) f64 { this.lock.lock(); defer this.lock.unlock(); - if (this.event_loop_timer.state == .ACTIVE) { - const until = this.event_loop_timer.next.duration(&bun.timespec.now()); + if (this.event_loop_timer.data.state == .ACTIVE) { + const until = this.event_loop_timer.data.next.duration(&bun.timespec.now()); const sec: f64, const nsec: f64 = .{ @floatFromInt(until.sec), @floatFromInt(until.nsec) }; return sec + nsec / std.time.ns_per_s; } @@ -1054,3 +1497,23 @@ pub const WTFTimer = struct { extern fn WTFTimer__fire(this: *RunLoopTimer) void; }; + +pub const internal_bindings = struct { + /// Node.js has some tests that check whether timers fire at the right time. They check this + /// with the internal binding `getLibuvNow()`, which returns an integer in milliseconds. This + /// works because `getLibuvNow()` is also the clock that their timers implementation uses to + /// choose when to schedule timers. + /// + /// I've tried changing those tests to use `performance.now()` or `Date.now()`. But that always + /// introduces spurious failures, because neither of those functions use the same clock that the + /// timers implementation uses (for Bun this is `bun.timespec.now()`), so the tests end up + /// thinking that the timing is wrong (this also happens when I run the modified test in + /// Node.js). So the best course of action is for Bun to also expose a function that reveals the + /// clock that is used to schedule timers. + pub fn timerClockMs(globalThis: *JSC.JSGlobalObject, callFrame: *JSC.CallFrame) bun.JSError!JSValue { + _ = globalThis; + _ = callFrame; + const now = timespec.now().ms(); + return .jsNumberFromInt64(now); + } +}; diff --git a/src/bun.js/api/bun/dns_resolver.zig b/src/bun.js/api/bun/dns_resolver.zig index cd45d507e5..ceb324d486 100644 --- a/src/bun.js/api/bun/dns_resolver.zig +++ b/src/bun.js/api/bun/dns_resolver.zig @@ -1776,10 +1776,10 @@ pub const DNSResolver = struct { options: c_ares.ChannelOptions = .{}, ref_count: u32 = 1, - event_loop_timer: EventLoopTimer = .{ + event_loop_timer: EventLoopTimer.Node = .{ .data = .{ .next = .{}, .tag = .DNSResolver, - }, + } }, pending_host_cache_cares: PendingCache = .empty, pending_host_cache_native: PendingCache = .empty, @@ -1894,13 +1894,13 @@ pub const DNSResolver = struct { this.deref(); } - this.event_loop_timer.state = .PENDING; + this.event_loop_timer.data.state = .PENDING; if (this.getChannelOrError(vm.global)) |channel| { if (this.anyRequestsPending()) { c_ares.ares_process_fd(channel, c_ares.ARES_SOCKET_BAD, c_ares.ARES_SOCKET_BAD); if (this.addTimer(now)) { - return .{ .rearm = this.event_loop_timer.next }; + return .{ .rearm = this.event_loop_timer.data.next }; } } } else |_| {} @@ -1933,19 +1933,19 @@ pub const DNSResolver = struct { } fn addTimer(this: *DNSResolver, now: ?*const timespec) bool { - if (this.event_loop_timer.state == .ACTIVE) { + if (this.event_loop_timer.data.state == .ACTIVE) { return false; } this.ref(); - this.event_loop_timer.next = (now orelse ×pec.now()).addMs(1000); + this.event_loop_timer.data.next = (now orelse ×pec.now()).addMs(1000); this.vm.timer.incrementTimerRef(1); this.vm.timer.insert(&this.event_loop_timer); return true; } fn removeTimer(this: *DNSResolver) void { - if (this.event_loop_timer.state != .ACTIVE) { + if (this.event_loop_timer.data.state != .ACTIVE) { return; } diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index 50472f878d..a9f80fb7dc 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -442,7 +442,7 @@ JSC_DEFINE_HOST_FUNCTION(functionBunSleep, } JSC::JSPromise* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); - Bun__Timer__setTimeout(globalObject, JSValue::encode(promise), JSC::JSValue::encode(millisecondsValue), {}); + Bun__Timer__setTimeout(globalObject, JSValue::encode(promise), JSC::JSValue::encode(millisecondsValue), {}, Bun::CountdownOverflowBehavior::Clamp); return JSC::JSValue::encode(promise); } diff --git a/src/bun.js/bindings/BunProcess.cpp b/src/bun.js/bindings/BunProcess.cpp index 685299efee..c41ccad1d7 100644 --- a/src/bun.js/bindings/BunProcess.cpp +++ b/src/bun.js/bindings/BunProcess.cpp @@ -1193,21 +1193,27 @@ static bool isJSValueEqualToASCIILiteral(JSC::JSGlobalObject* globalObject, JSC: return view == literal; } -JSC_DEFINE_HOST_FUNCTION(Process_emitWarning, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +extern "C" void Bun__Process__emitWarning(Zig::GlobalObject* globalObject, EncodedJSValue warning, EncodedJSValue type, EncodedJSValue code, EncodedJSValue ctor) { - Zig::GlobalObject* globalObject = jsCast(lexicalGlobalObject); - VM& vm = globalObject->vm(); + // ignoring return value -- emitWarning only ever returns undefined or throws + (void)Process::emitWarning( + globalObject, + JSValue::decode(warning), + JSValue::decode(type), + JSValue::decode(code), + JSValue::decode(ctor)); +} + +JSValue Process::emitWarning(JSC::JSGlobalObject* lexicalGlobalObject, JSValue warning, JSValue type, JSValue code, JSValue ctor) +{ + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + VM& vm = getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto* process = jsCast(globalObject->processObject()); - - auto warning = callFrame->argument(0); - auto type = callFrame->argument(1); - auto code = callFrame->argument(2); - auto ctor = callFrame->argument(3); - auto detail = jsUndefined(); + JSValue detail = jsUndefined(); if (Bun__Node__ProcessNoDeprecation && isJSValueEqualToASCIILiteral(globalObject, type, "DeprecationWarning"_s)) { - return JSValue::encode(jsUndefined()); + return jsUndefined(); } if (!type.isNull() && type.isObject() && !isJSArray(type)) { @@ -1254,7 +1260,7 @@ JSC_DEFINE_HOST_FUNCTION(Process_emitWarning, (JSGlobalObject * lexicalGlobalObj } else if (warning.isCell() && warning.asCell()->type() == ErrorInstanceType) { errorInstance = warning.getObject(); } else { - return Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "warning"_s, "string or Error"_s, warning); + return JSValue::decode(Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, "warning"_s, "string or Error"_s, warning)); } if (!code.isUndefined()) errorInstance->putDirect(vm, builtinNames(vm).codePublicName(), code, JSC::PropertyAttribute::DontEnum | 0); @@ -1263,7 +1269,7 @@ JSC_DEFINE_HOST_FUNCTION(Process_emitWarning, (JSGlobalObject * lexicalGlobalObj if (isJSValueEqualToASCIILiteral(globalObject, type, "DeprecationWarning"_s)) { if (Bun__Node__ProcessNoDeprecation) { - return JSValue::encode(jsUndefined()); + return jsUndefined(); } if (Bun__Node__ProcessThrowDeprecation) { // // Delay throwing the error to guarantee that all former warnings were properly logged. @@ -1272,14 +1278,24 @@ JSC_DEFINE_HOST_FUNCTION(Process_emitWarning, (JSGlobalObject * lexicalGlobalObj // }); auto func = JSFunction::create(vm, globalObject, 1, ""_s, jsFunction_throwValue, JSC::ImplementationVisibility::Private); process->queueNextTick(vm, globalObject, func, errorInstance); - return JSValue::encode(jsUndefined()); + return jsUndefined(); } } // process.nextTick(doEmitWarning, warning); auto func = JSFunction::create(vm, globalObject, 1, ""_s, jsFunction_emitWarning, JSC::ImplementationVisibility::Private); process->queueNextTick(vm, globalObject, func, errorInstance); - return JSValue::encode(jsUndefined()); + return jsUndefined(); +} + +JSC_DEFINE_HOST_FUNCTION(Process_emitWarning, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + Zig::GlobalObject* globalObject = jsCast(lexicalGlobalObject); + auto warning = callFrame->argument(0); + auto type = callFrame->argument(1); + auto code = callFrame->argument(2); + auto ctor = callFrame->argument(3); + return JSValue::encode(Process::emitWarning(globalObject, warning, type, code, ctor)); } JSC_DEFINE_CUSTOM_GETTER(processExitCode, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName name)) diff --git a/src/bun.js/bindings/BunProcess.h b/src/bun.js/bindings/BunProcess.h index 8475dbd994..b8abdc566d 100644 --- a/src/bun.js/bindings/BunProcess.h +++ b/src/bun.js/bindings/BunProcess.h @@ -53,6 +53,8 @@ public: void queueNextTick(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSValue); void queueNextTick(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSValue, JSValue); + static JSValue emitWarning(JSC::JSGlobalObject* lexicalGlobalObject, JSValue warning, JSValue type, JSValue code, JSValue ctor); + JSString* cachedCwd() { return m_cachedCwd.get(); } void setCachedCwd(JSC::VM& vm, JSString* cwd) { m_cachedCwd.set(vm, this, cwd); } diff --git a/src/bun.js/bindings/NodeTimerObject.cpp b/src/bun.js/bindings/NodeTimerObject.cpp index 27e1447b6f..8e24b82adc 100644 --- a/src/bun.js/bindings/NodeTimerObject.cpp +++ b/src/bun.js/bindings/NodeTimerObject.cpp @@ -16,15 +16,14 @@ namespace Bun { using namespace JSC; -extern "C" void Bun__JSTimeout__call(JSC::EncodedJSValue encodedTimeoutValue, JSC::JSGlobalObject* globalObject) +template +void callInternal(T* timeout, JSGlobalObject* globalObject) { + static_assert(std::is_same_v || std::is_same_v, + "wrong type passed to callInternal"); + auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - if (UNLIKELY(vm.hasPendingTerminationException())) { - return; - } - - WebCore::JSTimeout* timeout = jsCast(JSC::JSValue::decode(encodedTimeoutValue)); JSCell* callbackCell = timeout->m_callback.get().asCell(); JSValue restoreAsyncContext {}; @@ -80,4 +79,21 @@ extern "C" void Bun__JSTimeout__call(JSC::EncodedJSValue encodedTimeoutValue, JS } } +extern "C" void Bun__JSTimeout__call(JSC::EncodedJSValue encodedTimeoutValue, JSC::JSGlobalObject* globalObject) +{ + auto& vm = globalObject->vm(); + if (UNLIKELY(vm.hasPendingTerminationException())) { + return; + } + + JSValue timeoutValue = JSValue::decode(encodedTimeoutValue); + if (auto* timeout = jsDynamicCast(timeoutValue)) { + return callInternal(timeout, globalObject); + } else if (auto* immediate = jsDynamicCast(timeoutValue)) { + return callInternal(immediate, globalObject); + } + + ASSERT_NOT_REACHED_WITH_MESSAGE("Object passed to Bun__JSTimeout__call is not a JSTimeout or a JSImmediate"); +} + } diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index 3f0ebe2a66..2c1401c21f 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -1504,7 +1504,7 @@ JSC_DEFINE_HOST_FUNCTION(functionSetTimeout, auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); switch (argumentCount) { case 0: { - JSC::throwTypeError(globalObject, scope, "setTimeout requires 1 argument (a function)"_s); + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setTimeout requires 1 argument (a function)"_s); return {}; } case 1: @@ -1530,7 +1530,7 @@ JSC_DEFINE_HOST_FUNCTION(functionSetTimeout, } if (UNLIKELY(!job.isObject() || !job.getObject()->isCallable())) { - JSC::throwTypeError(globalObject, scope, "setTimeout expects a function"_s); + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setTimeout expects a function"_s); return {}; } @@ -1545,7 +1545,7 @@ JSC_DEFINE_HOST_FUNCTION(functionSetTimeout, } #endif - return Bun__Timer__setTimeout(globalObject, JSC::JSValue::encode(job), JSC::JSValue::encode(num), JSValue::encode(arguments)); + return Bun__Timer__setTimeout(globalObject, JSC::JSValue::encode(job), JSC::JSValue::encode(num), JSValue::encode(arguments), Bun::CountdownOverflowBehavior::OneMs); } JSC_DEFINE_HOST_FUNCTION(functionSetInterval, @@ -1560,7 +1560,7 @@ JSC_DEFINE_HOST_FUNCTION(functionSetInterval, switch (argumentCount) { case 0: { - JSC::throwTypeError(globalObject, scope, "setInterval requires 1 argument (a function)"_s); + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setInterval requires 1 argument (a function)"_s); return {}; } case 1: { @@ -1589,7 +1589,7 @@ JSC_DEFINE_HOST_FUNCTION(functionSetInterval, } if (UNLIKELY(!job.isObject() || !job.getObject()->isCallable())) { - JSC::throwTypeError(globalObject, scope, "setInterval expects a function"_s); + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setInterval expects a function"_s); return {}; } @@ -1607,18 +1607,12 @@ JSC_DEFINE_HOST_FUNCTION(functionSetInterval, return Bun__Timer__setInterval(globalObject, JSC::JSValue::encode(job), JSC::JSValue::encode(num), JSValue::encode(arguments)); } -JSC_DEFINE_HOST_FUNCTION(functionClearInterval, +JSC_DEFINE_HOST_FUNCTION(functionClearImmediate, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); - if (callFrame->argumentCount() == 0) { - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - JSC::throwTypeError(globalObject, scope, "clearInterval requires 1 argument (a number)"_s); - return {}; - } - - JSC::JSValue num = callFrame->argument(0); + JSC::JSValue timer_or_num = callFrame->argument(0); #ifdef BUN_DEBUG /** View the file name of the JS file that called this function @@ -1631,7 +1625,28 @@ JSC_DEFINE_HOST_FUNCTION(functionClearInterval, } #endif - return Bun__Timer__clearInterval(globalObject, JSC::JSValue::encode(num)); + return Bun__Timer__clearImmediate(globalObject, JSC::JSValue::encode(timer_or_num)); +} + +JSC_DEFINE_HOST_FUNCTION(functionClearInterval, + (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + + JSC::JSValue timer_or_num = callFrame->argument(0); + +#ifdef BUN_DEBUG + /** View the file name of the JS file that called this function + * from a debugger */ + SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); + const char* fileName = sourceOrigin.string().utf8().data(); + static const char* lastFileName = nullptr; + if (lastFileName != fileName) { + lastFileName = fileName; + } +#endif + + return Bun__Timer__clearInterval(globalObject, JSC::JSValue::encode(timer_or_num)); } JSC_DEFINE_HOST_FUNCTION(functionClearTimeout, @@ -1639,13 +1654,7 @@ JSC_DEFINE_HOST_FUNCTION(functionClearTimeout, { auto& vm = JSC::getVM(globalObject); - if (callFrame->argumentCount() == 0) { - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - JSC::throwTypeError(globalObject, scope, "clearTimeout requires 1 argument (a number)"_s); - return {}; - } - - JSC::JSValue num = callFrame->argument(0); + JSC::JSValue timer_or_num = callFrame->argument(0); #ifdef BUN_DEBUG /** View the file name of the JS file that called this function @@ -1658,7 +1667,7 @@ JSC_DEFINE_HOST_FUNCTION(functionClearTimeout, } #endif - return Bun__Timer__clearTimeout(globalObject, JSC::JSValue::encode(num)); + return Bun__Timer__clearTimeout(globalObject, JSC::JSValue::encode(timer_or_num)); } JSC_DEFINE_HOST_FUNCTION(functionStructuredClone, @@ -3444,14 +3453,14 @@ JSC_DEFINE_HOST_FUNCTION(functionSetImmediate, auto argCount = callFrame->argumentCount(); if (argCount == 0) { - JSC::throwTypeError(globalObject, scope, "setImmediate requires 1 argument (a function)"_s); + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setImmediate requires 1 argument (a function)"_s); return {}; } auto job = callFrame->argument(0); if (!job.isObject() || !job.getObject()->isCallable()) { - JSC::throwTypeError(globalObject, scope, "setImmediate expects a function"_s); + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setImmediate expects a function"_s); return {}; } diff --git a/src/bun.js/bindings/ZigGlobalObject.lut.txt b/src/bun.js/bindings/ZigGlobalObject.lut.txt index fa44a60673..67ffca1f5a 100644 --- a/src/bun.js/bindings/ZigGlobalObject.lut.txt +++ b/src/bun.js/bindings/ZigGlobalObject.lut.txt @@ -6,7 +6,7 @@ alert WebCore__alert Function 1 atob functionATOB Function 1 btoa functionBTOA Function 1 - clearImmediate functionClearTimeout Function 1 + clearImmediate functionClearImmediate Function 1 clearInterval functionClearInterval Function 1 clearTimeout functionClearTimeout Function 1 confirm WebCore__confirm Function 1 diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index ffcb4afd54..aaa3c40560 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -3173,6 +3173,12 @@ pub const JSGlobalObject = opaque { ); } + extern fn Bun__Process__emitWarning(globalObject: *JSGlobalObject, warning: JSValue, @"type": JSValue, code: JSValue, ctor: JSValue) void; + pub fn emitWarning(globalObject: *JSGlobalObject, warning: JSValue, @"type": JSValue, code: JSValue, ctor: JSValue) JSError!void { + Bun__Process__emitWarning(globalObject, warning, @"type", code, ctor); + if (globalObject.hasException()) return error.JSError; + } + extern fn JSC__JSGlobalObject__queueMicrotaskJob(JSC__JSGlobalObject__ptr: *JSGlobalObject, JSValue, JSValue, JSValue) void; pub fn queueMicrotaskJob(this: *JSGlobalObject, function: JSValue, first: JSValue, second: JSValue) void { JSC__JSGlobalObject__queueMicrotaskJob(this, function, first, second); diff --git a/src/bun.js/bindings/generated_classes_list.zig b/src/bun.js/bindings/generated_classes_list.zig index 96bae55029..101f23aab4 100644 --- a/src/bun.js/bindings/generated_classes_list.zig +++ b/src/bun.js/bindings/generated_classes_list.zig @@ -53,7 +53,8 @@ pub const Classes = struct { pub const UDPSocket = JSC.API.UDPSocket; pub const SocketAddress = JSC.API.SocketAddress; pub const TextDecoder = JSC.WebCore.TextDecoder; - pub const Timeout = JSC.API.Bun.Timer.TimerObject; + pub const Timeout = JSC.API.Bun.Timer.TimeoutObject; + pub const Immediate = JSC.API.Bun.Timer.ImmediateObject; pub const BuildArtifact = JSC.API.BuildArtifact; pub const BuildMessage = JSC.BuildMessage; pub const ResolveMessage = JSC.ResolveMessage; diff --git a/src/bun.js/bindings/headers.h b/src/bun.js/bindings/headers.h index b678f97144..99061ecb2d 100644 --- a/src/bun.js/bindings/headers.h +++ b/src/bun.js/bindings/headers.h @@ -785,11 +785,20 @@ extern "C" SYSV_ABI void Bun__ConsoleObject__timeStamp(void* arg0, JSC__JSGlobal #ifdef __cplusplus +ZIG_DECL JSC__JSValue Bun__Timer__clearImmediate(JSC__JSGlobalObject* arg0, JSC__JSValue JSValue1); ZIG_DECL JSC__JSValue Bun__Timer__clearInterval(JSC__JSGlobalObject* arg0, JSC__JSValue JSValue1); ZIG_DECL JSC__JSValue Bun__Timer__clearTimeout(JSC__JSGlobalObject* arg0, JSC__JSValue JSValue1); ZIG_DECL int32_t Bun__Timer__getNextID(); -ZIG_DECL JSC__JSValue Bun__Timer__setInterval(JSC__JSGlobalObject* arg0, JSC__JSValue JSValue1, JSC__JSValue JSValue2, JSC__JSValue JSValue3); -ZIG_DECL JSC__JSValue Bun__Timer__setTimeout(JSC__JSGlobalObject* arg0, JSC__JSValue JSValue1, JSC__JSValue JSValue2, JSC__JSValue JSValue3); +ZIG_DECL JSC__JSValue Bun__Timer__setInterval(JSC__JSGlobalObject* globalThis, JSC__JSValue callback, JSC__JSValue countdown, JSC__JSValue arguments); +namespace Bun { +enum class CountdownOverflowBehavior : uint8_t { + // If the countdown overflows the range of int32_t, use a countdown of 1ms instead. Behavior of `setTimeout` and friends. + OneMs, + // If the countdown overflows the range of int32_t, clamp to the nearest value within the range. Behavior of `Bun.sleep`. + Clamp, +}; +} // namespace Bun +ZIG_DECL JSC__JSValue Bun__Timer__setTimeout(JSC__JSGlobalObject* globalThis, JSC__JSValue callback, JSC__JSValue countdown, JSC__JSValue arguments, Bun::CountdownOverflowBehavior behavior); #endif diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index e39b5bd919..aadf239bc5 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -448,7 +448,8 @@ const ShellAsync = bun.shell.Interpreter.Async; // const ShellIOReaderAsyncDeinit = bun.shell.Interpreter.IOReader.AsyncDeinit; const ShellIOReaderAsyncDeinit = bun.shell.Interpreter.AsyncDeinitReader; const ShellIOWriterAsyncDeinit = bun.shell.Interpreter.AsyncDeinitWriter; -const TimerObject = JSC.BunTimer.TimerObject; +const TimeoutObject = JSC.BunTimer.TimeoutObject; +const ImmediateObject = JSC.BunTimer.ImmediateObject; const ProcessWaiterThreadTask = if (Environment.isPosix) bun.spawn.WaiterThread.ProcessQueue.ResultTask else opaque {}; const ProcessMiniEventLoopWaiterThreadTask = if (Environment.isPosix) bun.spawn.WaiterThread.ProcessMiniEventLoopQueue.ResultTask else opaque {}; const ShellAsyncSubprocessDone = bun.shell.Interpreter.Cmd.ShellAsyncSubprocessDone; @@ -484,6 +485,7 @@ pub const Task = TaggedPointerUnion(.{ Futimes, GetAddrInfoRequestTask, HotReloadTask, + ImmediateObject, JSCDeferredWorkTask, Lchmod, Lchown, @@ -535,7 +537,7 @@ pub const Task = TaggedPointerUnion(.{ StatFS, Symlink, ThreadSafeFunction, - TimerObject, + TimeoutObject, Truncate, Unlink, Utimes, @@ -1329,8 +1331,12 @@ pub const EventLoop = struct { var any: *RuntimeTranspilerStore = task.get(RuntimeTranspilerStore).?; any.drain(); }, - @field(Task.Tag, @typeName(TimerObject)) => { - var any: *TimerObject = task.get(TimerObject).?; + @field(Task.Tag, @typeName(TimeoutObject)) => { + var any: *TimeoutObject = task.get(TimeoutObject).?; + any.runImmediateTask(virtual_machine); + }, + @field(Task.Tag, @typeName(ImmediateObject)) => { + var any: *ImmediateObject = task.get(ImmediateObject).?; any.runImmediateTask(virtual_machine); }, @field(Task.Tag, @typeName(ServerAllConnectionsClosedTask)) => { diff --git a/src/bun.js/javascript.zig b/src/bun.js/javascript.zig index b6cfc59ef5..0cdd0e42f2 100644 --- a/src/bun.js/javascript.zig +++ b/src/bun.js/javascript.zig @@ -771,7 +771,6 @@ pub const VirtualMachine = struct { main_is_html_entrypoint: bool = false, main_resolved_path: bun.String = bun.String.empty, main_hash: u32 = 0, - process: bun.JSC.C.JSObjectRef = null, entry_point: ServerEntryPoint = undefined, origin: URL = URL{}, node_fs: ?*Node.NodeFS = null, @@ -1021,16 +1020,23 @@ pub const VirtualMachine = struct { } } - pub fn isEventLoopAlive(vm: *const VirtualMachine) bool { + pub fn isEventLoopAliveExcludingImmediates(vm: *const VirtualMachine) bool { return vm.unhandled_error_counter == 0 and (@intFromBool(vm.event_loop_handle.?.isActive()) + vm.active_tasks + vm.event_loop.tasks.count + - vm.event_loop.immediate_tasks.count + - vm.event_loop.next_immediate_tasks.count + @intFromBool(vm.event_loop.hasPendingRefs()) > 0); } + pub fn isEventLoopAlive(vm: *const VirtualMachine) bool { + return vm.isEventLoopAliveExcludingImmediates() or + // We need to keep running in this case so that immediate tasks get run. But immediates + // intentionally don't make the event loop _active_ so we need to check for them + // separately. + vm.event_loop.immediate_tasks.count > 0 or + vm.event_loop.next_immediate_tasks.count > 0; + } + pub fn wakeup(this: *VirtualMachine) void { this.eventLoop().wakeup(); } diff --git a/src/bun.js/node/node.classes.ts b/src/bun.js/node/node.classes.ts index bee94131c8..07b230411f 100644 --- a/src/bun.js/node/node.classes.ts +++ b/src/bun.js/node/node.classes.ts @@ -1,4 +1,4 @@ -import { define } from "../../codegen/class-definitions"; +import { define, InvalidThisBehavior } from "../../codegen/class-definitions"; export default [ define({ @@ -131,8 +131,7 @@ export default [ }), define({ name: "Timeout", - construct: false, - noConstructor: true, + construct: true, finalize: true, configurable: false, klass: {}, @@ -141,22 +140,74 @@ export default [ ref: { fn: "doRef", length: 0, + invalidThisBehavior: InvalidThisBehavior.NoOp, }, refresh: { fn: "doRefresh", length: 0, + invalidThisBehavior: InvalidThisBehavior.NoOp, }, unref: { fn: "doUnref", length: 0, + invalidThisBehavior: InvalidThisBehavior.NoOp, }, hasRef: { fn: "hasRef", length: 0, + invalidThisBehavior: InvalidThisBehavior.NoOp, }, ["@@toPrimitive"]: { fn: "toPrimitive", length: 1, + invalidThisBehavior: InvalidThisBehavior.NoOp, + }, + _destroyed: { + getter: "getDestroyed", + }, + ["@@dispose"]: { + fn: "dispose", + length: 0, + invalidThisBehavior: InvalidThisBehavior.NoOp, + }, + }, + values: ["arguments", "callback"], + }), + define({ + name: "Immediate", + construct: true, + finalize: true, + configurable: false, + klass: {}, + JSType: "0b11101110", + proto: { + ref: { + fn: "doRef", + length: 0, + invalidThisBehavior: InvalidThisBehavior.NoOp, + }, + unref: { + fn: "doUnref", + length: 0, + invalidThisBehavior: InvalidThisBehavior.NoOp, + }, + hasRef: { + fn: "hasRef", + length: 0, + invalidThisBehavior: InvalidThisBehavior.NoOp, + }, + ["@@toPrimitive"]: { + fn: "toPrimitive", + length: 1, + invalidThisBehavior: InvalidThisBehavior.NoOp, + }, + _destroyed: { + getter: "getDestroyed", + }, + ["@@dispose"]: { + fn: "dispose", + length: 0, + invalidThisBehavior: InvalidThisBehavior.NoOp, }, }, values: ["arguments", "callback"], diff --git a/src/bun.js/node/node_fs_stat_watcher.zig b/src/bun.js/node/node_fs_stat_watcher.zig index 5fac2538b4..8635c7b1d4 100644 --- a/src/bun.js/node/node_fs_stat_watcher.zig +++ b/src/bun.js/node/node_fs_stat_watcher.zig @@ -40,10 +40,10 @@ pub const StatWatcherScheduler = struct { vm: *bun.JSC.VirtualMachine, watchers: WatcherQueue = WatcherQueue{}, - event_loop_timer: EventLoopTimer = .{ + event_loop_timer: EventLoopTimer.Node = .{ .data = .{ .next = .{}, .tag = .StatWatcherScheduler, - }, + } }, const WatcherQueue = UnboundedQueue(StatWatcher, .next); @@ -89,7 +89,7 @@ pub const StatWatcherScheduler = struct { // if the interval is 0 means that we stop the timer if (interval == 0) { // if the timer is active we need to remove it - if (this.event_loop_timer.state == .ACTIVE) { + if (this.event_loop_timer.data.state == .ACTIVE) { this.vm.timer.remove(&this.event_loop_timer); } return; @@ -119,10 +119,9 @@ pub const StatWatcherScheduler = struct { } pub fn timerCallback(this: *StatWatcherScheduler) EventLoopTimer.Arm { - const has_been_cleared = this.event_loop_timer.state == .CANCELLED or this.vm.scriptExecutionStatus() != .running; + const has_been_cleared = this.event_loop_timer.data.state == .CANCELLED or this.vm.scriptExecutionStatus() != .running; - this.event_loop_timer.state = .FIRED; - this.event_loop_timer.heap = .{}; + this.event_loop_timer.data.state = .FIRED; if (has_been_cleared) { return .disarm; diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index a37bf81fed..b1532b8243 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -83,10 +83,10 @@ pub const TestRunner = struct { // from `setDefaultTimeout() or jest.setTimeout()` default_timeout_override: u32 = std.math.maxInt(u32), - event_loop_timer: JSC.API.Bun.Timer.EventLoopTimer = .{ + event_loop_timer: JSC.API.Bun.Timer.EventLoopTimer.Node = .{ .data = .{ .next = .{}, .tag = .TestRunner, - }, + } }, active_test_for_timeout: ?TestRunner.Test.ID = null, test_options: *const bun.CLI.Command.TestOptions = undefined, @@ -107,7 +107,7 @@ pub const TestRunner = struct { pub fn onTestTimeout(this: *TestRunner, now: *const bun.timespec, vm: *VirtualMachine) void { _ = vm; // autofix - this.event_loop_timer.state = .FIRED; + this.event_loop_timer.data.state = .FIRED; if (this.pending_test) |pending_test| { if (!pending_test.reported and (this.active_test_for_timeout orelse return) == pending_test.test_id) { @@ -132,12 +132,12 @@ pub const TestRunner = struct { const then = bun.timespec.msFromNow(@intCast(milliseconds)); const vm = JSC.VirtualMachine.get(); - this.event_loop_timer.tag = .TestRunner; - if (this.event_loop_timer.state == .ACTIVE) { + this.event_loop_timer.data.tag = .TestRunner; + if (this.event_loop_timer.data.state == .ACTIVE) { vm.timer.remove(&this.event_loop_timer); } - this.event_loop_timer.next = then; + this.event_loop_timer.data.next = then; vm.timer.insert(&this.event_loop_timer); } diff --git a/src/bun.js/test/pretty_format.zig b/src/bun.js/test/pretty_format.zig index 8c49631de9..128c2593b4 100644 --- a/src/bun.js/test/pretty_format.zig +++ b/src/bun.js/test/pretty_format.zig @@ -1275,19 +1275,26 @@ pub const JestPrettyFormat = struct { .Object, enable_ansi_colors, ); - } else if (value.as(JSC.API.Bun.Timer.TimerObject)) |timer| { - this.addForNewLine("Timeout(# ) ".len + bun.fmt.fastDigitCount(@as(u64, @intCast(@max(timer.id, 0))))); - if (timer.kind == .setInterval) { - this.addForNewLine("repeats ".len + bun.fmt.fastDigitCount(@as(u64, @intCast(@max(timer.id, 0))))); + } else if (value.as(JSC.API.Bun.Timer.TimeoutObject)) |timer| { + this.addForNewLine("Timeout(# ) ".len + bun.fmt.fastDigitCount(@as(u64, @intCast(@max(timer.internals.id, 0))))); + if (timer.internals.kind == .setInterval) { + this.addForNewLine("repeats ".len + bun.fmt.fastDigitCount(@as(u64, @intCast(@max(timer.internals.id, 0))))); writer.print(comptime Output.prettyFmt("Timeout (#{d}, repeats)", enable_ansi_colors), .{ - timer.id, + timer.internals.id, }); } else { writer.print(comptime Output.prettyFmt("Timeout (#{d})", enable_ansi_colors), .{ - timer.id, + timer.internals.id, }); } + return; + } else if (value.as(JSC.API.Bun.Timer.ImmediateObject)) |immediate| { + this.addForNewLine("Immediate(# ) ".len + bun.fmt.fastDigitCount(@as(u64, @intCast(@max(immediate.internals.id, 0))))); + writer.print(comptime Output.prettyFmt("Immediate (#{d})", enable_ansi_colors), .{ + immediate.internals.id, + }); + return; } else if (value.as(JSC.BuildMessage)) |build_log| { build_log.msg.writeFormat(writer_, enable_ansi_colors) catch {}; diff --git a/src/codegen/class-definitions.ts b/src/codegen/class-definitions.ts index 4195efee3d..334c1a4296 100644 --- a/src/codegen/class-definitions.ts +++ b/src/codegen/class-definitions.ts @@ -9,6 +9,22 @@ interface PropertyAttribute { privateSymbol?: string; } +/** + * Specifies what happens when a method is called with `this` set to a value that is not an instance + * of the class. + */ +export enum InvalidThisBehavior { + /** + * Default. Throws a `TypeError`. + */ + Throw, + /** + * Do not call the native implementation; return `undefined`. Some Node.js methods are supposed to + * work like this. + */ + NoOp, +} + export type Field = | ({ getter: string; @@ -35,6 +51,7 @@ export type Field = */ length?: number; passThis?: boolean; + invalidThisBehavior?: InvalidThisBehavior; DOMJIT?: { returns: string; args?: [string, string] | [string, string, string] | [string] | []; diff --git a/src/codegen/generate-classes.ts b/src/codegen/generate-classes.ts index 995b846803..0355fd999c 100644 --- a/src/codegen/generate-classes.ts +++ b/src/codegen/generate-classes.ts @@ -1,6 +1,6 @@ // @ts-nocheck import path from "path"; -import type { ClassDefinition, Field } from "./class-definitions"; +import { InvalidThisBehavior, type ClassDefinition, type Field } from "./class-definitions"; import { camelCase, pascalCase, writeIfNotChanged } from "./helpers"; import jsclasses from "./../bun.js/bindings/js_classes"; @@ -1163,6 +1163,7 @@ JSC_DEFINE_CUSTOM_SETTER(${symbolName(typeName, name)}SetterWrap, (JSGlobalObjec if ("fn" in proto[name]) { const fn = proto[name].fn; + const invalidThisBehavior = proto[name].invalidThisBehavior ?? InvalidThisBehavior.Throw; rows.push(` JSC_DEFINE_HOST_FUNCTION(${symbolName(typeName, name)}Callback, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { @@ -1172,8 +1173,13 @@ JSC_DEFINE_HOST_FUNCTION(${symbolName(typeName, name)}Callback, (JSGlobalObject ${className(typeName)}* thisObject = jsDynamicCast<${className(typeName)}*>(callFrame->thisValue()); if (UNLIKELY(!thisObject)) { - scope.throwException(lexicalGlobalObject, Bun::createInvalidThisError(lexicalGlobalObject, callFrame->thisValue(), "${typeName}"_s)); - return {}; + ${ + invalidThisBehavior == InvalidThisBehavior.Throw + ? ` + scope.throwException(lexicalGlobalObject, Bun::createInvalidThisError(lexicalGlobalObject, callFrame->thisValue(), "${typeName}"_s)); + return {};` + : `return JSValue::encode(JSC::jsUndefined());` + } } JSC::EnsureStillAliveScope thisArg = JSC::EnsureStillAliveScope(thisObject); diff --git a/src/deps/uws.zig b/src/deps/uws.zig index 7396ded7de..2476f91c02 100644 --- a/src/deps/uws.zig +++ b/src/deps/uws.zig @@ -125,10 +125,10 @@ pub const UpgradedDuplex = struct { onEndCallback: JSC.Strong = .{}, onWritableCallback: JSC.Strong = .{}, onCloseCallback: JSC.Strong = .{}, - event_loop_timer: EventLoopTimer = .{ + event_loop_timer: EventLoopTimer.Node = .{ .data = .{ .next = .{}, .tag = .UpgradedDuplex, - }, + } }, current_timeout: u32 = 0, pub const Handlers = struct { @@ -315,10 +315,9 @@ pub const UpgradedDuplex = struct { pub fn onTimeout(this: *UpgradedDuplex) EventLoopTimer.Arm { log("onTimeout", .{}); - const has_been_cleared = this.event_loop_timer.state == .CANCELLED or this.vm.scriptExecutionStatus() != .running; + const has_been_cleared = this.event_loop_timer.data.state == .CANCELLED or this.vm.scriptExecutionStatus() != .running; - this.event_loop_timer.state = .FIRED; - this.event_loop_timer.heap = .{}; + this.event_loop_timer.data.state = .FIRED; if (has_been_cleared) { return .disarm; @@ -508,7 +507,7 @@ pub const UpgradedDuplex = struct { this.setTimeoutInMilliseconds(this.current_timeout); } pub fn setTimeoutInMilliseconds(this: *UpgradedDuplex, ms: c_uint) void { - if (this.event_loop_timer.state == .ACTIVE) { + if (this.event_loop_timer.data.state == .ACTIVE) { this.vm.timer.remove(&this.event_loop_timer); } this.current_timeout = ms; @@ -519,7 +518,7 @@ pub const UpgradedDuplex = struct { } // reschedule the timer - this.event_loop_timer.next = bun.timespec.msFromNow(ms); + this.event_loop_timer.data.next = bun.timespec.msFromNow(ms); this.vm.timer.insert(&this.event_loop_timer); } pub fn setTimeout(this: *UpgradedDuplex, seconds: c_uint) void { @@ -576,10 +575,10 @@ pub const WindowsNamedPipe = if (Environment.isWindows) struct { handlers: Handlers, connect_req: uv.uv_connect_t = std.mem.zeroes(uv.uv_connect_t), - event_loop_timer: EventLoopTimer = .{ + event_loop_timer: EventLoopTimer.Node = .{ .data = .{ .next = .{}, .tag = .WindowsNamedPipe, - }, + } }, current_timeout: u32 = 0, flags: Flags = .{}, @@ -786,10 +785,9 @@ pub const WindowsNamedPipe = if (Environment.isWindows) struct { pub fn onTimeout(this: *WindowsNamedPipe) EventLoopTimer.Arm { log("onTimeout", .{}); - const has_been_cleared = this.event_loop_timer.state == .CANCELLED or this.vm.scriptExecutionStatus() != .running; + const has_been_cleared = this.event_loop_timer.data.state == .CANCELLED or this.vm.scriptExecutionStatus() != .running; - this.event_loop_timer.state = .FIRED; - this.event_loop_timer.heap = .{}; + this.event_loop_timer.data.state = .FIRED; if (has_been_cleared) { return .disarm; @@ -1077,7 +1075,7 @@ pub const WindowsNamedPipe = if (Environment.isWindows) struct { this.setTimeoutInMilliseconds(this.current_timeout); } pub fn setTimeoutInMilliseconds(this: *WindowsNamedPipe, ms: c_uint) void { - if (this.event_loop_timer.state == .ACTIVE) { + if (this.event_loop_timer.data.state == .ACTIVE) { this.vm.timer.remove(&this.event_loop_timer); } this.current_timeout = ms; @@ -1088,7 +1086,7 @@ pub const WindowsNamedPipe = if (Environment.isWindows) struct { } // reschedule the timer - this.event_loop_timer.next = bun.timespec.msFromNow(ms); + this.event_loop_timer.data.next = bun.timespec.msFromNow(ms); this.vm.timer.insert(&this.event_loop_timer); } pub fn setTimeout(this: *WindowsNamedPipe, seconds: c_uint) void { diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 9eaacee2f0..fd9bd9bd0c 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -173,6 +173,10 @@ export const arrayBufferViewHasBuffer = $newCppFunction( 1, ); +export const timerInternals = { + timerClockMs: $newZigFunction("Timer.zig", "internal_bindings.timerClockMs", 0), +}; + export const decodeURIComponentSIMD = $newCppFunction( "decodeURIComponentSIMD.cpp", "jsFunctionDecodeURIComponentSIMD", diff --git a/src/js/internal/promisify.ts b/src/js/internal/promisify.ts index 39921cb6ed..305fda5035 100644 --- a/src/js/internal/promisify.ts +++ b/src/js/internal/promisify.ts @@ -69,29 +69,25 @@ var promisify = function promisify(original) { }; promisify.custom = kCustomPromisifiedSymbol; -// Lazily load node:timers/promises promisified functions onto the global timers. +// Load node:timers/promises promisified functions onto the global timers. { const { setTimeout: timeout, setImmediate: immediate, setInterval: interval } = globalThis; + const { + setTimeout: timeoutPromise, + setImmediate: immediatePromise, + setInterval: intervalPromise, + } = require("node:timers/promises"); if (timeout && $isCallable(timeout)) { - defineCustomPromisify(timeout, function setTimeout(arg1) { - const fn = defineCustomPromisify(timeout, require("node:timers/promises").setTimeout); - return fn.$apply(this, arguments); - }); + defineCustomPromisify(timeout, timeoutPromise); } if (immediate && $isCallable(immediate)) { - defineCustomPromisify(immediate, function setImmediate(arg1) { - const fn = defineCustomPromisify(immediate, require("node:timers/promises").setImmediate); - return fn.$apply(this, arguments); - }); + defineCustomPromisify(immediate, immediatePromise); } if (interval && $isCallable(interval)) { - defineCustomPromisify(interval, function setInterval(arg1) { - const fn = defineCustomPromisify(interval, require("node:timers/promises").setInterval); - return fn.$apply(this, arguments); - }); + defineCustomPromisify(interval, intervalPromise); } } diff --git a/src/js/node/timers.promises.ts b/src/js/node/timers.promises.ts index 97c302d1ca..21ccddba1f 100644 --- a/src/js/node/timers.promises.ts +++ b/src/js/node/timers.promises.ts @@ -1,7 +1,7 @@ // Hardcoded module "node:timers/promises" // https://github.com/niksy/isomorphic-timers-promises/blob/master/index.js -const { validateBoolean, validateAbortSignal, validateObject } = require("internal/validators"); +const { validateBoolean, validateAbortSignal, validateObject, validateNumber } = require("internal/validators"); const symbolAsyncIterator = Symbol.asyncIterator; @@ -22,6 +22,16 @@ function asyncIterator({ next: nextFunction, return: returnFunction }) { function setTimeoutPromise(after = 1, value, options = {}) { const arguments_ = [].concat(value ?? []); + try { + // If after is a number, but an invalid one (too big, Infinity, NaN), we only want to emit a + // warning, not throw an error. So we can't call validateNumber as that will throw if the number + // is outside of a given range. + if (typeof after != "number") { + validateNumber(after, "delay"); + } + } catch (error) { + return Promise.reject(error); + } try { validateObject(options, "options"); } catch (error) { @@ -39,7 +49,7 @@ function setTimeoutPromise(after = 1, value, options = {}) { return Promise.reject(error); } if (signal?.aborted) { - return Promise.reject($makeAbortError()); + return Promise.reject($makeAbortError(undefined, { cause: signal.reason })); } let onCancel; const returnValue = new Promise((resolve, reject) => { @@ -50,7 +60,7 @@ function setTimeoutPromise(after = 1, value, options = {}) { if (signal) { onCancel = () => { clearTimeout(timeout); - reject($makeAbortError()); + reject($makeAbortError(undefined, { cause: signal.reason })); }; signal.addEventListener("abort", onCancel); } @@ -78,7 +88,7 @@ function setImmediatePromise(value, options = {}) { return Promise.reject(error); } if (signal?.aborted) { - return Promise.reject($makeAbortError()); + return Promise.reject($makeAbortError(undefined, { cause: signal.reason })); } let onCancel; const returnValue = new Promise((resolve, reject) => { @@ -89,7 +99,7 @@ function setImmediatePromise(value, options = {}) { if (signal) { onCancel = () => { clearImmediate(immediate); - reject($makeAbortError()); + reject($makeAbortError(undefined, { cause: signal.reason })); }; signal.addEventListener("abort", onCancel); } @@ -101,6 +111,20 @@ function setImmediatePromise(value, options = {}) { function setIntervalPromise(after = 1, value, options = {}) { /* eslint-disable no-undefined, no-unreachable-loop, no-loop-func */ + try { + // If after is a number, but an invalid one (too big, Infinity, NaN), we only want to emit a + // warning, not throw an error. So we can't call validateNumber as that will throw if the number + // is outside of a given range. + if (typeof after != "number") { + validateNumber(after, "delay"); + } + } catch (error) { + return asyncIterator({ + next: function () { + return Promise.reject(error); + }, + }); + } try { validateObject(options, "options"); } catch (error) { @@ -132,7 +156,7 @@ function setIntervalPromise(after = 1, value, options = {}) { if (signal?.aborted) { return asyncIterator({ next: function () { - return Promise.reject($makeAbortError()); + return Promise.reject($makeAbortError(undefined, { cause: signal.reason })); }, }); } @@ -173,7 +197,7 @@ function setIntervalPromise(after = 1, value, options = {}) { resolve(); } } else if (notYielded === 0) { - reject($makeAbortError()); + reject($makeAbortError(undefined, { cause: signal.reason })); } else { resolve(); } @@ -181,6 +205,8 @@ function setIntervalPromise(after = 1, value, options = {}) { if (notYielded > 0) { notYielded = notYielded - 1; return { done: false, value: value }; + } else if (signal?.aborted) { + throw $makeAbortError(undefined, { cause: signal.reason }); } return { done: true }; }); diff --git a/src/sql/postgres.zig b/src/sql/postgres.zig index be62e89f57..dc37cb9943 100644 --- a/src/sql/postgres.zig +++ b/src/sql/postgres.zig @@ -1258,25 +1258,25 @@ pub const PostgresSQLConnection = struct { /// Before being connected, this is a connection timeout timer. /// After being connected, this is an idle timeout timer. - timer: JSC.BunTimer.EventLoopTimer = .{ + timer: JSC.BunTimer.EventLoopTimer.Node = .{ .data = .{ .tag = .PostgresSQLConnectionTimeout, .next = .{ .sec = 0, .nsec = 0, }, - }, + } }, /// This timer controls the maximum lifetime of a connection. /// It starts when the connection successfully starts (i.e. after handshake is complete). /// It stops when the connection is closed. max_lifetime_interval_ms: u32 = 0, - max_lifetime_timer: JSC.BunTimer.EventLoopTimer = .{ + max_lifetime_timer: JSC.BunTimer.EventLoopTimer.Node = .{ .data = .{ .tag = .PostgresSQLConnectionMaxLifetime, .next = .{ .sec = 0, .nsec = 0, }, - }, + } }, pub const ConnectionFlags = packed struct { is_ready_for_query: bool = false, @@ -1421,23 +1421,23 @@ pub const PostgresSQLConnection = struct { }; } pub fn disableConnectionTimeout(this: *PostgresSQLConnection) void { - if (this.timer.state == .ACTIVE) { + if (this.timer.data.state == .ACTIVE) { this.globalObject.bunVM().timer.remove(&this.timer); } - this.timer.state = .CANCELLED; + this.timer.data.state = .CANCELLED; } pub fn resetConnectionTimeout(this: *PostgresSQLConnection) void { // if we are processing data, don't reset the timeout, wait for the data to be processed if (this.flags.is_processing_data) return; const interval = this.getTimeoutInterval(); - if (this.timer.state == .ACTIVE) { + if (this.timer.data.state == .ACTIVE) { this.globalObject.bunVM().timer.remove(&this.timer); } if (interval == 0) { return; } - this.timer.next = bun.timespec.msFromNow(@intCast(interval)); + this.timer.data.next = bun.timespec.msFromNow(@intCast(interval)); this.globalObject.bunVM().timer.insert(&this.timer); } @@ -1496,16 +1496,16 @@ pub const PostgresSQLConnection = struct { } fn setupMaxLifetimeTimerIfNecessary(this: *PostgresSQLConnection) void { if (this.max_lifetime_interval_ms == 0) return; - if (this.max_lifetime_timer.state == .ACTIVE) return; + if (this.max_lifetime_timer.data.state == .ACTIVE) return; - this.max_lifetime_timer.next = bun.timespec.msFromNow(@intCast(this.max_lifetime_interval_ms)); + this.max_lifetime_timer.data.next = bun.timespec.msFromNow(@intCast(this.max_lifetime_interval_ms)); this.globalObject.bunVM().timer.insert(&this.max_lifetime_timer); } pub fn onConnectionTimeout(this: *PostgresSQLConnection) JSC.BunTimer.EventLoopTimer.Arm { debug("onConnectionTimeout", .{}); - this.timer.state = .FIRED; + this.timer.data.state = .FIRED; if (this.flags.is_processing_data) { return .disarm; } @@ -1531,7 +1531,7 @@ pub const PostgresSQLConnection = struct { pub fn onMaxLifetimeTimeout(this: *PostgresSQLConnection) JSC.BunTimer.EventLoopTimer.Arm { debug("onMaxLifetimeTimeout", .{}); - this.max_lifetime_timer.state = .FIRED; + this.max_lifetime_timer.data.state = .FIRED; if (this.status == .failed) return .disarm; this.failFmt(.ERR_POSTGRES_LIFETIME_TIMEOUT, "Max lifetime timeout reached after {}", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.max_lifetime_interval_ms) *| std.time.ns_per_ms)}); return .disarm; @@ -2103,10 +2103,10 @@ pub const PostgresSQLConnection = struct { } pub fn stopTimers(this: *PostgresSQLConnection) void { - if (this.timer.state == .ACTIVE) { + if (this.timer.data.state == .ACTIVE) { this.globalObject.bunVM().timer.remove(&this.timer); } - if (this.max_lifetime_timer.state == .ACTIVE) { + if (this.max_lifetime_timer.data.state == .ACTIVE) { this.globalObject.bunVM().timer.remove(&this.max_lifetime_timer); } } diff --git a/test/js/node/test/parallel/test-timers-clear-null-does-not-throw-error.js b/test/js/node/test/parallel/test-timers-clear-null-does-not-throw-error.js new file mode 100644 index 0000000000..89d433c191 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-clear-null-does-not-throw-error.js @@ -0,0 +1,11 @@ +'use strict'; +require('../common'); + +// This test makes sure clearing timers with +// 'null' or no input does not throw error +clearInterval(null); +clearInterval(); +clearTimeout(null); +clearTimeout(); +clearImmediate(null); +clearImmediate(); diff --git a/test/js/node/test/parallel/test-timers-clearImmediate-als.js b/test/js/node/test/parallel/test-timers-clearImmediate-als.js new file mode 100644 index 0000000000..bf2bc6b8d5 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-clearImmediate-als.js @@ -0,0 +1,28 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { AsyncLocalStorage } = require('async_hooks'); + +// This is an asynclocalstorage variant of test-timers-clearImmediate.js +const asyncLocalStorage = new AsyncLocalStorage(); +const N = 3; + +function next() { + const fn = common.mustCall(onImmediate); + asyncLocalStorage.run(new Map(), common.mustCall(() => { + const immediate = setImmediate(fn); + const store = asyncLocalStorage.getStore(); + store.set('immediate', immediate); + })); +} + +function onImmediate() { + const store = asyncLocalStorage.getStore(); + const immediate = store.get('immediate'); + assert.strictEqual(immediate.constructor.name, 'Immediate'); + clearImmediate(immediate); +} + +for (let i = 0; i < N; i++) { + next(); +} diff --git a/test/js/node/test/parallel/test-timers-destroyed.js b/test/js/node/test/parallel/test-timers-destroyed.js new file mode 100644 index 0000000000..b11f93b9ff --- /dev/null +++ b/test/js/node/test/parallel/test-timers-destroyed.js @@ -0,0 +1,27 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +// We don't really care about the calling results here. +// So, this makes the test less fragile. +const noop = () => {}; + +const t1 = setTimeout(common.mustNotCall(), 1); +const t2 = setTimeout(common.mustCall(), 1); +const i1 = setInterval(common.mustNotCall(), 1); +const i2 = setInterval(noop, 1); +i2.unref(); + +// Keep process alive for i2 to call once due to timer ordering. +setTimeout(common.mustCall(), 1); + +clearTimeout(t1); +clearInterval(i1); + +process.on('exit', () => { + assert.strictEqual(t1._destroyed, true); + assert.strictEqual(t2._destroyed, true); + assert.strictEqual(i1._destroyed, true); + assert.strictEqual(i2._destroyed, false); +}); diff --git a/test/js/node/test/parallel/test-timers-dispose.js b/test/js/node/test/parallel/test-timers-dispose.js new file mode 100644 index 0000000000..a75916b418 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-dispose.js @@ -0,0 +1,18 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const timer = setTimeout(common.mustNotCall(), 10); +const interval = setInterval(common.mustNotCall(), 10); +const immediate = setImmediate(common.mustNotCall()); + +timer[Symbol.dispose](); +interval[Symbol.dispose](); +immediate[Symbol.dispose](); + + +process.on('exit', () => { + assert.strictEqual(timer._destroyed, true); + assert.strictEqual(interval._destroyed, true); + assert.strictEqual(immediate._destroyed, true); +}); diff --git a/test/js/node/test/parallel/test-timers-immediate-promisified.js b/test/js/node/test/parallel/test-timers-immediate-promisified.js new file mode 100644 index 0000000000..83f4fcda15 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-immediate-promisified.js @@ -0,0 +1,104 @@ +// Flags: --no-warnings +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const timers = require('timers'); +const { promisify } = require('util'); + +const { getEventListeners } = require('events'); + +const timerPromises = require('timers/promises'); + +const setPromiseImmediate = promisify(timers.setImmediate); + +assert.strictEqual(setPromiseImmediate, timerPromises.setImmediate); + +process.on('multipleResolves', common.mustNotCall()); + +{ + const promise = setPromiseImmediate(); + promise.then(common.mustCall((value) => { + assert.strictEqual(value, undefined); + })); +} + +{ + const promise = setPromiseImmediate('foobar'); + promise.then(common.mustCall((value) => { + assert.strictEqual(value, 'foobar'); + })); +} + +{ + const ac = new AbortController(); + const signal = ac.signal; + assert.rejects(setPromiseImmediate(10, { signal }), /AbortError/) + .then(common.mustCall()); + ac.abort(); +} + +{ + const signal = AbortSignal.abort(); // Abort in advance + assert.rejects(setPromiseImmediate(10, { signal }), /AbortError/) + .then(common.mustCall()); +} + +{ + // Check that aborting after resolve will not reject. + const ac = new AbortController(); + const signal = ac.signal; + setPromiseImmediate(10, { signal }) + .then(common.mustCall(() => { ac.abort(); })) + .then(common.mustCall()); +} + +{ + // Check that timer adding signals does not leak handlers + const ac = new AbortController(); + const signal = ac.signal; + setPromiseImmediate(0, { signal }).finally(common.mustCall(() => { + assert.strictEqual(getEventListeners(signal, 'abort').length, 0); + })); + assert.strictEqual(getEventListeners(signal, 'abort').length, 1); +} + +{ + Promise.all( + [1, '', false, Infinity].map( + (i) => assert.rejects(setPromiseImmediate(10, i), { + code: 'ERR_INVALID_ARG_TYPE' + }) + ) + ).then(common.mustCall()); + + Promise.all( + [1, '', false, Infinity, null, {}].map( + (signal) => assert.rejects(setPromiseImmediate(10, { signal }), { + code: 'ERR_INVALID_ARG_TYPE' + }) + ) + ).then(common.mustCall()); + + Promise.all( + [1, '', Infinity, null, {}].map( + (ref) => assert.rejects(setPromiseImmediate(10, { ref }), { + code: 'ERR_INVALID_ARG_TYPE' + }) + ) + ).then(common.mustCall()); +} + +{ + common.spawnPromisified(process.execPath, ['-p', "const assert = require('assert');" + + 'require(\'timers/promises\').setImmediate(null, { ref: false }).' + + 'then(assert.fail)']).then(common.mustCall(({ stderr }) => { + assert.strictEqual(stderr, ''); + })); +} + +(async () => { + const signal = AbortSignal.abort('boom'); + await assert.rejects(timerPromises.setImmediate(undefined, { signal }), { + cause: 'boom', + }); +})().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-timers-immediate-queue.js b/test/js/node/test/parallel/test-timers-immediate-queue.js new file mode 100644 index 0000000000..d67b98a431 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-immediate-queue.js @@ -0,0 +1,59 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +require('../common'); +const assert = require('assert'); + +// setImmediate should run clear its queued cbs once per event loop turn +// but immediates queued while processing the current queue should happen +// on the next turn of the event loop. + +// hit should be the exact same size of QUEUE, if we're letting things +// recursively add to the immediate QUEUE hit will be > QUEUE + +let ticked = false; + +let hit = 0; +const QUEUE = 10; + +function run() { + if (hit === 0) { + setTimeout(() => { ticked = true; }, 1); + const now = Date.now(); + // BUN: blocking duration changed from 2ms to 20ms as our Date.now() is slightly less precise + // than Node on Windows, and setTimeout() uses a different clock than Date.now(), so sometimes + // this loop will finish blocking for 2ms but the setTimeout() will not fire yet + while (Date.now() - now < 20); + } + + if (ticked) return; + + hit += 1; + setImmediate(run); +} + +for (let i = 0; i < QUEUE; i++) + setImmediate(run); + +process.on('exit', function() { + assert.strictEqual(hit, QUEUE); +}); diff --git a/test/js/node/test/parallel/test-timers-immediate-unref-nested-once.js b/test/js/node/test/parallel/test-timers-immediate-unref-nested-once.js new file mode 100644 index 0000000000..00efce9bcb --- /dev/null +++ b/test/js/node/test/parallel/test-timers-immediate-unref-nested-once.js @@ -0,0 +1,9 @@ +'use strict'; + +const common = require('../common'); + +// This immediate should not execute as it was unrefed +// and nothing else is keeping the event loop alive +setImmediate(() => { + setImmediate(common.mustNotCall()).unref(); +}); diff --git a/test/js/node/test/parallel/test-timers-immediate-unref-simple.js b/test/js/node/test/parallel/test-timers-immediate-unref-simple.js new file mode 100644 index 0000000000..fae8ad3eae --- /dev/null +++ b/test/js/node/test/parallel/test-timers-immediate-unref-simple.js @@ -0,0 +1,13 @@ +'use strict'; + +const common = require('../common'); +const { isMainThread } = require('worker_threads'); + +if (!isMainThread) { + // Note that test-timers-immediate-unref-nested-once works instead. + common.skip('Worker bootstrapping works differently -> different timing'); +} + +// This immediate should not execute as it was unrefed +// and nothing else is keeping the event loop alive +setImmediate(common.mustNotCall()).unref(); diff --git a/test/js/node/test/parallel/test-timers-interval-promisified.js b/test/js/node/test/parallel/test-timers-interval-promisified.js new file mode 100644 index 0000000000..3af54bb8e5 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-interval-promisified.js @@ -0,0 +1,259 @@ +// Flags: --no-warnings +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const timers = require('timers'); +const { promisify } = require('util'); + +const { getEventListeners } = require('events'); + +const timerPromises = require('timers/promises'); + +const setPromiseTimeout = promisify(timers.setTimeout); + +const { setInterval } = timerPromises; + +process.on('multipleResolves', common.mustNotCall()); + +{ + const iterable = setInterval(1, undefined); + const iterator = iterable[Symbol.asyncIterator](); + const promise = iterator.next(); + promise.then(common.mustCall((result) => { + assert.ok(!result.done, 'iterator was wrongly marked as done'); + assert.strictEqual(result.value, undefined); + return iterator.return(); + })).then(common.mustCall()); +} + +{ + const iterable = setInterval(1, 'foobar'); + const iterator = iterable[Symbol.asyncIterator](); + const promise = iterator.next(); + promise.then(common.mustCall((result) => { + assert.ok(!result.done, 'iterator was wronly marked as done'); + assert.strictEqual(result.value, 'foobar'); + return iterator.return(); + })).then(common.mustCall()); +} + +{ + const iterable = setInterval(1, 'foobar'); + const iterator = iterable[Symbol.asyncIterator](); + const promise = iterator.next(); + promise + .then(common.mustCall((result) => { + assert.ok(!result.done, 'iterator was wronly marked as done'); + assert.strictEqual(result.value, 'foobar'); + return iterator.next(); + })) + .then(common.mustCall((result) => { + assert.ok(!result.done, 'iterator was wrongly marked as done'); + assert.strictEqual(result.value, 'foobar'); + return iterator.return(); + })) + .then(common.mustCall()); +} + +{ + const signal = AbortSignal.abort(); // Abort in advance + + const iterable = setInterval(1, undefined, { signal }); + const iterator = iterable[Symbol.asyncIterator](); + assert.rejects(iterator.next(), /AbortError/).then(common.mustCall()); +} + +{ + const ac = new AbortController(); + const { signal } = ac; + + const iterable = setInterval(100, undefined, { signal }); + const iterator = iterable[Symbol.asyncIterator](); + + // This promise should take 100 seconds to resolve, so now aborting it should + // mean we abort early + const promise = iterator.next(); + + ac.abort(); // Abort in after we have a next promise + + assert.rejects(promise, /AbortError/).then(common.mustCall()); +} + +{ + // Check aborting after getting a value. + const ac = new AbortController(); + const { signal } = ac; + + const iterable = setInterval(100, undefined, { signal }); + const iterator = iterable[Symbol.asyncIterator](); + + const promise = iterator.next(); + const abortPromise = promise.then(common.mustCall(() => ac.abort())) + .then(() => iterator.next()); + assert.rejects(abortPromise, /AbortError/).then(common.mustCall()); +} + +{ + [1, '', Infinity, null, {}].forEach((ref) => { + const iterable = setInterval(10, undefined, { ref }); + assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/) + .then(common.mustCall()); + }); + + [1, '', Infinity, null, {}].forEach((signal) => { + const iterable = setInterval(10, undefined, { signal }); + assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/) + .then(common.mustCall()); + }); + + [1, '', Infinity, null, true, false].forEach((options) => { + const iterable = setInterval(10, undefined, options); + assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/) + .then(common.mustCall()); + }); +} + +{ + // Check that timer adding signals does not leak handlers + const ac = new AbortController(); + const { signal } = ac; + assert.strictEqual(signal.aborted, false); + const iterator = setInterval(1, undefined, { signal }); + iterator.next().then(common.mustCall(() => { + assert.strictEqual(getEventListeners(signal, 'abort').length, 1); + iterator.return(); + })).finally(common.mustCall(() => { + assert.strictEqual(getEventListeners(signal, 'abort').length, 0); + })); +} + +{ + // Check that break removes the signal listener + const ac = new AbortController(); + const { signal } = ac; + assert.strictEqual(signal.aborted, false); + async function tryBreak() { + const iterator = setInterval(10, undefined, { signal }); + let i = 0; + // eslint-disable-next-line no-unused-vars + for await (const _ of iterator) { + if (i === 0) { + assert.strictEqual(getEventListeners(signal, 'abort').length, 1); + } + i++; + if (i === 2) { + break; + } + } + assert.strictEqual(i, 2); + assert.strictEqual(getEventListeners(signal, 'abort').length, 0); + } + + tryBreak().then(common.mustCall()); +} + +{ + common.spawnPromisified(process.execPath, ['-p', "const assert = require('assert');" + + 'const interval = require(\'timers/promises\')' + + '.setInterval(1000, null, { ref: false });' + + 'interval[Symbol.asyncIterator]().next()' + + '.then(assert.fail)']).then(common.mustCall(({ stderr }) => { + assert.strictEqual(stderr, ''); + })); +} + +{ + async function runInterval(fn, intervalTime, signal) { + const input = 'foobar'; + const interval = setInterval(intervalTime, input, { signal }); + let iteration = 0; + for await (const value of interval) { + assert.strictEqual(value, input); + iteration++; + await fn(iteration); + } + } + + { + // Check that we call the correct amount of times. + const controller = new AbortController(); + const { signal } = controller; + + let loopCount = 0; + const delay = 20; + const timeoutLoop = runInterval(() => { + loopCount++; + if (loopCount === 5) controller.abort(); + if (loopCount > 5) throw new Error('ran too many times'); + }, delay, signal); + + assert.rejects(timeoutLoop, /AbortError/).then(common.mustCall(() => { + assert.strictEqual(loopCount, 5); + })); + } + + { + // Check that if we abort when we have some unresolved callbacks, + // we actually call them. + const controller = new AbortController(); + const { signal } = controller; + const delay = 10; + let totalIterations = 0; + const timeoutLoop = runInterval(async (iterationNumber) => { + await setPromiseTimeout(delay * 4); + if (iterationNumber <= 2) { + assert.strictEqual(signal.aborted, false); + } + if (iterationNumber === 2) { + controller.abort(); + } + if (iterationNumber > 2) { + assert.strictEqual(signal.aborted, true); + } + if (iterationNumber > totalIterations) { + totalIterations = iterationNumber; + } + }, delay, signal); + + timeoutLoop.catch(common.mustCall(() => { + assert.ok(totalIterations >= 3, `iterations was ${totalIterations} < 3`); + })); + } +} + +{ + // Check that the timing is correct + let pre = false; + let post = false; + + const time_unit = 50; + Promise.all([ + setPromiseTimeout(1).then(() => pre = true), + new Promise((res) => { + const iterable = timerPromises.setInterval(time_unit * 2); + const iterator = iterable[Symbol.asyncIterator](); + + iterator.next().then(() => { + assert.ok(pre, 'interval ran too early'); + assert.ok(!post, 'interval ran too late'); + return iterator.next(); + }).then(() => { + assert.ok(post, 'second interval ran too early'); + return iterator.return(); + }).then(res); + }), + setPromiseTimeout(time_unit * 3).then(() => post = true), + ]).then(common.mustCall()); +} + +(async () => { + const signal = AbortSignal.abort('boom'); + try { + const iterable = timerPromises.setInterval(2, undefined, { signal }); + // eslint-disable-next-line no-unused-vars, no-empty + for await (const _ of iterable) { } + assert.fail('should have failed'); + } catch (err) { + assert.strictEqual(err.cause, 'boom'); + } +})().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-timers-linked-list.js b/test/js/node/test/parallel/test-timers-linked-list.js new file mode 100644 index 0000000000..d34a206088 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-linked-list.js @@ -0,0 +1,108 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; + +// Flags: --expose-internals +const common = require('../common'); +common.skip("skipped because it tests node internals irrelevant to bun"); + +const assert = require('assert'); +const L = require('internal/linkedlist'); + +const list = { name: 'list' }; +const A = { name: 'A' }; +const B = { name: 'B' }; +const C = { name: 'C' }; +const D = { name: 'D' }; + + +L.init(list); +L.init(A); +L.init(B); +L.init(C); +L.init(D); + +assert.ok(L.isEmpty(list)); +assert.strictEqual(L.peek(list), null); + +L.append(list, A); +// list -> A +assert.strictEqual(L.peek(list), A); + +L.append(list, B); +// list -> A -> B +assert.strictEqual(L.peek(list), A); + +L.append(list, C); +// list -> A -> B -> C +assert.strictEqual(L.peek(list), A); + +L.append(list, D); +// list -> A -> B -> C -> D +assert.strictEqual(L.peek(list), A); + +L.remove(A); +L.remove(B); +// B is already removed, so removing it again shouldn't hurt. +L.remove(B); +// list -> C -> D +assert.strictEqual(L.peek(list), C); + +// Put B back on the list +L.append(list, B); +// list -> C -> D -> B +assert.strictEqual(L.peek(list), C); + +L.remove(C); +// list -> D -> B +assert.strictEqual(L.peek(list), D); + +L.remove(B); +// list -> D +assert.strictEqual(L.peek(list), D); + +L.remove(D); +// list +assert.strictEqual(L.peek(list), null); + + +assert.ok(L.isEmpty(list)); + + +L.append(list, D); +// list -> D +assert.strictEqual(L.peek(list), D); + +L.append(list, C); +L.append(list, B); +L.append(list, A); +// list -> D -> C -> B -> A + +// Append should REMOVE C from the list and append it to the end. +L.append(list, C); +// list -> D -> B -> A -> C + +assert.strictEqual(L.peek(list), D); +assert.strictEqual(L.peek(D), B); +assert.strictEqual(L.peek(B), A); +assert.strictEqual(L.peek(A), C); +assert.strictEqual(L.peek(C), list); diff --git a/test/js/node/test/parallel/test-timers-nan-duration-emit-once-per-process.js b/test/js/node/test/parallel/test-timers-nan-duration-emit-once-per-process.js new file mode 100644 index 0000000000..4dd2eed5ac --- /dev/null +++ b/test/js/node/test/parallel/test-timers-nan-duration-emit-once-per-process.js @@ -0,0 +1,39 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +const NOT_A_NUMBER = NaN; + +function timerNotCanceled() { + assert.fail('Timer should be canceled'); +} + +process.on( + 'warning', + common.mustCall((warning) => { + if (warning.name === 'DeprecationWarning') return; + + const lines = warning.message.split('\n'); + + assert.strictEqual(warning.name, 'TimeoutNaNWarning'); + assert.strictEqual(lines[0], `${NOT_A_NUMBER} is not a number.`); + assert.strictEqual(lines.length, 2); + }, 1) +); + +{ + const timeout = setTimeout(timerNotCanceled, NOT_A_NUMBER); + clearTimeout(timeout); +} + +{ + const interval = setInterval(timerNotCanceled, NOT_A_NUMBER); + clearInterval(interval); +} + +{ + const timeout = setTimeout(timerNotCanceled, NOT_A_NUMBER); + timeout.refresh(); + clearTimeout(timeout); +} diff --git a/test/js/node/test/parallel/test-timers-nan-duration-warning-promises.js b/test/js/node/test/parallel/test-timers-nan-duration-warning-promises.js new file mode 100644 index 0000000000..2a7cbeb2fb --- /dev/null +++ b/test/js/node/test/parallel/test-timers-nan-duration-warning-promises.js @@ -0,0 +1,11 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { setTimeout } = require('timers/promises'); + +process.once('warning', common.mustCall((warning) => { + assert.strictEqual(warning.name, 'TimeoutNaNWarning'); +})); + +setTimeout(NaN).then(common.mustCall(), common.mustNotCall()); diff --git a/test/js/node/test/parallel/test-timers-nan-duration-warning.js b/test/js/node/test/parallel/test-timers-nan-duration-warning.js new file mode 100644 index 0000000000..f8bbf65a08 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-nan-duration-warning.js @@ -0,0 +1,67 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const child_process = require('child_process'); +const path = require('path'); + +const NOT_A_NUMBER = NaN; + +function timerNotCanceled() { + assert.fail('Timer should be canceled'); +} + +const testCases = ['timeout', 'interval', 'refresh']; + +function runTests() { + const args = process.argv.slice(2); + + const testChoice = args[0]; + + if (!testChoice) { + const filePath = path.join(__filename); + + testCases.forEach((testCase) => { + const { stdout } = child_process.spawnSync( + process.execPath, + [filePath, testCase], + { encoding: 'utf8' } + ); + + const lines = stdout.split('\n'); + + if (lines[0] === 'DeprecationWarning') return; + + assert.strictEqual(lines[0], 'TimeoutNaNWarning'); + assert.strictEqual(lines[1], `${NOT_A_NUMBER} is not a number.`); + assert.strictEqual(lines[2], 'Timeout duration was set to 1.'); + }); + } + + if (args[0] === testCases[0]) { + const timeout = setTimeout(timerNotCanceled, NOT_A_NUMBER); + clearTimeout(timeout); + } + + if (args[0] === testCases[1]) { + const interval = setInterval(timerNotCanceled, NOT_A_NUMBER); + clearInterval(interval); + } + + if (args[0] === testCases[2]) { + const timeout = setTimeout(timerNotCanceled, NOT_A_NUMBER); + timeout.refresh(); + clearTimeout(timeout); + } + + process.on( + 'warning', + + (warning) => { + console.log(warning.name); + console.log(warning.message); + } + ); +} + +runTests(); diff --git a/test/js/node/test/parallel/test-timers-negative-duration-warning-emit-once-per-process.js b/test/js/node/test/parallel/test-timers-negative-duration-warning-emit-once-per-process.js new file mode 100644 index 0000000000..2cf3197789 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-negative-duration-warning-emit-once-per-process.js @@ -0,0 +1,39 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +const NEGATIVE_NUMBER = -1; + +function timerNotCanceled() { + assert.fail('Timer should be canceled'); +} + +process.on( + 'warning', + common.mustCall((warning) => { + if (warning.name === 'DeprecationWarning') return; + + const lines = warning.message.split('\n'); + + assert.strictEqual(warning.name, 'TimeoutNegativeWarning'); + assert.strictEqual(lines[0], `${NEGATIVE_NUMBER} is a negative number.`); + assert.strictEqual(lines.length, 2); + }, 1) +); + +{ + const timeout = setTimeout(timerNotCanceled, NEGATIVE_NUMBER); + clearTimeout(timeout); +} + +{ + const interval = setInterval(timerNotCanceled, NEGATIVE_NUMBER); + clearInterval(interval); +} + +{ + const timeout = setTimeout(timerNotCanceled, NEGATIVE_NUMBER); + timeout.refresh(); + clearTimeout(timeout); +} diff --git a/test/js/node/test/parallel/test-timers-negative-duration-warning.js b/test/js/node/test/parallel/test-timers-negative-duration-warning.js new file mode 100644 index 0000000000..4e236e1af9 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-negative-duration-warning.js @@ -0,0 +1,67 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const child_process = require('child_process'); +const path = require('path'); + +const NEGATIVE_NUMBER = -1; + +function timerNotCanceled() { + assert.fail('Timer should be canceled'); +} + +const testCases = ['timeout', 'interval', 'refresh']; + +function runTests() { + const args = process.argv.slice(2); + + const testChoice = args[0]; + + if (!testChoice) { + const filePath = path.join(__filename); + + testCases.forEach((testCase) => { + const { stdout } = child_process.spawnSync( + process.execPath, + [filePath, testCase], + { encoding: 'utf8' } + ); + + const lines = stdout.split('\n'); + + if (lines[0] === 'DeprecationWarning') return; + + assert.strictEqual(lines[0], 'TimeoutNegativeWarning'); + assert.strictEqual(lines[1], `${NEGATIVE_NUMBER} is a negative number.`); + assert.strictEqual(lines[2], 'Timeout duration was set to 1.'); + }); + } + + if (args[0] === testCases[0]) { + const timeout = setTimeout(timerNotCanceled, NEGATIVE_NUMBER); + clearTimeout(timeout); + } + + if (args[0] === testCases[1]) { + const interval = setInterval(timerNotCanceled, NEGATIVE_NUMBER); + clearInterval(interval); + } + + if (args[0] === testCases[2]) { + const timeout = setTimeout(timerNotCanceled, NEGATIVE_NUMBER); + timeout.refresh(); + clearTimeout(timeout); + } + + process.on( + 'warning', + + (warning) => { + console.log(warning.name); + console.log(warning.message); + } + ); +} + +runTests(); diff --git a/test/js/node/test/parallel/test-timers-nested.js b/test/js/node/test/parallel/test-timers-nested.js new file mode 100644 index 0000000000..2f978dd987 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-nested.js @@ -0,0 +1,41 @@ +// Flags: --expose-internals +'use strict'; + +require('../common'); +const assert = require('assert'); +const { sleep } = typeof Bun == 'object' ? { sleep: Bun.sleepSync } : require('internal/util'); + +// Make sure we test 0ms timers, since they would had always wanted to run on +// the current tick, and greater than 0ms timers, for scenarios where the +// outer timer takes longer to complete than the delay of the nested timer. +// Since the process of recreating this is identical regardless of the timer +// delay, these scenarios are in one test. +const scenarios = [0, 100]; + +scenarios.forEach(function(delay) { + let nestedCalled = false; + + setTimeout(function A() { + // Create the nested timer with the same delay as the outer timer so that it + // gets added to the current list of timers being processed by + // listOnTimeout. + setTimeout(function B() { + nestedCalled = true; + }, delay); + + // Busy loop for the same timeout used for the nested timer to ensure that + // we are in fact expiring the nested timer. + sleep(delay); + + // The purpose of running this assert in nextTick is to make sure it runs + // after A but before the next iteration of the libuv event loop. + process.nextTick(function() { + assert.ok(!nestedCalled); + }); + + // Ensure that the nested callback is indeed called prior to process exit. + process.on('exit', function onExit() { + assert.ok(nestedCalled); + }); + }, delay); +}); diff --git a/test/js/node/test/parallel/test-timers-next-tick.js b/test/js/node/test/parallel/test-timers-next-tick.js new file mode 100644 index 0000000000..66dbcb3be2 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-next-tick.js @@ -0,0 +1,32 @@ +// Flags: --expose-internals +'use strict'; + +const common = require('../common'); +const { sleep } = typeof Bun === "object" ? { sleep: Bun.sleepSync } : require('internal/util'); + +// This test verifies that the next tick queue runs after each +// individual Timeout, as well as each individual Immediate. + +setTimeout(common.mustCall(() => { + process.nextTick(() => { + // Confirm that clearing Timeouts from a next tick doesn't explode. + clearTimeout(t2); + clearTimeout(t3); + }); +}), 1); +const t2 = setTimeout(common.mustNotCall(), 1); +const t3 = setTimeout(common.mustNotCall(), 1); +setTimeout(common.mustCall(), 1); + +sleep(5); + +setImmediate(common.mustCall(() => { + process.nextTick(() => { + // Confirm that clearing Immediates from a next tick doesn't explode. + clearImmediate(i2); + clearImmediate(i3); + }); +})); +const i2 = setImmediate(common.mustNotCall()); +const i3 = setImmediate(common.mustNotCall()); +setImmediate(common.mustCall()); diff --git a/test/js/node/test/parallel/test-timers-now.js b/test/js/node/test/parallel/test-timers-now.js new file mode 100644 index 0000000000..cb04e22f17 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-now.js @@ -0,0 +1,12 @@ +'use strict'; +// Flags: --expose-internals + +const common = require('../common'); +common.skip("skipped because it tests node internals irrelevant to bun"); +const assert = require('assert'); +const { internalBinding } = require('internal/test/binding'); +const binding = internalBinding('timers'); + +// Return value of getLibuvNow() should easily fit in a SMI after start-up. +// We need to use the binding as the receiver for fast API calls. +assert(binding.getLibuvNow() < 0x3ffffff); diff --git a/test/js/node/test/parallel/test-timers-ordering.js b/test/js/node/test/parallel/test-timers-ordering.js new file mode 100644 index 0000000000..b8e23aa2d9 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-ordering.js @@ -0,0 +1,57 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Flags: --expose-internals + +'use strict'; +require('../common'); +const assert = require('assert'); +let binding; +if (typeof Bun === 'undefined') { + const { internalBinding } = require('internal/test/binding'); + binding = internalBinding('timers'); +} else { + binding = { getLibuvNow: require('bun:internal-for-testing').timerInternals.timerClockMs }; +} + +const N = 30; + +let last_i = 0; +let last_ts = 0; + +function f(i) { + if (i <= N) { + // check order + assert.strictEqual(i, last_i + 1, `order is broken: ${i} != ${last_i} + 1`); + last_i = i; + + // Check that this iteration is fired at least 1ms later than the previous + // We need to use the binding as the receiver for fast API calls. + const now = binding.getLibuvNow(); + assert(now >= last_ts + 1, + `current ts ${now} < prev ts ${last_ts} + 1`); + last_ts = now; + + // Schedule next iteration + setTimeout(f, 1, i + 1); + } +} +setTimeout(f, 1, 1); diff --git a/test/js/node/test/parallel/test-timers-promises.js b/test/js/node/test/parallel/test-timers-promises.js new file mode 100644 index 0000000000..4df8d74447 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-promises.js @@ -0,0 +1,9 @@ +'use strict'; + +const common = require('../common'); + +const timer = require('node:timers'); +const timerPromises = require('node:timers/promises'); +const assert = require('assert'); + +assert.deepStrictEqual(timerPromises, timer.promises); diff --git a/test/js/node/test/parallel/test-timers-refresh.js b/test/js/node/test/parallel/test-timers-refresh.js new file mode 100644 index 0000000000..f7fdb723f1 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-refresh.js @@ -0,0 +1,73 @@ +'use strict'; + +const common = require('../common'); + +const { strictEqual, throws } = require('assert'); + +// Schedule the unrefed cases first so that the later case keeps the event loop +// active. + +// Every case in this test relies on implicit sorting within either Node's or +// libuv's timers storage data structures. + +// unref()'d timer +{ + let called = false; + const timer = setTimeout(common.mustCall(() => { + called = true; + }), 1); + timer.unref(); + + // This relies on implicit timers handle sorting within libuv. + + setTimeout(common.mustCall(() => { + strictEqual(called, false, 'unref()\'d timer returned before check'); + }), 1); + + strictEqual(timer.refresh(), timer); +} + +// regular timer +{ + let called = false; + const timer = setTimeout(common.mustCall(() => { + called = true; + }), 1); + + setTimeout(common.mustCall(() => { + strictEqual(called, false, 'pooled timer returned before check'); + }), 1); + + strictEqual(timer.refresh(), timer); +} + +// regular timer +{ + let called = false; + const timer = setTimeout(common.mustCall(() => { + if (!called) { + called = true; + process.nextTick(common.mustCall(() => { + timer.refresh(); + strictEqual(timer.hasRef(), true); + })); + } + }, 2), 1); +} + +// interval +{ + let called = 0; + const timer = setInterval(common.mustCall(() => { + called += 1; + if (called === 2) { + clearInterval(timer); + } + }, 2), 1); + + setTimeout(common.mustCall(() => { + strictEqual(called, 0, 'pooled timer returned before check'); + }), 1); + + strictEqual(timer.refresh(), timer); +} diff --git a/test/js/node/test/parallel/test-timers-throw-when-cb-not-function.js b/test/js/node/test/parallel/test-timers-throw-when-cb-not-function.js new file mode 100644 index 0000000000..f7432af233 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-throw-when-cb-not-function.js @@ -0,0 +1,49 @@ +'use strict'; +require('../common'); +const assert = require('assert'); + +function doSetTimeout(callback, after) { + return function() { + setTimeout(callback, after); + }; +} + +const errMessage = { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError' +}; + +assert.throws(doSetTimeout('foo'), errMessage); +assert.throws(doSetTimeout({ foo: 'bar' }), errMessage); +assert.throws(doSetTimeout(), errMessage); +assert.throws(doSetTimeout(undefined, 0), errMessage); +assert.throws(doSetTimeout(null, 0), errMessage); +assert.throws(doSetTimeout(false, 0), errMessage); + + +function doSetInterval(callback, after) { + return function() { + setInterval(callback, after); + }; +} + +assert.throws(doSetInterval('foo'), errMessage); +assert.throws(doSetInterval({ foo: 'bar' }), errMessage); +assert.throws(doSetInterval(), errMessage); +assert.throws(doSetInterval(undefined, 0), errMessage); +assert.throws(doSetInterval(null, 0), errMessage); +assert.throws(doSetInterval(false, 0), errMessage); + + +function doSetImmediate(callback, after) { + return function() { + setImmediate(callback, after); + }; +} + +assert.throws(doSetImmediate('foo'), errMessage); +assert.throws(doSetImmediate({ foo: 'bar' }), errMessage); +assert.throws(doSetImmediate(), errMessage); +assert.throws(doSetImmediate(undefined, 0), errMessage); +assert.throws(doSetImmediate(null, 0), errMessage); +assert.throws(doSetImmediate(false, 0), errMessage); diff --git a/test/js/node/test/parallel/test-timers-timeout-promisified.js b/test/js/node/test/parallel/test-timers-timeout-promisified.js new file mode 100644 index 0000000000..b3d781859a --- /dev/null +++ b/test/js/node/test/parallel/test-timers-timeout-promisified.js @@ -0,0 +1,96 @@ +// Flags: --no-warnings +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const timers = require('timers'); +const { promisify } = require('util'); + +const { getEventListeners } = require('events'); + +const timerPromises = require('timers/promises'); + +const setPromiseTimeout = promisify(timers.setTimeout); + +assert.strictEqual(setPromiseTimeout, timerPromises.setTimeout); + +process.on('multipleResolves', common.mustNotCall()); + +{ + const promise = setPromiseTimeout(1); + promise.then(common.mustCall((value) => { + assert.strictEqual(value, undefined); + })); +} + +{ + const promise = setPromiseTimeout(1, 'foobar'); + promise.then(common.mustCall((value) => { + assert.strictEqual(value, 'foobar'); + })); +} + +{ + const ac = new AbortController(); + const signal = ac.signal; + assert.rejects(setPromiseTimeout(10, undefined, { signal }), /AbortError/) + .then(common.mustCall()); + ac.abort(); +} + +{ + const signal = AbortSignal.abort(); // Abort in advance + assert.rejects(setPromiseTimeout(10, undefined, { signal }), /AbortError/) + .then(common.mustCall()); +} + +{ + // Check that aborting after resolve will not reject. + const ac = new AbortController(); + const signal = ac.signal; + setPromiseTimeout(10, undefined, { signal }) + .then(common.mustCall(() => { ac.abort(); })) + .then(common.mustCall()); +} + +{ + // Check that timer adding signals does not leak handlers + const ac = new AbortController(); + const signal = ac.signal; + setPromiseTimeout(0, null, { signal }).finally(common.mustCall(() => { + assert.strictEqual(getEventListeners(signal, 'abort').length, 0); + })); + assert.strictEqual(getEventListeners(signal, 'abort').length, 1); +} + +{ + for (const delay of ['', false]) { + assert.rejects(() => setPromiseTimeout(delay, null, {}), /ERR_INVALID_ARG_TYPE/).then(common.mustCall()); + } + + for (const options of [1, '', false, Infinity]) { + assert.rejects(() => setPromiseTimeout(10, null, options), /ERR_INVALID_ARG_TYPE/).then(common.mustCall()); + } + + for (const signal of [1, '', false, Infinity, null, {}]) { + assert.rejects(() => setPromiseTimeout(10, null, { signal }), /ERR_INVALID_ARG_TYPE/).then(common.mustCall()); + } + + for (const ref of [1, '', Infinity, null, {}]) { + assert.rejects(() => setPromiseTimeout(10, null, { ref }), /ERR_INVALID_ARG_TYPE/).then(common.mustCall()); + } +} + +{ + common.spawnPromisified(process.execPath, ['-p', 'const assert = require(\'assert\');' + + 'require(\'timers/promises\').setTimeout(1000, null, { ref: false }).' + + 'then(assert.fail)']).then(common.mustCall(({ stderr }) => { + assert.strictEqual(stderr, ''); + })); +} + +(async () => { + const signal = AbortSignal.abort('boom'); + await assert.rejects(timerPromises.setTimeout(1, undefined, { signal }), { + cause: 'boom', + }); +})().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-timers-to-primitive.js b/test/js/node/test/parallel/test-timers-to-primitive.js new file mode 100644 index 0000000000..65f11b9148 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-to-primitive.js @@ -0,0 +1,29 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +[ + setTimeout(common.mustNotCall(), 1), + setInterval(common.mustNotCall(), 1), +].forEach((timeout) => { + assert.strictEqual(Number.isNaN(+timeout), false); + assert.strictEqual(+timeout, timeout[Symbol.toPrimitive]()); + assert.strictEqual(`${timeout}`, timeout[Symbol.toPrimitive]().toString()); + assert.deepStrictEqual(Object.keys({ [timeout]: timeout }), [`${timeout}`]); + clearTimeout(+timeout); +}); + +{ + // Check that clearTimeout works with number id. + const timeout = setTimeout(common.mustNotCall(), 1); + const id = +timeout; + clearTimeout(id); +} + +{ + // Check that clearTimeout works with string id. + const timeout = setTimeout(common.mustNotCall(), 1); + const id = `${timeout}`; + clearTimeout(id); +} diff --git a/test/js/node/test/parallel/test-timers-unref.js b/test/js/node/test/parallel/test-timers-unref.js new file mode 100644 index 0000000000..34a661c761 --- /dev/null +++ b/test/js/node/test/parallel/test-timers-unref.js @@ -0,0 +1,81 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; + +const common = require('../common'); + +const assert = require('assert'); + +let unref_interval = false; +let unref_timer = false; +let checks = 0; + +const LONG_TIME = 10 * 1000; +const SHORT_TIME = 100; + +const timer = setTimeout(() => {}, 10); +assert.strictEqual(timer.hasRef(), true); +// Should not throw. +timer.unref().ref().unref(); +assert.strictEqual(timer.hasRef(), false); + +setInterval(() => {}, 10).unref().ref().unref(); + +setInterval(common.mustNotCall('Interval should not fire'), LONG_TIME).unref(); +setTimeout(common.mustNotCall('Timer should not fire'), LONG_TIME).unref(); + +const interval = setInterval(common.mustCall(() => { + unref_interval = true; + clearInterval(interval); +}), SHORT_TIME); +interval.unref(); + +setTimeout(common.mustCall(() => { + unref_timer = true; +}), SHORT_TIME).unref(); + +const check_unref = setInterval(() => { + if (checks > 5 || (unref_interval && unref_timer)) + clearInterval(check_unref); + checks += 1; +}, 100); + +{ + const timeout = + setTimeout(common.mustCall(() => { + timeout.unref(); + }), SHORT_TIME); +} + +{ + // Should not timeout the test + const timeout = + setInterval(() => timeout.unref(), SHORT_TIME); +} + +// Should not assert on args.This()->InternalFieldCount() > 0. +// See https://github.com/nodejs/node-v0.x-archive/issues/4261. +{ + const t = setInterval(() => {}, 1); + process.nextTick(t.unref.bind({})); + process.nextTick(t.unref.bind(t)); +} diff --git a/test/js/node/test/parallel/test-timers-unrefed-in-callback.js b/test/js/node/test/parallel/test-timers-unrefed-in-callback.js new file mode 100644 index 0000000000..afeb3706df --- /dev/null +++ b/test/js/node/test/parallel/test-timers-unrefed-in-callback.js @@ -0,0 +1,56 @@ +'use strict'; +// Checks that setInterval timers keep running even when they're +// unrefed within their callback. + +const common = require('../common'); +const net = require('net'); + +let counter1 = 0; +let counter2 = 0; + +// Test1 checks that clearInterval works as expected for a timer +// unrefed within its callback: it removes the timer and its callback +// is not called anymore. Note that the only reason why this test is +// robust is that: +// 1. the repeated timer it creates has a delay of 1ms +// 2. when this test is completed, another test starts that creates a +// new repeated timer with the same delay (1ms) +// 3. because of the way timers are implemented in libuv, if two +// repeated timers A and B are created in that order with the same +// delay, it is guaranteed that the first occurrence of timer A +// will fire before the first occurrence of timer B +// 4. as a result, when the timer created by Test2 fired 11 times, if +// the timer created by Test1 hadn't been removed by clearInterval, +// it would have fired 11 more times, and the assertion in the +// process'exit event handler would fail. +function Test1() { + // Server only for maintaining event loop + const server = net.createServer().listen(0); + + const timer1 = setInterval(common.mustCall(() => { + timer1.unref(); + if (counter1++ === 3) { + clearInterval(timer1); + server.close(() => { + Test2(); + }); + } + }, 4), 1); +} + + +// Test2 checks setInterval continues even if it is unrefed within +// timer callback. counter2 continues to be incremented more than 11 +// until server close completed. +function Test2() { + // Server only for maintaining event loop + const server = net.createServer().listen(0); + + const timer2 = setInterval(() => { + timer2.unref(); + if (counter2++ === 3) + server.close(); + }, 1); +} + +Test1(); diff --git a/test/js/node/test/parallel/test-timers.js b/test/js/node/test/parallel/test-timers.js new file mode 100644 index 0000000000..11c6e106e8 --- /dev/null +++ b/test/js/node/test/parallel/test-timers.js @@ -0,0 +1,86 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const inputs = [ + undefined, + null, + true, + false, + '', + [], + {}, + NaN, + +Infinity, + -Infinity, + (1.0 / 0.0), // sanity check + parseFloat('x'), // NaN + -10, + -1, + -0.5, + -0.1, + -0.0, + 0, + 0.0, + 0.1, + 0.5, + 1, + 1.0, + 2147483648, // Browser behavior: timeouts > 2^31-1 run on next tick + 12345678901234, // ditto +]; + +const timeouts = []; +const intervals = []; + +inputs.forEach((value, index) => { + setTimeout(() => { + timeouts[index] = true; + }, value); + + const handle = setInterval(() => { + clearInterval(handle); // Disarm timer or we'll never finish + intervals[index] = true; + }, value); +}); + +// All values in inputs array coerce to 1 ms. Therefore, they should all run +// before a timer set here for 2 ms. + +setTimeout(common.mustCall(() => { + // Assert that all other timers have run + inputs.forEach((value, index) => { + assert(timeouts[index]); + assert(intervals[index]); + }); +}), 2); + +// Test 10 ms timeout separately. +setTimeout(common.mustCall(), 10); +setInterval(common.mustCall(function() { clearInterval(this); }), 10); + +// Test no timeout separately +setTimeout(common.mustCall()); +// eslint-disable-next-line no-restricted-syntax +setInterval(common.mustCall(function() { clearInterval(this); })); diff --git a/test/js/node/timers/node-timers.test.ts b/test/js/node/timers/node-timers.test.ts index 912365986a..d09adad485 100644 --- a/test/js/node/timers/node-timers.test.ts +++ b/test/js/node/timers/node-timers.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it, test } from "bun:test"; -import { clearInterval, clearTimeout, promises, setInterval, setTimeout } from "node:timers"; +import { describe, expect, it, test, mock } from "bun:test"; +import { clearInterval, clearTimeout, promises, setInterval, setTimeout, setImmediate } from "node:timers"; import { promisify } from "util"; +import { bunEnv, bunExe } from "harness"; +import jsc from "bun:jsc"; +import path from "node:path"; for (const fn of [setTimeout, setInterval]) { describe(fn.name, () => { @@ -57,3 +60,183 @@ it("timers.promises === timers/promises", async () => { const ns = await import("node:timers/promises"); expect(ns.default).toBe(promises); }); + +type TimerWithDestroyed = Timer & { _destroyed: boolean }; + +describe("_destroyed", () => { + it("is false by default", () => { + const timers = [ + setTimeout(() => {}, 0), + setInterval(() => {}, 0), + setImmediate(() => {}), + ] as Array; + for (const t of timers) { + expect(t._destroyed).toBeFalse(); + } + clearTimeout(timers[0]); + clearInterval(timers[1]); + clearImmediate(timers[2]); + }); + + it("is false during the callback", async () => { + for (const fn of [setTimeout, setInterval, setImmediate]) { + const { promise: done, resolve } = Promise.withResolvers(); + const timer = fn(() => { + try { + expect(timer._destroyed).toBeFalse(); + } finally { + resolve(); + // make sure we don't make an interval that runs forever + clearInterval(timer); + } + }, 1) as TimerWithDestroyed; + await done; + } + }); + + it("is true after clearing", () => { + const timeout = setTimeout(() => {}, 0) as TimerWithDestroyed; + clearTimeout(timeout); + expect(timeout._destroyed).toBeTrue(); + + const interval = setInterval(() => {}, 0) as TimerWithDestroyed; + clearInterval(interval); + expect(interval._destroyed).toBeTrue(); + + const immediate = setImmediate(() => {}) as TimerWithDestroyed; + clearImmediate(immediate); + expect(immediate._destroyed).toBeTrue(); + }); + + it("is true after clearing during the callback", async () => { + for (const [setFn, clearFn] of [ + [setTimeout, clearTimeout], + [setInterval, clearInterval], + [setImmediate, clearImmediate], + ] as unknown as Array< + [(cb: () => void, time: number) => TimerWithDestroyed, (timer: TimerWithDestroyed) => void] + >) { + const { promise: done, resolve } = Promise.withResolvers(); + const timer = setFn(() => { + try { + clearFn(timer); + expect(timer._destroyed).toBeTrue(); + } finally { + resolve(); + } + }, 1); + await done; + } + }); + + it("is true after firing", async () => { + let calls = 0; + const timeout = setTimeout(() => calls++, 0) as TimerWithDestroyed; + const immediate = setImmediate(() => calls++) as TimerWithDestroyed; + while (calls < 2) await Bun.sleep(1); + expect(timeout._destroyed).toBeTrue(); + expect(immediate._destroyed).toBeTrue(); + }); + + it("is false when timer refreshes", async () => { + let refreshed = false; + const { promise: done, resolve } = Promise.withResolvers(); + const timeout = setTimeout(() => { + if (!refreshed) { + refreshed = true; + timeout.refresh(); + setImmediate(() => expect(timeout._destroyed).toBeFalse()); + } else { + resolve(); + } + }, 2) as TimerWithDestroyed; + await done; + expect(timeout._destroyed).toBeTrue(); + }); +}); + +describe("clear", () => { + it("can clear the other kind of timer", async () => { + const timeout1 = setTimeout(() => { + throw new Error("timeout not cleared"); + }, 1); + const interval1 = setInterval(() => { + throw new Error("interval not cleared"); + }, 1); + // TODO: this may become wrong once https://github.com/nodejs/node/pull/57069 is merged + const timeout2 = setTimeout(() => { + throw new Error("timeout not cleared"); + }, 1); + const interval2 = setInterval(() => { + throw new Error("interval not cleared"); + }, 1); + clearInterval(timeout1); + clearTimeout(interval1); + clearImmediate(timeout2); + clearImmediate(interval2); + }); + + it("interval/timeout do not affect immediates", async () => { + const mockedCb = mock(); + const immediate = setImmediate(mockedCb); + clearTimeout(immediate); + clearInterval(immediate); + + await Bun.sleep(1); + expect(mockedCb).toHaveBeenCalledTimes(1); + }); + + it("accepts a string", async () => { + const timeout = setTimeout(() => { + throw new Error("timeout not cleared"); + }, 1); + clearTimeout((+timeout).toString()); + }); + + it("rejects malformed strings", async () => { + const mockedCb = mock(); + const timeout = setTimeout(mockedCb, 1); + const stringId = (+timeout).toString(); + + for (const badString of [" " + stringId, stringId + " ", "0" + stringId, "+" + stringId]) { + clearTimeout(badString); + } + + // make sure we can't cause integer overflow + clearTimeout((2 ** 64).toString()); + + // none of the above strings should cause the timeout to be cleared + await Bun.sleep(2); + expect(mockedCb).toHaveBeenCalled(); + }); + + it("accepts UTF-16 strings", async () => { + const timeout = setTimeout(() => { + throw new Error("timeout not cleared"); + }, 1); + const stringId = (+timeout).toString(); + // make a version of stringId that has the same text content, but is encoded as UTF-16 + // instead of Latin-1 + const codeUnits = new DataView(new ArrayBuffer(2 * stringId.length)); + for (let i = 0; i < stringId.length; i++) { + codeUnits.setUint16(2 * i, stringId.charCodeAt(i), true); + } + const decoder = new TextDecoder("utf-16le"); + const stringIdUtf16 = decoder.decode(codeUnits); + // make sure we succeeded in making a UTF-16 string + expect(jsc.jscDescribe(stringIdUtf16)).toContain("8Bit:(0)"); + clearTimeout(stringIdUtf16); + }); +}); + +describe("setImmediate", () => { + it("has reasonable performance when nested with no other timers running", async () => { + const process = Bun.spawn({ + cmd: [bunExe(), path.join(__dirname, "setImmediate-fixture.ts")], + stdout: "pipe", + env: bunEnv, + }); + + expect(await new Response(process.stdout).text()).toBe("callback\n".repeat(5000)); + }, 5000); +}); diff --git a/test/js/node/timers/setImmediate-fixture.ts b/test/js/node/timers/setImmediate-fixture.ts new file mode 100644 index 0000000000..ec2f9eff70 --- /dev/null +++ b/test/js/node/timers/setImmediate-fixture.ts @@ -0,0 +1,6 @@ +let i = 0; +setImmediate(function callback() { + i++; + console.log("callback"); + if (i < 5000) setImmediate(callback); +});