diff --git a/src/runtime/node/node.classes.ts b/src/runtime/node/node.classes.ts index c6455ed259da..0bfe0a10b4dc 100644 --- a/src/runtime/node/node.classes.ts +++ b/src/runtime/node/node.classes.ts @@ -233,6 +233,11 @@ export default [ _destroyed: { getter: "getDestroyed", }, + _onImmediate: { + getter: "get_onImmediate", + setter: "set_onImmediate", + this: true, + }, ["@@dispose"]: { fn: "dispose", length: 0, diff --git a/src/runtime/timer/ImmediateObject.rs b/src/runtime/timer/ImmediateObject.rs index b2cb178b46cc..0e1d4106ea25 100644 --- a/src/runtime/timer/ImmediateObject.rs +++ b/src/runtime/timer/ImmediateObject.rs @@ -3,6 +3,17 @@ use bun_jsc::{JSGlobalObject, JSValue}; use super::{Kind, TimerObjectInternals}; +/// Only the cached-property accessors — `callbackGetCached` / `callbackSetCached` +/// etc. per `values` entry in the `Immediate` `.classes.ts` define — are declared +/// here; the rest of the C++ JSCell wrapper is emitted by the `#[JsClass]` derive. +pub mod js { + bun_jsc::codegen_cached_accessors!( + "Immediate"; + arguments, + callback, + ); +} + // `jsc.Codegen.JSImmediate` — the C++ JSCell wrapper stays generated; this // struct is the `m_ctx` payload. Struct + `RefCounted`/`Default` impls + the // forwarder host-fns (`to_primitive`/`do_ref`/`do_unref`/`has_ref`/ @@ -20,6 +31,27 @@ impl ImmediateObject { Self::init_with(global, id, Kind::SetImmediate, 0, callback, arguments) } + // Cached-property getter/setter — codegen passes `this_value` (the JS + // wrapper) so the cached `WriteBarrier` slot on the C++ side can be read/written. + // Mirrors `Timeout::get_on_timeout`/`set_on_timeout` (see `TimeoutObject.rs`). + + pub fn get_on_immediate( + _this: &Self, + this_value: JSValue, + _global: &JSGlobalObject, + ) -> JSValue { + js::callback_get_cached(this_value).unwrap() + } + + pub fn set_on_immediate( + _this: &Self, + this_value: JSValue, + global: &JSGlobalObject, + value: JSValue, + ) { + js::callback_set_cached(this_value, global, value); + } + /// Thin forwarder to /// `internals.run_immediate_task`. Reached from `bun_jsc::event_loop` /// via `__bun_run_immediate_task` (definer in [`crate::dispatch`]). diff --git a/test/js/node/timers/node-timers.test.ts b/test/js/node/timers/node-timers.test.ts index a33cb72f4642..1f9487e32755 100644 --- a/test/js/node/timers/node-timers.test.ts +++ b/test/js/node/timers/node-timers.test.ts @@ -155,6 +155,67 @@ describe("_destroyed", () => { }); }); +// https://github.com/oven-sh/bun/issues/31801 +describe("_onImmediate", () => { + it("exposes the scheduled callback", () => { + const fn = () => {}; + const immediate = setImmediate(fn) as any; + try { + expect(typeof immediate._onImmediate).toBe("function"); + expect(immediate._onImmediate).toBe(fn); + } finally { + clearImmediate(immediate); + } + }); + + it("is a prototype accessor, like Timeout._onTimeout", () => { + const immediate = setImmediate(() => {}) as any; + try { + const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(immediate), "_onImmediate"); + expect(typeof descriptor?.get).toBe("function"); + expect(typeof descriptor?.set).toBe("function"); + } finally { + clearImmediate(immediate); + } + }); + + it("is writable through the prototype setter", () => { + const replacement = () => {}; + const immediate = setImmediate(() => {}) as any; + try { + immediate._onImmediate = replacement; + expect(immediate._onImmediate).toBe(replacement); + // The write must route through the prototype accessor, not create an own + // expando data property (which is what a runtime lacking the accessor does). + expect(Object.getOwnPropertyDescriptor(immediate, "_onImmediate")).toBeUndefined(); + } finally { + clearImmediate(immediate); + } + }); + + it("fires the reassigned callback, not the original", async () => { + // Matches Node: the callback invoked at dispatch is whatever _onImmediate + // currently holds, so reassigning it before it fires swaps what runs. + const { promise, resolve, reject } = Promise.withResolvers(); + const immediate = setImmediate(() => reject(new Error("original callback fired"))) as any; + immediate._onImmediate = () => resolve("replacement"); + expect(await promise).toBe("replacement"); + }); + + it("mirrors Timeout's _onTimeout", () => { + const fn = () => {}; + const timeout = setTimeout(fn, 0) as any; + const immediate = setImmediate(fn) as any; + try { + expect(immediate._onImmediate).toBe(timeout._onTimeout); + expect(immediate._onImmediate).toBe(fn); + } finally { + clearTimeout(timeout); + clearImmediate(immediate); + } + }); +}); + describe("clear", () => { it("can clear the other kind of timer", async () => { const timeout1 = setTimeout(() => {