diff --git a/src/bun.js/api/bun.zig b/src/bun.js/api/bun.zig index c6250a47c2..4ae5c34103 100644 --- a/src/bun.js/api/bun.zig +++ b/src/bun.js/api/bun.zig @@ -3630,7 +3630,6 @@ pub const Timer = struct { var map = vm.timer.maps.get(kind); // setImmediate(foo) - // setTimeout(foo, 0) if (kind == .setTimeout and interval == 0) { var cb: CallbackJob = .{ .callback = JSC.Strong.create(callback, globalThis), @@ -3651,7 +3650,7 @@ pub const Timer = struct { job.task = CallbackJob.Task.init(job); job.ref.ref(vm); - vm.enqueueTask(JSC.Task.init(&job.task)); + vm.enqueueImmediateTask(JSC.Task.init(&job.task)); if (vm.isInspectorEnabled()) { Debugger.didScheduleAsyncCall(globalThis, .DOMTimer, Timeout.ID.asyncID(.{ .id = id, .kind = kind }), !repeat); } @@ -3693,6 +3692,31 @@ pub const Timer = struct { ); } + pub fn setImmediate( + globalThis: *JSGlobalObject, + callback: JSValue, + arguments: JSValue, + ) callconv(.C) JSValue { + JSC.markBinding(@src()); + const id = globalThis.bunVM().timer.last_id; + globalThis.bunVM().timer.last_id +%= 1; + + const interval: i32 = 0; + + const wrappedCallback = callback.withAsyncContextIfNeeded(globalThis); + + Timer.set(id, globalThis, wrappedCallback, interval, arguments, false) catch + return JSValue.jsUndefined(); + + return TimerObject.init(globalThis, id, .setTimeout, interval, wrappedCallback, arguments); + } + + comptime { + if (!JSC.is_bindgen) { + @export(setImmediate, .{ .name = "Bun__Timer__setImmediate" }); + } + } + pub fn setTimeout( globalThis: *JSGlobalObject, callback: JSValue, @@ -3705,7 +3729,8 @@ pub const Timer = struct { const interval: i32 = @max( countdown.coerce(i32, globalThis), - 0, + // It must be 1 at minimum or setTimeout(cb, 0) will seemingly hang + 1, ); const wrappedCallback = callback.withAsyncContextIfNeeded(globalThis); diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index 15f5e9fd4a..ee55f78636 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -2207,7 +2207,8 @@ static inline EncodedJSValue functionPerformanceNowBody(JSGlobalObject* globalOb return JSValue::encode(jsDoubleNumber(result)); } -static inline EncodedJSValue functionPerformanceGetEntriesByNameBody(JSGlobalObject* globalObject) { +static inline EncodedJSValue functionPerformanceGetEntriesByNameBody(JSGlobalObject* globalObject) +{ auto& vm = globalObject->vm(); auto* global = reinterpret_cast(globalObject); auto* array = JSC::constructEmptyArray(globalObject, nullptr); @@ -2297,7 +2298,6 @@ JSC_DEFINE_HOST_FUNCTION(functionPerformanceNow, (JSGlobalObject * globalObject, return functionPerformanceNowBody(globalObject); } - JSC_DEFINE_HOST_FUNCTION(functionPerformanceGetEntriesByName, (JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { return functionPerformanceGetEntriesByNameBody(globalObject); @@ -3438,8 +3438,8 @@ JSC_DEFINE_CUSTOM_GETTER(BunCommonJSModule_getter, (JSGlobalObject * globalObjec } return JSValue::encode(returnValue); } -// This implementation works the same as setTimeout(myFunction, 0) -// TODO: make it more efficient + +extern "C" JSC__JSValue Bun__Timer__setImmediate(JSC__JSGlobalObject* arg0, JSC__JSValue JSValue1, JSC__JSValue JSValue3); // https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate JSC_DEFINE_HOST_FUNCTION(functionSetImmediate, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) @@ -3480,7 +3480,7 @@ JSC_DEFINE_HOST_FUNCTION(functionSetImmediate, } arguments = JSValue(argumentsArray); } - return Bun__Timer__setTimeout(globalObject, JSC::JSValue::encode(job), JSC::JSValue::encode(jsNumber(0)), JSValue::encode(arguments)); + return Bun__Timer__setImmediate(globalObject, JSC::JSValue::encode(job), JSValue::encode(arguments)); } JSValue getEventSourceConstructor(VM& vm, JSObject* thisObject) diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index c41b05e150..7c444fbe71 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -608,6 +608,19 @@ comptime { pub const DeferredRepeatingTask = *const (fn (*anyopaque) bool); pub const EventLoop = struct { tasks: if (JSC.is_bindgen) void else Queue = undefined, + + /// setImmediate() gets it's own two task queues + /// When you call `setImmediate` in JS, it queues to the start of the next tick + /// This is confusing, but that is how it works in Node.js. + /// + /// So we have two queues: + /// - next_immediate_tasks: tasks that will run on the next tick + /// - immediate_tasks: tasks that will run on the current tick + /// + /// Having two queues avoids infinite loops creating by calling `setImmediate` in a `setImmediate` callback. + immediate_tasks: Queue = undefined, + next_immediate_tasks: Queue = undefined, + concurrent_tasks: ConcurrentTask.Queue = ConcurrentTask.Queue{}, global: *JSGlobalObject = undefined, virtual_machine: *JSC.VirtualMachine = undefined, @@ -670,11 +683,11 @@ pub const EventLoop = struct { } } - pub fn tickWithCount(this: *EventLoop) u32 { + pub fn tickQueueWithCount(this: *EventLoop, comptime queue_name: []const u8) u32 { var global = this.global; var global_vm = global.vm(); var counter: usize = 0; - while (this.tasks.readItem()) |task| { + while (@field(this, queue_name).readItem()) |task| { defer counter += 1; switch (task.tag()) { .Microtask => { @@ -922,10 +935,18 @@ pub const EventLoop = struct { this.drainMicrotasksWithGlobal(global); } - this.tasks.head = if (this.tasks.count == 0) 0 else this.tasks.head; + @field(this, queue_name).head = if (@field(this, queue_name).count == 0) 0 else @field(this, queue_name).head; return @as(u32, @truncate(counter)); } + pub fn tickWithCount(this: *EventLoop) u32 { + return this.tickQueueWithCount("tasks"); + } + + pub fn tickImmediateTasks(this: *EventLoop) void { + _ = this.tickQueueWithCount("immediate_tasks"); + } + pub fn tickConcurrent(this: *EventLoop) void { _ = this.tickConcurrentWithCount(); } @@ -994,6 +1015,8 @@ pub const EventLoop = struct { ctx.onAfterEventLoop(); // this.afterUSocketsTick(); + } else { + this.flushImmediateQueue(); } } @@ -1016,8 +1039,25 @@ pub const EventLoop = struct { if (loop.num_polls > 0 or loop.active > 0) { this.processGCTimer(); loop.tickWithTimeout(timeoutMs, ctx.jsc); + this.flushImmediateQueue(); ctx.onAfterEventLoop(); // this.afterUSocketsTick(); + } else { + this.flushImmediateQueue(); + } + } + + pub fn flushImmediateQueue(this: *EventLoop) void { + // If we can get away with swapping the queues, do that rather than copying the data + if (this.immediate_tasks.count > 0) { + this.immediate_tasks.write(this.next_immediate_tasks.readableSlice(0)) catch unreachable; + this.next_immediate_tasks.head = 0; + this.next_immediate_tasks.count = 0; + } else if (this.next_immediate_tasks.count > 0) { + var prev_immediate = this.immediate_tasks; + var next_immediate = this.next_immediate_tasks; + this.immediate_tasks = next_immediate; + this.next_immediate_tasks = prev_immediate; } } @@ -1041,6 +1081,7 @@ pub const EventLoop = struct { this.processGCTimer(); loop.tick(ctx.jsc); + ctx.onAfterEventLoop(); this.tickConcurrent(); this.tick(); @@ -1064,8 +1105,11 @@ pub const EventLoop = struct { if (loop.active > 0) { this.processGCTimer(); loop.tick(ctx.jsc); + this.flushImmediateQueue(); ctx.onAfterEventLoop(); // this.afterUSocketsTick(); + } else { + this.flushImmediateQueue(); } } @@ -1078,7 +1122,7 @@ pub const EventLoop = struct { var ctx = this.virtual_machine; this.tickConcurrent(); - + this.tickImmediateTasks(); this.processGCTimer(); var global = ctx.global; @@ -1149,6 +1193,11 @@ pub const EventLoop = struct { this.tasks.writeItem(task) catch unreachable; } + pub fn enqueueImmediateTask(this: *EventLoop, task: Task) void { + JSC.markBinding(@src()); + this.next_immediate_tasks.writeItem(task) catch unreachable; + } + pub fn enqueueTaskWithTimeout(this: *EventLoop, task: Task, timeout: i32) void { // TODO: make this more efficient! var loop = this.virtual_machine.event_loop_handle orelse @panic("EventLoop.enqueueTaskWithTimeout: uSockets event loop is not initialized"); diff --git a/src/bun.js/javascript.zig b/src/bun.js/javascript.zig index 1c996039d8..65b918eee4 100644 --- a/src/bun.js/javascript.zig +++ b/src/bun.js/javascript.zig @@ -629,9 +629,9 @@ pub const VirtualMachine = struct { } pub fn isEventLoopAlive(vm: *const VirtualMachine) bool { - return vm.active_tasks > 0 or - vm.event_loop_handle.?.active > 0 or - vm.event_loop.tasks.count > 0; + return vm.active_tasks + + @as(usize, vm.event_loop_handle.?.active) + + vm.event_loop.tasks.count + vm.event_loop.immediate_tasks.count + vm.event_loop.next_immediate_tasks.count > 0; } const SourceMapHandlerGetter = struct { @@ -1008,6 +1008,10 @@ pub const VirtualMachine = struct { this.eventLoop().enqueueTask(task); } + pub inline fn enqueueImmediateTask(this: *VirtualMachine, task: Task) void { + this.eventLoop().enqueueImmediateTask(task); + } + pub inline fn enqueueTaskConcurrent(this: *VirtualMachine, task: *JSC.ConcurrentTask) void { this.eventLoop().enqueueTaskConcurrent(task); } @@ -1046,6 +1050,8 @@ pub const VirtualMachine = struct { if (!this.has_enabled_macro_mode) { this.has_enabled_macro_mode = true; this.macro_event_loop.tasks = EventLoop.Queue.init(default_allocator); + this.macro_event_loop.immediate_tasks = EventLoop.Queue.init(default_allocator); + this.macro_event_loop.next_immediate_tasks = EventLoop.Queue.init(default_allocator); this.macro_event_loop.tasks.ensureTotalCapacity(16) catch unreachable; this.macro_event_loop.global = this.global; this.macro_event_loop.virtual_machine = this; @@ -1137,6 +1143,12 @@ pub const VirtualMachine = struct { vm.regular_event_loop.tasks = EventLoop.Queue.init( default_allocator, ); + vm.regular_event_loop.immediate_tasks = EventLoop.Queue.init( + default_allocator, + ); + vm.regular_event_loop.next_immediate_tasks = EventLoop.Queue.init( + default_allocator, + ); vm.regular_event_loop.tasks.ensureUnusedCapacity(64) catch unreachable; vm.regular_event_loop.concurrent_tasks = .{}; vm.event_loop = &vm.regular_event_loop; @@ -1240,6 +1252,12 @@ pub const VirtualMachine = struct { vm.regular_event_loop.tasks = EventLoop.Queue.init( default_allocator, ); + vm.regular_event_loop.immediate_tasks = EventLoop.Queue.init( + default_allocator, + ); + vm.regular_event_loop.next_immediate_tasks = EventLoop.Queue.init( + default_allocator, + ); vm.regular_event_loop.tasks.ensureUnusedCapacity(64) catch unreachable; vm.regular_event_loop.concurrent_tasks = .{}; vm.event_loop = &vm.regular_event_loop; @@ -1372,6 +1390,12 @@ pub const VirtualMachine = struct { vm.regular_event_loop.tasks = EventLoop.Queue.init( default_allocator, ); + vm.regular_event_loop.immediate_tasks = EventLoop.Queue.init( + default_allocator, + ); + vm.regular_event_loop.next_immediate_tasks = EventLoop.Queue.init( + default_allocator, + ); vm.regular_event_loop.tasks.ensureUnusedCapacity(64) catch unreachable; vm.regular_event_loop.concurrent_tasks = .{}; vm.event_loop = &vm.regular_event_loop; diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 141d7ff253..0c28ea36ed 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -976,8 +976,10 @@ pub const TestCommand = struct { vm.eventLoop().tick(); var prev_unhandled_count = vm.unhandled_error_counter; - while (vm.active_tasks > 0) { - if (!jest.Jest.runner.?.has_pending_tests) jest.Jest.runner.?.drain(); + while (vm.active_tasks > 0) : (vm.eventLoop().flushImmediateQueue()) { + if (!jest.Jest.runner.?.has_pending_tests) { + jest.Jest.runner.?.drain(); + } vm.eventLoop().tick(); while (jest.Jest.runner.?.has_pending_tests) { @@ -991,6 +993,9 @@ pub const TestCommand = struct { prev_unhandled_count = vm.unhandled_error_counter; } } + + vm.eventLoop().flushImmediateQueue(); + switch (vm.aggressive_garbage_collection) { .none => {}, .mild => { diff --git a/test/harness.ts b/test/harness.ts index 77b4b4f43e..f6f3fb375b 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -48,7 +48,7 @@ export async function expectMaxObjectTypeCount( gc(true); for (const wait = 20; maxWait > 0; maxWait -= wait) { if (heapStats().objectTypeCounts[type] <= count) break; - await new Promise(resolve => setTimeout(resolve, wait)); + await Bun.sleep(wait); gc(); } expect(heapStats().objectTypeCounts[type]).toBeLessThanOrEqual(count); @@ -60,7 +60,7 @@ export function gcTick(trace = false) { trace && console.trace(""); // console.trace("hello"); gc(); - return new Promise(resolve => setTimeout(resolve, 0)); + return Bun.sleep(0); } export function withoutAggressiveGC(block: () => unknown) { diff --git a/test/js/web/timers/setImmediate.test.ts b/test/js/web/timers/setImmediate.test.ts new file mode 100644 index 0000000000..a52d315b09 --- /dev/null +++ b/test/js/web/timers/setImmediate.test.ts @@ -0,0 +1,24 @@ +import { test, expect } from "bun:test"; + +test("setImmediate doesn't block the event loop", async () => { + const incomingTimestamps = []; + var hasResponded = false; + var expectedTime = ""; + const server = Bun.serve({ + port: 0, + async fetch(req) { + await new Promise(resolve => setTimeout(resolve, 50).unref()); + function queuey() { + incomingTimestamps.push(Date.now()); + if (!hasResponded) setImmediate(queuey); + } + setImmediate(queuey); + return new Response((expectedTime = Date.now().toString(10))); + }, + }); + + const resp = await fetch(`http://${server.hostname}:${server.port}/`); + expect(await resp.text()).toBe(expectedTime); + hasResponded = true; + server.stop(true); +}); diff --git a/test/js/web/timers/setTimeout.test.js b/test/js/web/timers/setTimeout.test.js index eef6bbae09..9f898c8b56 100644 --- a/test/js/web/timers/setTimeout.test.js +++ b/test/js/web/timers/setTimeout.test.js @@ -67,6 +67,20 @@ it("clearTimeout", async () => { expect(called).toBe(false); }); +it("setImmediate runs after setTimeout cb", async () => { + var ranFirst = -1; + setTimeout(() => { + if (ranFirst === -1) ranFirst = 1; + }, 0); + setImmediate(() => { + if (ranFirst === -1) ranFirst = 0; + }); + + await Bun.sleep(5); + + expect(ranFirst).toBe(1); +}); + it("setTimeout(() => {}, 0)", async () => { var called = false; setTimeout(() => { @@ -80,10 +94,10 @@ it("setTimeout(() => {}, 0)", async () => { expect(called).toBe(true); var ranFirst = -1; setTimeout(() => { - if (ranFirst === -1) ranFirst = 1; + if (ranFirst === -1) ranFirst = 0; }, 1); setTimeout(() => { - if (ranFirst === -1) ranFirst = 0; + if (ranFirst === -1) ranFirst = 1; }, 0); await new Promise((resolve, reject) => { diff --git a/test/js/web/websocket/websocket.test.js b/test/js/web/websocket/websocket.test.js index 76ff16ecb3..604d398097 100644 --- a/test/js/web/websocket/websocket.test.js +++ b/test/js/web/websocket/websocket.test.js @@ -134,15 +134,13 @@ describe("WebSocket", () => { websocket: { open(ws) { ws.sendBinary(new Uint8Array([1, 2, 3])); - setTimeout(() => { - client.onmessage = ({ data }) => { - client.close(); - expect(Buffer.isBuffer(data)).toBe(true); - expect(data).toEqual(new Uint8Array([1, 2, 3])); - server.stop(true); - done(); - }; - }, 0); + client.onmessage = ({ data }) => { + client.close(); + expect(Buffer.isBuffer(data)).toBe(true); + expect(data).toEqual(new Uint8Array([1, 2, 3])); + server.stop(true); + done(); + }; }, }, });