diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index e8e36dc9d05b..066d2360f375 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -401,13 +401,28 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout if (!handed_off && will_idle_inside_event_loop) { static const uint64_t idle_sweep_interval_ns = 100 * 1000000ULL; static _Thread_local uint64_t last_idle_sweep_ns = 0; - const uint64_t sweep_now_ns = now_ns ? now_ns : us_internal_monotonic_ns(); + const uint64_t sweep_now_ns = now_ns ? now_ns : us_loop_monotonic_ns(); if (sweep_now_ns >= last_idle_sweep_ns + idle_sweep_interval_ns) { last_idle_sweep_ns = sweep_now_ns; mi_on_thread_idle(); } } + /* Measure time blocked in the event provider for + * performance.eventLoopUtilization(). Gate on the timeout only (like libuv), + * NOT on will_idle_inside_event_loop: a stale pending_wakeups (the wakeup + * eventfd is edge-triggered and consumed by the previous tick's dispatch, + * but pending_wakeups is only cleared by the exchange above) would make + * will_idle false while the provider still blocks, mis-attributing the wait + * to active. A zero-timeout poll-through is not idle. Publish the wait's + * start so a cross-thread reader can credit the in-progress wait to idle. + * Timestamped after the inline sweep above: that sweep is CPU work on this + * thread, not time blocked in the provider. */ + const int will_track_idle = !timeout || (timeout->tv_nsec != 0 || timeout->tv_sec != 0); + const uint64_t idle_start_ns = will_track_idle ? us_loop_monotonic_ns() : 0; + if (will_track_idle) + __atomic_store_n(&loop->data.idle_entry_ns, idle_start_ns, __ATOMIC_RELEASE); + /* Fetch ready polls */ #ifdef LIBUS_USE_EPOLL /* A zero timespec already has a fast path in ep_poll (fs/eventpoll.c): @@ -432,6 +447,21 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout if (handed_off) mi_on_thread_idle_end(); + if (will_track_idle) { + const uint64_t idle_end_ns = us_loop_monotonic_ns(); + /* Clear the in-progress marker, then credit the total. The credit is a + * release store and the reader loads idle_time_ns with acquire, so a + * reader that observes the credited total also observes the cleared + * marker (never both, which would double-count the just-finished wait). + * A reader landing between the two stores sees a slightly-low idle + * instead, which is acceptable. Release on the clear alone would not + * order the later credit, so on weak-memory targets the credit could + * become visible first without the acquire/release pairing below. */ + __atomic_store_n(&loop->data.idle_entry_ns, 0, __ATOMIC_RELEASE); + if (idle_end_ns > idle_start_ns) + __atomic_add_fetch(&loop->data.idle_time_ns, idle_end_ns - idle_start_ns, __ATOMIC_RELEASE); + } + us_internal_dispatch_ready_polls(loop); us_internal_drain_ready_polls(loop); us_internal_sweep_if_due(loop); diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 4d0fdca172f6..14981a24f876 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -162,6 +162,10 @@ struct us_loop_t *us_create_loop(void *hint, loop->uv_loop = hint ? hint : uv_loop_new(); loop->is_default = hint != 0; + // Required before uv_metrics_idle_time() accumulates anything; it backs + // performance.eventLoopUtilization() on the libuv (Windows) path. + uv_loop_configure(loop->uv_loop, UV_METRICS_IDLE_TIME); + loop->uv_pre = malloc(sizeof(uv_prepare_t)); uv_prepare_init(loop->uv_loop, loop->uv_pre); uv_prepare_start(loop->uv_pre, prepare_cb); diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index f8480ce5bdf6..b8ee6a277dcb 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -75,6 +75,13 @@ extern void __attribute__((__noreturn__)) Bun__panic(const char *message, size_t * allocations this library has no way to fail gracefully from. */ extern void __attribute__((__noreturn__)) Bun__outOfMemory(void); +/* Monotonic clock (ns): uv_hrtime under libuv, else CLOCK_MONOTONIC. Used for + * event-loop-utilization accounting and (POSIX) the sweep-timer deadlines, so + * anything comparing against a loop deadline must read it and not another. */ +uint64_t us_loop_monotonic_ns(void); +/* Sample the loop's accumulated idle time and active time (both ns). */ +void us_loop_event_loop_utilization(struct us_loop_t *loop, uint64_t *idle_ns_out, uint64_t *active_ns_out); + #ifdef _WIN32 #define IS_EINTR(rc) (rc == SOCKET_ERROR && WSAGetLastError() == WSAEINTR) #define LIBUS_ERR WSAGetLastError() @@ -150,9 +157,6 @@ void us_internal_timer_sweep(us_loop_r loop); void us_internal_enable_sweep_timer(struct us_loop_t *loop); void us_internal_disable_sweep_timer(struct us_loop_t *loop); #ifndef LIBUS_USE_LIBUV -/* CLOCK_MONOTONIC in ns. The clock every deadline on the loop is measured - * against, so anything comparing against one must read it and not another. */ -uint64_t us_internal_monotonic_ns(void); long long us_internal_sweep_timeout_ns(struct us_loop_t *loop); void us_internal_sweep_if_due(struct us_loop_t *loop); #endif diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 8e369f11df0b..2e1ae54f04d7 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -87,6 +87,22 @@ struct us_internal_loop_data_t { * sockets must be deferred to the outermost tick so the outer dispatch * doesn't read a freed poll. */ int tick_depth; + /* Monotonic timestamp (ns) captured when the loop was created; the origin + * for event-loop-utilization. Set once in us_internal_loop_data_init. */ + uint64_t creation_monotonic_ns; + /* Accumulated time (ns) the loop spent blocked in the event provider + * (epoll_pwait2 / kevent64). Written with __atomic_* by the owning thread + * in us_loop_run_bun_tick; read atomically by any thread (the parent reads + * a worker's counter for Worker.performance.eventLoopUtilization()). On + * Windows the idle time comes from uv_metrics_idle_time() instead, so this + * field is left at zero there. */ + uint64_t idle_time_ns; + /* Monotonic timestamp (ns) of an in-progress provider wait, or 0 when not + * waiting. Lets a cross-thread reader credit the currently-blocked wait to + * idle instead of active (mirrors libuv's provider_entry_time). Written + * with __atomic_* by the owning thread around the epoll/kevent syscall; + * unused on Windows (uv_metrics_idle_time already accounts for it). */ + uint64_t idle_entry_ns; }; #endif // LOOP_DATA_H diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 0a5fb727bed5..de85f1be8da5 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -30,6 +30,48 @@ #include #endif +uint64_t us_loop_monotonic_ns(void) { +#ifdef LIBUS_USE_LIBUV + return uv_hrtime(); +#else + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +#endif +} + +/* Fills *idle_ns_out / *active_ns_out with the loop's accumulated idle time and + * the active (non-idle) time since the loop was created. Thread-safe: the idle + * counter is read atomically and the creation timestamp is immutable, so the + * parent thread can sample a worker's loop. */ +void us_loop_event_loop_utilization(struct us_loop_t *loop, uint64_t *idle_ns_out, uint64_t *active_ns_out) { + if (!loop) { + *idle_ns_out = 0; + *active_ns_out = 0; + return; + } + uint64_t now_ns = us_loop_monotonic_ns(); +#ifdef LIBUS_USE_LIBUV + /* libuv folds any in-progress provider wait into this value itself. */ + uint64_t idle_ns = uv_metrics_idle_time(loop->uv_loop); +#else + /* Acquire pairs with the release credit in us_loop_run_bun_tick: if we see + * a just-credited idle_time_ns we also see the cleared idle_entry_ns below, + * so an in-progress wait is never counted twice. */ + uint64_t idle_ns = __atomic_load_n(&loop->data.idle_time_ns, __ATOMIC_ACQUIRE); + /* If the loop is blocked in the provider right now, credit the elapsed part + * of that wait to idle (it is not yet added to idle_time_ns). Without this a + * loop sampled mid-wait looks fully active. */ + uint64_t idle_entry_ns = __atomic_load_n(&loop->data.idle_entry_ns, __ATOMIC_ACQUIRE); + if (idle_entry_ns != 0 && now_ns > idle_entry_ns) + idle_ns += now_ns - idle_entry_ns; +#endif + uint64_t created_ns = loop->data.creation_monotonic_ns; + uint64_t elapsed_ns = (created_ns && now_ns > created_ns) ? (now_ns - created_ns) : 0; + *idle_ns_out = idle_ns; + *active_ns_out = elapsed_ns > idle_ns ? elapsed_ns - idle_ns : 0; +} + #if __has_include("wtf/Platform.h") #include "wtf/Platform.h" #elif !defined(ASSERT_ENABLED) @@ -72,16 +114,10 @@ void us_internal_disable_sweep_timer(struct us_loop_t *loop) { #define LIBUS_TIMEOUT_GRANULARITY_NS ((long long) LIBUS_TIMEOUT_GRANULARITY * 1000000000LL) -uint64_t us_internal_monotonic_ns(void) { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (uint64_t) ts.tv_sec * 1000000000ULL + (uint64_t) ts.tv_nsec; -} - void us_internal_enable_sweep_timer(struct us_loop_t *loop) { loop->data.sweep_timer_count++; if (loop->data.sweep_timer_count == 1) { - loop->data.sweep_next_tick_ns = (long long) us_internal_monotonic_ns() + LIBUS_TIMEOUT_GRANULARITY_NS; + loop->data.sweep_next_tick_ns = (long long) us_loop_monotonic_ns() + LIBUS_TIMEOUT_GRANULARITY_NS; Bun__internal_ensureDateHeaderTimerIsEnabled(loop); } } @@ -99,7 +135,7 @@ long long us_internal_sweep_timeout_ns(struct us_loop_t *loop) { } /* Its own reading, deliberately: this bounds the poll so the sweep is not * starved, and a caller's older reading would round the deadline up. */ - long long diff = loop->data.sweep_next_tick_ns - (long long) us_internal_monotonic_ns(); + long long diff = loop->data.sweep_next_tick_ns - (long long) us_loop_monotonic_ns(); return diff > 0 ? diff : 0; } @@ -107,7 +143,7 @@ void us_internal_sweep_if_due(struct us_loop_t *loop) { if (loop->data.sweep_next_tick_ns < 0) { return; } - long long now = (long long) us_internal_monotonic_ns(); + long long now = (long long) us_loop_monotonic_ns(); if (now < loop->data.sweep_next_tick_ns) { return; } @@ -122,6 +158,7 @@ void us_internal_sweep_if_due(struct us_loop_t *loop) { void us_internal_loop_data_init(struct us_loop_t *loop, void (*wakeup_cb)(struct us_loop_t *loop), void (*pre_cb)(struct us_loop_t *loop), void (*post_cb)(struct us_loop_t *loop)) { // We allocate with calloc, so we only need to initialize the specific fields in use. + loop->data.creation_monotonic_ns = us_loop_monotonic_ns(); #ifdef LIBUS_USE_LIBUV loop->data.sweep_timer = us_create_timer(loop, 1, 0); #else diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index ad88418760dd..25f1a1980dd9 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -37,19 +37,6 @@ function hideFromStack(...fns: Function[]) { } } -let warned: Set; -function warnNotImplementedOnce(feature: string, issue?: number) { - if (!warned) { - warned = new Set(); - } - - if (warned.has(feature)) { - return; - } - warned.add(feature); - console.warn(new NotImplementedError(feature, issue)); -} - let util: typeof import("node:util"); class ExceptionWithHostPort extends Error { errno: number; @@ -271,11 +258,37 @@ function makeNodeEntryList(entries) { // +// Shared by perf_hooks and worker_threads. `getRaw` returns the loop's total +// { idle, active } time in ms; the two optional arguments are prior ELU +// snapshots to diff against. Mirrors Node's internal/perf/event_loop_utilization. +function eventLoopUtilization(getRaw: () => { idle: number; active: number }, util1?, util2?) { + if (util2) { + const idle = util1.idle - util2.idle; + const active = util1.active - util2.active; + const total = idle + active; + return { idle, active, utilization: total === 0 ? 0 : active / total }; + } + + const { idle, active } = getRaw(); + if (!util1) { + const total = idle + active; + return { idle, active, utilization: total === 0 ? 0 : active / total }; + } + + // Clamp: a cross-thread prior sample taken mid-wait can exceed a later read + // that lands between the writer's entry-clear and idle-credit stores; a + // negative delta would otherwise surface as utilization > 1. + const idleDelta = Math.max(0, idle - util1.idle); + const activeDelta = Math.max(0, active - util1.active); + const total = idleDelta + activeDelta; + return { idle: idleDelta, active: activeDelta, utilization: total === 0 ? 0 : activeDelta / total }; +} + export default { NotImplementedError, throwNotImplemented, hideFromStack, - warnNotImplementedOnce, + eventLoopUtilization, ExceptionWithHostPort, NodeAggregateError, ConnResetException, diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index 23be4ec2364e..b34f515b80ff 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -1,5 +1,15 @@ // Hardcoded module "node:perf_hooks" -const { throwNotImplemented, kNodeEntryTypes, NodeEntryObserver } = require("internal/shared"); +const { + throwNotImplemented, + kNodeEntryTypes, + NodeEntryObserver, + eventLoopUtilization: computeEventLoopUtilization, +} = require("internal/shared"); + +const getEventLoopUtilizationRaw = $newRustFunction("event_loop.rs", "jsEventLoopUtilization", 0) as () => { + idle: number; + active: number; +}; const cppCreateHistogram = $newCppFunction("JSNodePerformanceHooksHistogram.cpp", "jsFunction_createHistogram", 3) as ( min: number, @@ -97,12 +107,8 @@ function createPerformanceNodeTiming() { return object; } -function eventLoopUtilization(_utilization1, _utilization2) { - return { - idle: 0, - active: 0, - utilization: 0, - }; +function eventLoopUtilization(utilization1, utilization2) { + return computeEventLoopUtilization(getEventLoopUtilizationRaw, utilization1, utilization2); } // PerformanceEntry is not a valid constructor, so we have to fake it. diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 7262da0f30f7..47466174a54f 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -7,7 +7,7 @@ const EventEmitter = require("node:events"); const { SafeMap } = require("internal/primordials"); const Readable = require("internal/streams/readable"); const Writable = require("internal/streams/writable"); -const { throwNotImplemented, warnNotImplementedOnce } = require("internal/shared"); +const { throwNotImplemented, eventLoopUtilization: computeEventLoopUtilization } = require("internal/shared"); const { validateString, validateObject, @@ -1146,16 +1146,23 @@ class Worker extends EventEmitter { } get performance() { - return (this.#performance ??= { - eventLoopUtilization() { - warnNotImplementedOnce("worker_threads.Worker.performance"); - return { - idle: 0, - active: 0, - utilization: 0, - }; - }, - }); + if (this.#performance === undefined) { + const getRaw = () => this.#worker.eventLoopUtilizationInternal(); + this.#performance = { + eventLoopUtilization(utilization1, utilization2) { + const raw = getRaw(); + // The native side returns { idle: 0, active: 0 } while the worker + // isn't running (not yet online, terminating, or exited). Match Node: + // return zeros and ignore the prior samples rather than producing + // negative deltas from `raw - utilization1`. + if (raw.idle === 0 && raw.active === 0) { + return { idle: 0, active: 0, utilization: 0 }; + } + return computeEventLoopUtilization(() => raw, utilization1, utilization2); + }, + }; + } + return this.#performance; } terminate(callback: unknown) { diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 0e94c9adb507..8ea76c1be6bf 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -88,6 +88,7 @@ static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_getHeapStatistics); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_startCpuProfileInternal); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_stopCpuProfileInternal); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_cpuUsageInternal); +static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_eventLoopUtilization); // Attributes @@ -447,6 +448,7 @@ static const HashTableValue JSWorkerPrototypeTableValues[] = { { "startCpuProfileInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_startCpuProfileInternal, 0 } }, { "stopCpuProfileInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_stopCpuProfileInternal, 0 } }, { "cpuUsageInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_cpuUsageInternal, 0 } }, + { "eventLoopUtilizationInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_eventLoopUtilization, 0 } }, }; const ClassInfo JSWorkerPrototype::s_info = { "Worker"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSWorkerPrototype) }; @@ -937,6 +939,23 @@ JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_getHeapSnapshot, (JSGlobalObj return IDLOperation::call(*lexicalGlobalObject, *callFrame, "getHeapSnapshot"); } +static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_eventLoopUtilizationBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + double idleMs = 0; + double activeMs = 0; + castedThis->wrapped().eventLoopUtilization(idleMs, activeMs); + auto* result = JSC::constructEmptyObject(lexicalGlobalObject, lexicalGlobalObject->objectPrototype(), 2); + result->putDirect(vm, JSC::Identifier::fromString(vm, "idle"_s), JSC::jsDoubleNumber(idleMs)); + result->putDirect(vm, JSC::Identifier::fromString(vm, "active"_s), JSC::jsDoubleNumber(activeMs)); + return JSValue::encode(result); +} + +JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_eventLoopUtilization, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "eventLoopUtilization"); +} + JSC::GCClient::IsoSubspace* JSWorker::subspaceForImpl(JSC::VM& vm) { return WebCore::subspaceForImpl( diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 142bd56baf62..8c8407a9e811 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -89,6 +89,11 @@ void WebWorker__notifyNeedTermination(void* worker); // worker.ref()/.unref() — toggle the keep-alive on the parent event loop. Parent thread only. void WebWorker__setRef(void* worker, bool ref); +// Sample the worker's event-loop idle/active time (ms) for +// worker.performance.eventLoopUtilization(). Parent thread only; synchronised +// against worker teardown in src/jsc/web_worker.rs. +void WebWorker__getEventLoopUtilization(void* worker, double* idleMs, double* activeMs); + // Release the keep-alive on the parent event loop. Called from the close task on the parent // thread. void WebWorker__releaseParentPollRef(void* worker); @@ -383,6 +388,20 @@ void Worker::setKeepAlive(bool keepAlive) WebWorker__setRef(impl_, keepAlive); } +void Worker::eventLoopUtilization(double& idleMs, double& activeMs) +{ + idleMs = 0; + activeMs = 0; + // Node reports zeros until 'online' fires (kIsOnline) and again once exit + // handling nulls kHandle; !isOnline() covers Pending and Closing/Closed. + // Between terminate() and the close task Node still reports real values, + // so a terminate request alone must not zero this; the Rust side reads + // under vm_lock and reports zeros once the VM is gone. + if (!impl_ || !isOnline()) + return; + WebWorker__getEventLoopUtilization(impl_, &idleMs, &activeMs); +} + void Worker::dispatchEvent(Event& event) { // Suppress user-visible events once terminate() has been called or the diff --git a/src/jsc/bindings/webcore/Worker.h b/src/jsc/bindings/webcore/Worker.h index 657597d84ec5..af33aa49968f 100644 --- a/src/jsc/bindings/webcore/Worker.h +++ b/src/jsc/bindings/webcore/Worker.h @@ -113,6 +113,9 @@ class Worker final : public ThreadSafeRefCounted, public EventTargetWith // -- Parent-thread API (called from JS on the owning thread) ------------- void terminate(); void setKeepAlive(bool); + // Sample the worker's event-loop idle/active time (ms). Writes zeros when + // the worker is not running. + void eventLoopUtilization(double& idleMs, double& activeMs); void dispatchEvent(Event&); // Returns true if the task was accepted (queued to Pending or posted to // Running). Returns false if the worker is Closing/Closed or its context diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index daae5d2a5284..4a99e5a18a12 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -1207,6 +1207,24 @@ impl EventLoop { } } +/// Backs perf_hooks `performance.eventLoopUtilization()`: returns the current +/// thread's event loop idle/active time in milliseconds. The JS side computes +/// the utilization ratio and the diff against prior samples. +#[bun_jsc::host_fn] +pub fn js_event_loop_utilization( + global_object: &JSGlobalObject, + _frame: &CallFrame, +) -> JsResult { + let vm_ref = global_object.bun_vm(); + let loop_ptr = vm_ref.event_loop_shared().usockets_loop(); + // SAFETY: usockets_loop() returns the current thread's live loop. + let elu = unsafe { uws::loop_event_loop_utilization(loop_ptr) }; + let result = JSValue::create_empty_object(global_object, 2); + result.put(global_object, b"idle", JSValue::js_number(elu.idle_ms)); + result.put(global_object, b"active", JSValue::js_number(elu.active_ms)); + Ok(result) +} + /// Testing API to expose event loop state #[bun_jsc::host_fn] pub fn get_active_tasks(global_object: &JSGlobalObject, _frame: &CallFrame) -> JsResult { diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index e94c6266d17b..58e24283c50f 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -67,6 +67,7 @@ use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use bun_core::{String as BunString, WTFStringImpl}; use bun_io::KeepAlive; use bun_threading::{Futex, Mutex}; +use bun_uws as uws; use crate::virtual_machine::{self, VirtualMachine, runtime_hooks}; use crate::{self as jsc, JSGlobalObject, JSValue, JsError, LogJsc}; @@ -141,6 +142,16 @@ pub struct WebWorker { vm: Cell<*mut VirtualMachine>, vm_lock: Mutex, + /// The worker's own uWS loop pointer, cached once at the `vm` publish point + /// so `worker.performance.eventLoopUtilization()` can read it from the + /// parent without touching `vm.event_loop_handle` / `vm.event_loop`. Those + /// fields are rewritten non-atomically on the worker thread (spawnSync's + /// temporary loop swap, `Bun.serve`), so a cross-thread read of them would + /// race; this pointer is the worker's real loop and never changes. Null + /// until published. Guarded by `vm_lock` like `vm`; valid to dereference + /// only while `vm` is non-null (both live in the arena freed at shutdown). + uws_loop_ptr: Cell<*mut uws::Loop>, + // ---- Parent-thread only ------------------------------------------------- /// Keep-alive on the parent's event loop. `Async.KeepAlive` is not /// thread-safe; it is reffed in `create()`, toggled by `setRef()` (JS @@ -560,6 +571,7 @@ impl WebWorker { requested_terminate: AtomicBool::new(false), vm: Cell::new(core::ptr::null_mut()), vm_lock: Mutex::new(), + uws_loop_ptr: Cell::new(core::ptr::null_mut()), parent_poll_ref: JsCell::new(KeepAlive::init()), status: Cell::new(Status::Start), arena: JsCell::new(None), @@ -669,6 +681,58 @@ impl WebWorker { }); } + /// Sample the worker's event-loop idle/active time (milliseconds) from the + /// parent thread, backing `worker.performance.eventLoopUtilization()`. + /// + /// Reads `uws_loop_ptr` (the worker's real loop, cached once at publish) + /// rather than `vm.event_loop_handle` / `vm.event_loop`, which the worker + /// thread rewrites non-atomically during `spawnSync`/`Bun.serve` and would + /// race a cross-thread read. `vm_lock` serialises against `shutdown()` + /// nulling `vm` before it frees the arena, so while `vm` is non-null the + /// cached loop (also in that arena) is alive for the C call. The idle + /// counter C reads is atomic and the creation timestamp is immutable, so no + /// `&VM` / `&EventLoop` is ever formed. Writes zeros before the loop exists + /// or after the worker exits (Node returns zeros until the loop starts). + /// + /// Takes `*mut` for the same reason as `set_ref`: the worker thread + /// concurrently dereferences this struct. + // C++-only FFI entry point; the out-params are validated by the C++ caller. + #[allow(clippy::not_unsafe_ptr_arg_deref)] + #[unsafe(export_name = "WebWorker__getEventLoopUtilization")] + pub extern "C" fn get_event_loop_utilization( + this: *mut WebWorker, + idle_ms_out: *mut f64, + active_ms_out: *mut f64, + ) { + // SAFETY: out-params are valid, writable pointers supplied by C++. + unsafe { + *idle_ms_out = 0.0; + *active_ms_out = 0.0; + } + // `this` is a valid heap allocation owned by C++ `WebCore::Worker`; + // parent-thread only. + let this = bun_ptr::ParentRef::from(NonNull::new(this).expect("WebWorker FFI ptr")); + this.vm_lock.lock(); + // `vm` non-null guarantees the arena (and the cached loop within it) is + // still alive; both are read under `vm_lock`. + let loop_ptr = if this.vm_ptr().is_null() { + core::ptr::null_mut() + } else { + this.uws_loop_ptr.get() + }; + if !loop_ptr.is_null() { + // SAFETY: the loop is kept alive by the held vm_lock; the C fn + // reads the idle counter atomically and an immutable timestamp. + let elu = unsafe { uws::loop_event_loop_utilization(loop_ptr) }; + // SAFETY: out-params validated above. + unsafe { + *idle_ms_out = elu.idle_ms; + *active_ms_out = elu.active_ms; + } + } + this.vm_lock.unlock(); + } + /// worker.terminate() from JS. Sets `requested_terminate`, interrupts /// running JS in the worker (TerminationException at the next safepoint), /// and wakes the worker loop so it observes the flag. `parent_poll_ref` @@ -964,8 +1028,17 @@ impl WebWorker { // non-null vm runs vm.onExit() (JS), which requires holdAPILock. // Instead we return; threadMain enters holdAPILock(spin) and spin()'s // first check observes requested_terminate. + // Capture the worker's own uWS loop pointer while `vm` is still + // exclusively ours (pre-publish). `ensure_waker()` ran inside + // `init_worker`, so `usockets_loop()` resolves the real loop on both + // platforms. Caching it here means the parent never reads the + // spawnSync/serve-mutated `event_loop_handle` fields cross-thread. + // SAFETY: `vm` is not yet published; this `&*vm` is the only reference. + let uws_loop_ptr = unsafe { (*vm).event_loop_shared().usockets_loop() }; + self.vm_lock.lock(); // vm_lock held; this is the publish point. + self.uws_loop_ptr.set(uws_loop_ptr); self.vm.set(vm); self.vm_lock.unlock(); diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 500fe8b5bbae..5992196b409d 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -39,6 +39,7 @@ pub use bun_install_jsc::ini_jsc::ini_testing_parse as ini_ini_ini_testing_ap_is pub use bun_jsc::bindgen_test::get_bindgen_test_functions as jsc_bindgen_test_get_bindgen_test_functions; pub use bun_jsc::counters::create_counters_object as jsc_counters_create_counters_object; pub use bun_jsc::event_loop::get_active_tasks as jsc_event_loop_get_active_tasks; +pub use bun_jsc::event_loop::js_event_loop_utilization as jsc_event_loop_js_event_loop_utilization; pub use bun_jsc::virtual_machine_exports::Bun__setSyntheticAllocationLimitForTesting as jsc_virtual_machine_exports_bun__set_synthetic_allocation_limit_for_testing; // `emit_handle_ipc_message` is implemented in this crate (`ipc_host.rs`) // because it dereferences `Subprocess`, a runtime type. diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 2f0ce5dc0bd9..1a8920f0a136 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -1272,7 +1272,9 @@ pub mod ssl_wrapper { // loop_data.h) and `struct us_loop_t` (epoll_kqueue.h / libuv.h). Re-exported // from bun_uws_sys so `bun_uws::Loop` and `bun_uws_sys::Loop` are the same // type (bun_io's EventLoopCtxVTable is typed against the uws_sys version). -pub use bun_uws_sys::loop_::{LoopHandler, us_wakeup_loop}; +pub use bun_uws_sys::loop_::{ + EventLoopUtilization, LoopHandler, loop_event_loop_utilization, us_wakeup_loop, +}; pub use bun_uws_sys::{InternalLoopData, Loop, NOW_NS_UNKNOWN, PosixLoop, Timespec, WindowsLoop}; /// Carrier trait so `set_parent_event_loop` can accept the higher-tier diff --git a/src/uws_sys/InternalLoopData.rs b/src/uws_sys/InternalLoopData.rs index 5a38af507434..e63350ac71b8 100644 --- a/src/uws_sys/InternalLoopData.rs +++ b/src/uws_sys/InternalLoopData.rs @@ -61,6 +61,17 @@ pub struct InternalLoopData { // Higher tier (`bun_runtime`) casts this back when reading. pub jsc_vm: *const c_void, pub tick_depth: c_int, + /// Monotonic timestamp (ns) when the loop was created; origin for + /// event-loop-utilization. Written once by C `us_internal_loop_data_init`. + pub creation_monotonic_ns: u64, + /// Accumulated ns the loop spent blocked in the event provider, written by + /// C with `__atomic_*`. Read via `us_loop_event_loop_utilization`, not + /// directly. Zero on Windows (libuv's `uv_metrics_idle_time` is used there). + pub idle_time_ns: u64, + /// Monotonic ns of an in-progress provider wait (0 when not waiting), so a + /// cross-thread reader credits the currently-blocked wait to idle. Written + /// by C around the epoll/kevent syscall; unused on Windows. + pub idle_entry_ns: u64, } impl InternalLoopData { diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index 144d86379b31..498f0a08f29d 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -619,6 +619,32 @@ pub type Loop = WindowsLoop; #[cfg(not(windows))] pub type Loop = PosixLoop; +/// Event loop idle/active timings (milliseconds) for +/// `performance.eventLoopUtilization()`. +#[derive(Clone, Copy, Debug)] +pub struct EventLoopUtilization { + pub idle_ms: f64, + pub active_ms: f64, +} + +/// Sample a loop's accumulated idle time and active (non-idle) time. +/// +/// # Safety +/// `loop_` must point at a live `Loop`. The C side reads the idle counter +/// atomically and the creation timestamp is immutable, so this is safe to call +/// from another thread (the parent sampling a worker's loop) as long as the +/// loop outlives the call. +pub unsafe fn loop_event_loop_utilization(loop_: *mut Loop) -> EventLoopUtilization { + let mut idle_ns: u64 = 0; + let mut active_ns: u64 = 0; + // SAFETY: caller guarantees `loop_` is live for the duration of the call. + unsafe { c::us_loop_event_loop_utilization(loop_, &raw mut idle_ns, &raw mut active_ns) }; + EventLoopUtilization { + idle_ms: idle_ns as f64 / 1_000_000.0, + active_ms: active_ns as f64 / 1_000_000.0, + } +} + // ───────────────────────────── extern "C" ───────────────────────────── pub(crate) type LoopCb = unsafe extern "C" fn(*mut Loop); @@ -660,6 +686,11 @@ mod c { ); pub(super) fn us_internal_free_closed_sockets(loop_: *mut Loop); pub(super) fn us_loop_close_all_groups(loop_: *mut Loop) -> c_int; + pub(super) fn us_loop_event_loop_utilization( + loop_: *mut Loop, + idle_ns_out: *mut u64, + active_ns_out: *mut u64, + ); #[cfg(not(windows))] pub(super) safe fn uws_get_loop() -> *mut Loop; #[cfg(windows)] diff --git a/test/js/node/perf_hooks/perf_hooks.test.ts b/test/js/node/perf_hooks/perf_hooks.test.ts index 29e965523700..bc8c5894baa0 100644 --- a/test/js/node/perf_hooks/perf_hooks.test.ts +++ b/test/js/node/perf_hooks/perf_hooks.test.ts @@ -9,6 +9,27 @@ test("stubs", () => { expect(perf.performance.eventLoopUtilization()).toBeObject(); }); +test("eventLoopUtilization reports active time for busy work", () => { + const elu1 = perf.performance.eventLoopUtilization(); + const start = Date.now(); + while (Date.now() - start < 200); + const elu2 = perf.performance.eventLoopUtilization(elu1); + + // Busy-waiting 200ms keeps the loop active; the delta should reflect it. + expect(elu2.active).toBeGreaterThan(50); + expect(elu2.utilization).toBeGreaterThan(0.5); +}); + +test("eventLoopUtilization reports idle time while awaiting", async () => { + const elu1 = perf.performance.eventLoopUtilization(); + await Bun.sleep(200); + const elu2 = perf.performance.eventLoopUtilization(elu1); + + // Sleeping blocks the loop in the event provider; that counts as idle. + expect(elu2.idle).toBeGreaterThan(50); + expect(elu2.utilization).toBeLessThan(0.5); +}); + test("doesn't throw", () => { expect(() => performance.mark("test")).not.toThrow(); expect(() => performance.measure("test", "test")).not.toThrow(); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index cedccbbc66b8..59875afc5d8d 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1,4 +1,4 @@ -import { bunEnv, bunExe, tmpdirSync } from "harness"; +import { bunEnv, bunExe, tempDir, tmpdirSync } from "harness"; import { once } from "node:events"; import fs from "node:fs"; import { join, relative, resolve } from "node:path"; @@ -326,17 +326,20 @@ describe("execArgv option", async () => { expect(await proc.stdout.text()).toBe(expected); } + // Each case boots a subprocess plus a worker VM, which can exceed the + // default 5s timeout on loaded debug/ASAN machines; the ceilings below are + // pure headroom (the cases run in well under a second normally). it("inherits the parent's execArgv when falsy or unspecified", async () => { await run("null", '["--smol"]\n'); await run("0", '["--smol"]\n'); - }); + }, 60_000); it("provides empty execArgv when passed an empty array", async () => { // empty array should result in empty execArgv, not inherited from parent thread await run("[]", "[]\n"); - }); + }, 60_000); it("can specify an array of strings", async () => { await run('["--no-warnings"]', '["--no-warnings"]\n'); - }); + }, 60_000); // TODO(@190n) get our handling of non-string array elements in line with Node's }); @@ -352,7 +355,9 @@ test("eval does not leak source code", async () => { const errors = await proc.stderr.text(); if (errors.length > 0) throw new Error(errors); expect(proc.exitCode).toBe(0); -}); + // The fixture round-trips 500 MiB of worker source; debug/ASAN builds need + // far more than the default 5s. +}, 240_000); describe("captured stdio backpressure", () => { // node flow control (lib/internal/worker/io.js): a writev batch's callback is @@ -514,7 +519,9 @@ describe("environmentData", () => { expect(proc.exitCode).toBe(0); const out = await proc.stdout.text(); expect(out).toBe("foo\n".repeat(5)); - }); + // Boots a subprocess plus a chain of worker VMs; needs headroom over the + // default 5s on loaded debug/ASAN machines. + }, 60_000); test("can be used if parent thread had not imported worker_threads", async () => { const proc = Bun.spawn({ @@ -631,7 +638,9 @@ describe("getHeapSnapshot", () => { code: "ERR_WORKER_NOT_RUNNING", message: "Worker instance not running", }); - }); + // Worker boot plus a heap snapshot; needs headroom over the default 5s on + // loaded debug/ASAN machines. + }, 60_000); test("resolves to a Stream.Readable with JSON text in V8 format", async () => { const worker = new Worker( @@ -666,7 +675,9 @@ describe("getHeapSnapshot", () => { "trace_tree", ]); worker.postMessage(0); - }); + // Worker boot plus a heap snapshot; needs headroom over the default 5s on + // loaded debug/ASAN machines. + }, 60_000); }); test("failed Worker construction restores transferred FileHandles", async () => { @@ -749,7 +760,9 @@ test("worker name survives parent-side GC and terminate cycles", async () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stdout.trim()).toBe("done"); expect(exitCode).toBe(0); -}); + // Boots a subprocess plus four worker VMs with full GC cycles; needs + // headroom over the default 5s on loaded debug/ASAN machines. +}, 60_000); test("partially transferred FileHandles are restored when a later transfer throws", async () => { const dir = tmpdirSync("worker-fh-transfer"); @@ -1364,6 +1377,7 @@ test("*Internal introspection methods are DontEnum on Worker.prototype", () => { expect(enumerable).not.toContain("startCpuProfileInternal"); expect(enumerable).not.toContain("stopCpuProfileInternal"); expect(enumerable).not.toContain("cpuUsageInternal"); + expect(enumerable).not.toContain("eventLoopUtilizationInternal"); }); describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide one", () => { @@ -1383,6 +1397,9 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on // main -> A (snapshot env) -> B (SHARE_ENV) is a tree disjoint from // main -> C (SHARE_ENV); values must not cross between them. + // The run() cases boot a subprocess plus several worker VMs, which can exceed + // the default 5s timeout on loaded debug/ASAN machines; the ceilings are pure + // headroom (the cases run in well under a second normally). it("keeps disjoint SHARE_ENV chains isolated", async () => { expect(await run("tree")).toEqual({ B_sees_FROM_A: "a", @@ -1393,7 +1410,7 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on main_sees_FROM_B: null, main_sees_FROM_C: "c", }); - }); + }, 60_000); // Founding a store must not adopt another tree's value for a key the founding // thread already has. @@ -1404,7 +1421,7 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on B_sees_SHARED_KEY: "from-A", main_SHARED_KEY: "from-main", }); - }); + }, 60_000); // An accessor installed via defineProperty lands on the base object, but reads hit // the store first — so the store entry must go, or the getter is shadowed. (Node @@ -1437,7 +1454,7 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on const want = { read: "new", count: 1, afterDelete: null }; expect(JSON.parse(stdout)).toEqual({ regular: want, shared: want }); expect(exitCode).toBe(0); - }); + }, 60_000); // node roots a main-founded SHARE_ENV tree at its RealEnvStore, so a worker writing // through it reaches the real environment a child process inherits; a snapshot @@ -1446,12 +1463,14 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on it.each([ ["SHARE_ENV", "written-by-worker"], ["snapshot", "absent"], - ])("a %s worker's env write is %s to a child process", async (mode, want) => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `const { Worker, SHARE_ENV, isMainThread, parentPort } = require("worker_threads"); + ])( + "a %s worker's env write is %s to a child process", + async (mode, want) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker, SHARE_ENV, isMainThread, parentPort } = require("worker_threads"); const { execFileSync } = require("child_process"); if (isMainThread) { const opts = ${JSON.stringify(mode)} === "SHARE_ENV" ? { env: SHARE_ENV, eval: true } : { eval: true }; @@ -1464,14 +1483,16 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on console.log(out); }); }`, - ], - env: bunEnv, - stderr: "pipe", - }); - const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout.trim()).toBe(want); - expect(exitCode).toBe(0); - }); + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe(want); + expect(exitCode).toBe(0); + }, + 60_000, + ); // Integer-like keys reach JSC through the indexed hooks; without ByIndex overrides // they land in JSObject's indexed storage and never touch the shared store. @@ -1483,7 +1504,7 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on main_sees_123: "from-main", main_sees_7_after_delete: null, }); - }); + }, 60_000); // Two SHARE_ENV children of one thread alias a single store: writes, deletes and // enumeration cross between them, and a default-env grandchild snapshots it. @@ -1496,7 +1517,7 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on main_sees_FROM_S1: "s1", main_sees_TO_DELETE: null, }); - }); + }, 60_000); // Founding a tree replaces process.env; Bun.env is reified from the same object // at startup and must not be left observing the orphaned pre-swap env. @@ -1519,7 +1540,7 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(JSON.parse(stdout)).toEqual({ same: true, bunEnv: "x" }); expect(exitCode).toBe(0); - }); + }, 60_000); }); test("postMessage with a non-object transfer element throws DataCloneError", () => { @@ -1702,4 +1723,228 @@ test("the SHARE_ENV founding thread's process.env stays live after the swap", as const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stdout.trim()).toBe("yes,unset"); expect(exitCode).toBe(0); + // Subprocess boots a worker plus a child process; needs headroom over the + // default 5s on loaded debug/ASAN machines. +}, 60_000); + +// https://github.com/oven-sh/bun/issues/32609 +test("worker.performance.eventLoopUtilization() reports the worker's activity", async () => { + using dir = tempDir("wt-elu", { + "worker.mjs": ` + import { parentPort } from "worker_threads"; + parentPort.on("message", () => {}); + // Keep the worker loop busy so active time accumulates. + (function busy() { + const t = Date.now(); + while (Date.now() - t < 50); + setImmediate(busy); + })(); + `, + }); + + const worker = new Worker(join(String(dir), "worker.mjs")); + try { + await new Promise((resolve, reject) => { + worker.on("online", () => resolve()); + worker.on("error", reject); + }); + + const elu1 = worker.performance.eventLoopUtilization(); + await Bun.sleep(300); + const elu2 = worker.performance.eventLoopUtilization(elu1); + + expect(elu2.active).toBeGreaterThan(50); + expect(elu2.utilization).toBeGreaterThan(0.5); + } finally { + await worker.terminate(); + } +}); + +// https://github.com/oven-sh/bun/issues/32609 +test("worker.performance.eventLoopUtilization() keeps reporting between terminate() and exit", async () => { + // Node's gate is !kIsOnline || !kHandle; neither changes at terminate(), so + // values stay real until exit handling. To sample that window without racing + // the worker's own teardown, park the worker inside a message handler on a + // SAB gate: it signals slot 1 once parked, the parent terminates and samples + // while the worker thread cannot reach shutdown, then releases slot 0. + const sab = new SharedArrayBuffer(8); + const gate = new Int32Array(sab); + const worker = new Worker( + `const { parentPort, workerData } = require("worker_threads"); + const t = Date.now(); + while (Date.now() - t < 100); // accumulate active time before parking + const g = new Int32Array(workerData.sab); + parentPort.on("message", () => { + Atomics.store(g, 1, 1); + Atomics.notify(g, 1); + Atomics.wait(g, 0, 0, 30_000); + });`, + { eval: true, workerData: { sab } }, + ); + try { + await new Promise((resolve, reject) => { + worker.on("online", () => resolve()); + worker.on("error", reject); + }); + worker.postMessage(0); + Atomics.wait(gate, 1, 0, 30_000); // worker is parked in the handler + + const terminated = worker.terminate(); + // Same tick as terminate(): the close task has not run (m_state flips on a + // parent task) and the parked worker cannot have torn down its VM, so the + // entry burn must still be visible rather than forced to zeros. + expect(worker.performance.eventLoopUtilization().active).toBeGreaterThan(50); + + Atomics.store(gate, 0, 1); + Atomics.notify(gate, 0); + await terminated; + } finally { + Atomics.store(gate, 0, 1); + Atomics.notify(gate, 0); + await worker.terminate(); + } +}); + +// https://github.com/oven-sh/bun/issues/32609 +test("worker.performance.eventLoopUtilization() returns zeros before 'online' fires", async () => { + // 'online' only fires after the worker's entry script finishes evaluating. + // The worker signals slot 1 from inside its entry (VM and loop pointer are + // published by then) and parks on slot 0, so the parent's sample lands in + // the post-publish, pre-online window where only the online gate (Node's + // kIsOnline) forces zeros. + const sab = new SharedArrayBuffer(8); + const gate = new Int32Array(sab); + const worker = new Worker( + `const { workerData } = require("worker_threads"); + const g = new Int32Array(workerData.sab); + Atomics.store(g, 1, 1); + Atomics.notify(g, 1); + Atomics.wait(g, 0, 0, 30_000);`, + { eval: true, workerData: { sab } }, + ); + try { + Atomics.wait(gate, 1, 0, 30_000); // worker is mid-entry: published, not online + expect(worker.performance.eventLoopUtilization()).toEqual({ idle: 0, active: 0, utilization: 0 }); + } finally { + Atomics.store(gate, 0, 1); + Atomics.notify(gate, 0); + await worker.terminate(); + } +}); + +// https://github.com/oven-sh/bun/issues/32609 +test("worker.performance.eventLoopUtilization() returns zeros (not negatives) after the worker exits", async () => { + using dir = tempDir("wt-elu-exit", { + "worker.mjs": ` + import { parentPort } from "worker_threads"; + let notified = false; + (function busy() { + const t = Date.now(); + while (Date.now() - t < 20); + // Signal once the loop has accumulated active time, so the parent waits + // on an observable condition instead of a fixed delay. + if (!notified) { + notified = true; + parentPort.postMessage("busy"); + } + setImmediate(busy); + })(); + `, + }); + + const worker = new Worker(join(String(dir), "worker.mjs")); + let terminated = false; + try { + await new Promise((resolve, reject) => { + worker.once("message", () => resolve()); + worker.once("error", reject); + }); + const elu1 = worker.performance.eventLoopUtilization(); + expect(elu1.active).toBeGreaterThan(0); + + await worker.terminate(); + terminated = true; + + // Sampling against a prior snapshot after the worker has exited must not + // produce negative deltas; Node returns zeros and ignores the prior sample. + expect(worker.performance.eventLoopUtilization(elu1)).toEqual({ idle: 0, active: 0, utilization: 0 }); + } finally { + if (!terminated) await worker.terminate(); + } +}); + +// https://github.com/oven-sh/bun/issues/32609 +test("worker.performance.eventLoopUtilization() reports low utilization for an idle worker", async () => { + using dir = tempDir("wt-elu-idle", { + "worker.mjs": ` + import { parentPort } from "worker_threads"; + // Stay alive but idle: blocked in the event provider waiting for messages. + parentPort.on("message", () => {}); + parentPort.postMessage("ready"); + `, + }); + + const worker = new Worker(join(String(dir), "worker.mjs")); + let terminated = false; + try { + await new Promise((resolve, reject) => { + worker.once("message", () => resolve()); + worker.once("error", reject); + }); + const elu1 = worker.performance.eventLoopUtilization(); + await Bun.sleep(200); + const elu2 = worker.performance.eventLoopUtilization(elu1); + + // The worker spends the whole window blocked in the event provider. That + // in-progress wait must count as idle, not active, even though it hasn't + // returned yet when the parent samples it. + expect(elu2.idle).toBeGreaterThan(50); + expect(elu2.utilization).toBeLessThan(0.5); + + await worker.terminate(); + terminated = true; + } finally { + if (!terminated) await worker.terminate(); + } +}); + +// https://github.com/oven-sh/bun/issues/32609 +test("worker.performance.eventLoopUtilization() stays low for a message-driven idle worker", async () => { + using dir = tempDir("wt-elu-msg", { + "worker.mjs": ` + import { parentPort } from "worker_threads"; + // Handle each message in ~no time and go back to waiting: ~0% busy. + parentPort.on("message", () => {}); + parentPort.postMessage("ready"); + `, + }); + + const worker = new Worker(join(String(dir), "worker.mjs")); + let terminated = false; + try { + await new Promise((resolve, reject) => { + worker.once("message", () => resolve()); + worker.once("error", reject); + }); + + const elu1 = worker.performance.eventLoopUtilization(); + // Drive the worker with periodic messages; each wakes its loop but does + // almost no work. The loop is blocked (idle) between messages. The 40ms + // gaps keep idle dominant even when a loaded machine stretches each + // wakeup's scheduling and handler time. + for (let i = 0; i < 15; i++) { + worker.postMessage(0); + await Bun.sleep(40); + } + const elu2 = worker.performance.eventLoopUtilization(elu1); + + // A worker that only wakes briefly to handle messages must not report as + // saturated: the between-message blocks are idle, not active. + expect(elu2.utilization).toBeLessThan(0.5); + + await worker.terminate(); + terminated = true; + } finally { + if (!terminated) await worker.terminate(); + } });