Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,6 @@

- Keep ESM namespaces free of inherited `__esModule` markers and preserve the own marker and live exports for `require(esm)`, fixing Vite/tsx namespace interop. Adapts [oven-sh/bun#33894](https://github.com/oven-sh/bun/pull/33894) and [oven-sh/WebKit#279](https://github.com/oven-sh/WebKit/pull/279). Thanks @robobun!


- Sync oven-sh/bun through `c7b06d94bac19817ba34b6677bb1099fb4f6d2be`, preserving fork fixes and incorporating TLS handshake shutdown, macOS split-DNS failover, file-body cloning, Buffer write validation, mimalloc 3.5.3 and idle-memory release.
- Pin immutable [OpenClaw WebKit `42ab38d705`](https://github.com/openclaw/WebKit/releases/tag/autobuild-42ab38d705d4838748ccee77e7deb0e4e35515ee) by archive checksum, together with the required namespace facade integration from #106; retain fail-closed artifact selection.

Expand Down Expand Up @@ -244,3 +243,5 @@
- Avoid eager full bytecode generation on live `node:vm` cache hits while retaining post-GC promotion for payloads missing function bodies, preserving the cache budget and public cached-data generation.

- Return integral byte sizes in `node:inspector` heap allocation profiles while preserving allocation-site sampling and independent node/sample estimates.

- Run POSIX `setImmediate` callbacks after ready I/O and before due timers, including when task completions are queued, while preserving nested microtasks and task-created connection ordering. Ports and extends the event-loop changes from [oven-sh/bun#41355](https://github.com/oven-sh/bun/pull/41355); thanks @robobun!
2 changes: 2 additions & 0 deletions docs/runtime/nodejs-compat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ Upgrade requests emit `upgrade` while an earlier response on the connection is s

### [`node:timers`](https://nodejs.org/api/timers.html)

On POSIX, each event-loop turn runs ready I/O before `setImmediate` callbacks and due timers. An immediate queued by an I/O callback runs before timers that came due in that callback. An immediate queued by a timer lets the next poll deliver ready I/O first. Windows retains its libuv timer ordering.

🟢 Fully implemented. The exports are the same functions as the globals. `node:timers/promises` (including `scheduler.wait()` and `scheduler.yield()`) is also implemented.

### [`node:tty`](https://nodejs.org/api/tty.html)
Expand Down
46 changes: 31 additions & 15 deletions src/event_loop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ Built-in JSC microtask queue for promises and queueMicrotask.
│
▼
┌─────────────────────────────────────┐
│ 3. Drain regular task queue │ ← tickQueueWithCount()
│ 3. Poll ready I/O (POSIX) │ ← Only before the first queued task
│ Drain poll callback microtasks │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 4. Drain regular task queue │ ← tickQueueWithCount()
│ For each task: │
│ - Run task │
│ - Release weak refs │
Expand All @@ -63,7 +69,7 @@ Built-in JSC microtask queue for promises and queueMicrotask.
│
▼
┌─────────────────────────────────────┐
│ 4. Handle rejected promises │
│ 5. Handle rejected promises │
└─────────────────────────────────────┘
```

Expand All @@ -73,25 +79,25 @@ This is called when the event loop is active and needs to wait for I/O:

```
┌─────────────────────────────────────┐
│ 1. Tick immediate tasks │ ← setImmediate() callbacks
│ 1. Update date header timer │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 2. Update date header timer │
│ 2. Process GC timer │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 3. Process GC timer │
│ 3. Poll I/O via uSockets │ ← epoll_wait/kevent with timeout
│ (epoll_kqueue.c:251-320) │ (zero while immediates are queued)
│ - Dispatch ready polls │
│ - Each I/O event treated as task│
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 4. Poll I/O via uSockets │ ← epoll_wait/kevent with timeout
│ (epoll_kqueue.c:251-320) │
│ - Dispatch ready polls │
│ - Each I/O event treated as task│
│ 4. Tick immediate tasks (POSIX) │ ← setImmediate() callbacks
└──────────────┬──────────────────────┘
│
▼
Expand All @@ -110,6 +116,14 @@ This is called when the event loop is active and needs to wait for I/O:
└─────────────────────────────────────┘
```

Steps 3 to 5 follow Node's phase order: poll, check (`setImmediate`), timers. The timers run last, so the caller of `autoTick` sees what they did before the next poll.

Node runs thread-pool completions in the poll phase. Before its first queued task, Bun's POSIX `tick()` polls ready I/O without waiting and drains the poll callbacks' microtasks. Task refills do not poll again, so I/O started by a task belongs to the next poll. Before step 3, `autoTick` completes that task phase by running its immediates and timers. If one of them ran a callback, the next poll does not wait.

The pre-task poll runs only when no native tick is active (`tick_depth == 0`). A nested wait inside a native callback drains queued tasks without this extra poll, preserving the outer dispatcher's ready-event batch. The native tick owns that batch through its post-poll hook.

On Windows, libuv runs the timers inside the poll call (step 3). There, the immediates run before step 1, and steps 4 and 5 do not run.

## Task Draining Algorithm

### For Regular Tasks (`Task.rs:97-512`)
Expand Down Expand Up @@ -287,7 +301,7 @@ Timers are handled differently based on platform:
ctx.timer.drain_timers(ctx);
```

Timers are drained after I/O polling. Each timer callback:
Timers are drained after I/O polling and after the immediates. Each timer callback:

1. Is wrapped in `enter()`/`exit()`
2. Triggers microtask draining after execution
Expand All @@ -310,9 +324,11 @@ setImmediate(() => console.log("immediate"));

This is because:

- `setImmediate` runs in `tickImmediateTasks()` before I/O polling
- `setTimeout` fires after I/O polling (even with 0ms)
- However, this can vary based on timing and event loop state
- In one turn of the loop, `tickImmediateTasks()` runs the queued immediates after I/O polling
- The timers that are due run after them, in the same turn (a 0ms timer is due after 1ms)
- This is not a guarantee for code at the top level: it can vary with timing and event loop state

Inside an I/O callback the order is fixed, as in Node. An immediate that the callback queues runs before any timer, even a timer that came due while the callback ran.

## Enter/Exit Mechanism

Expand All @@ -338,8 +354,8 @@ This ensures microtasks are only drained once per top-level event loop task, eve

The Bun event loop processes work in this order:

1. **Immediate tasks** (setImmediate)
2. **I/O polling** (epoll/kqueue)
1. **I/O polling** (epoll/kqueue)
2. **Immediate tasks** (setImmediate; on Windows these run before the poll)
3. **Timer callbacks** (setTimeout/setInterval)
4. **Regular tasks** from the task queue
- For each task:
Expand Down
65 changes: 62 additions & 3 deletions src/jsc/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ pub struct EventLoop {
/// until the queue is empty (a task that re-posts itself there never lets
/// the loop poll). Promoted into `tasks` by `auto_tick`, like immediates.
pub yield_tasks: Vec<Task>,
/// A `tick()` ran a task since `auto_tick` last took this.
ran_tasks: bool,

pub concurrent_tasks: ConcurrentQueue,
/// Set only on Bun.spawnSync's isolated loop: how other threads reach *this*
Expand Down Expand Up @@ -120,6 +122,7 @@ impl Default for EventLoop {
immediate_tasks: Vec::new(),
next_immediate_tasks: Vec::new(),
yield_tasks: Vec::new(),
ran_tasks: false,
concurrent_tasks: ConcurrentQueue::default(),
isolated_poster: None,
global: None,
Expand Down Expand Up @@ -456,6 +459,15 @@ impl EventLoop {
self.drain_microtasks_with_global(global, jsc_vm)
}

/// `auto_tick`, after the poll. A callback that ran inside an entered scope
/// (a nested loop, such as a wait for a promise) did not drain the
/// microtask queue at its exit.
pub fn drain_microtasks_if_nested(&mut self) {
if self.entered_event_loop_count > 0 {
let _ = self.drain_microtasks();
}
}

// should be called after exit()
pub fn maybe_drain_microtasks(&mut self) -> Result<(), Stopped> {
if self.entered_event_loop_count == 0 && !self.vm_ref().is_inside_deferred_task_queue.get()
Expand Down Expand Up @@ -565,10 +577,51 @@ impl EventLoop {
/// turn is over (`tick()` / `tick_tasks_only()` return rather than run more against that VM).
fn tick_with_count(&mut self, virtual_machine: *mut VirtualMachine) -> Result<u32, Stopped> {
let mut counter: u32 = 0;
tick_queue_with_count(self, virtual_machine, &mut counter)?;
let result = tick_queue_with_count(self, virtual_machine, &mut counter);
if counter > 0 {
self.ran_tasks = true;
}
result?;
Ok(counter)
}

fn tick_poll_phase_tasks(
&mut self,
virtual_machine: *mut VirtualMachine,
first_batch: &mut bool,
) -> Result<u32, Stopped> {
if *first_batch && self.tasks.readable_length() > 0 {
*first_batch = false;
#[cfg(unix)]
{
// Sample ready I/O before callbacks can start new I/O. The check phase
// after these tasks may then run before those new operations complete.
let this = core::hint::black_box(core::ptr::from_mut(self));
// SAFETY: the initialized loop and its VM stay live across callbacks.
let loop_ = unsafe { (*this).usockets_loop() };
// A nested wait must not overwrite the native dispatcher's ready batch.
// SAFETY: only the owning thread reads or changes the native tick depth.
if unsafe { (*loop_).internal_loop_data.tick_depth } == 0 {
// Remove tick's task-drain scope during the poll, so separate I/O
// callbacks get their normal checkpoints. Keep any enclosing JS scope.
// SAFETY: tick() entered this scope before calling tick_turn().
unsafe { (*this).entered_event_loop_count -= 1 };
// SAFETY: no native tick is active; the zero timeout cannot park.
unsafe { (*loop_).tick_without_idle() };
let this = core::hint::black_box(this);
// SAFETY: restore tick's scope after the reentrant poll returns.
unsafe { (*this).entered_event_loop_count += 1 };
// SAFETY: the VM owns `this` and remains live after the poll.
unsafe { (*(*this).vm()).on_after_event_loop() };
// A poll inside `tick()` is nested, so callbacks defer their checkpoint.
// SAFETY: `this` is still the live per-thread event loop.
unsafe { (*this).drain_microtasks() }?;
}
}
}
self.tick_with_count(virtual_machine)
}

fn tick_concurrent(&mut self) {
let _ = self.tick_concurrent_with_count();
}
Expand Down Expand Up @@ -789,9 +842,10 @@ impl EventLoop {
let global_vm = self.vm_ref().jsc_vm();

let mut refills = 0u32;
let mut first_task_batch = true;
'tick: loop {
loop {
if self.tick_with_count(ctx)? == 0 {
if self.tick_poll_phase_tasks(ctx, &mut first_task_batch)? == 0 {
break;
}
if refills == Self::CONCURRENT_REFILLS_PER_TICK {
Expand Down Expand Up @@ -822,7 +876,7 @@ impl EventLoop {
}

while refills < Self::CONCURRENT_REFILLS_PER_TICK {
if self.tick_with_count(ctx)? == 0 {
if self.tick_poll_phase_tasks(ctx, &mut first_task_batch)? == 0 {
break;
}
refills += 1;
Expand Down Expand Up @@ -981,6 +1035,11 @@ impl EventLoop {
self.yield_tasks.push(task);
}

/// `auto_tick`: whether a `tick()` ran a task since the last call.
pub fn take_ran_tasks(&mut self) -> bool {
core::mem::take(&mut self.ran_tasks)
}

/// `auto_tick`, before it polls: last iteration's yielded tasks become
/// runnable. Returns whether there are any, so the poll does not block.
pub fn promote_yield_tasks(&mut self) -> bool {
Expand Down
Loading
Loading