From 60ed63a82a626ce2a323a89bb3c6d2a65c83db68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 15:25:09 +0200 Subject: [PATCH] fix(stdlib): build with --no-default-features again, unbreaking the auto-optimize relink (#7764) --- .../7772-stdlib-no-default-features.md | 13 +++ .../perry-stdlib/src/common/dispatch/init.rs | 9 +++ crates/perry-stdlib/src/worker_threads.rs | 9 ++- .../src/worker_threads/async_shim.rs | 80 +++++++++++++++++++ .../src/worker_threads/broadcast_channel.rs | 4 +- .../src/worker_threads/direct_message.rs | 10 +-- .../src/worker_threads/message_port.rs | 4 +- .../src/worker_threads/worker_pump.rs | 2 +- 8 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 changelog.d/7772-stdlib-no-default-features.md create mode 100644 crates/perry-stdlib/src/worker_threads/async_shim.rs diff --git a/changelog.d/7772-stdlib-no-default-features.md b/changelog.d/7772-stdlib-no-default-features.md new file mode 100644 index 0000000000..1de2e4536f --- /dev/null +++ b/changelog.d/7772-stdlib-no-default-features.md @@ -0,0 +1,13 @@ +### Fixed + +- **`perry-stdlib` builds with `--no-default-features` again (#7764).** That is the configuration the auto-optimize relink uses, so while it was broken every `perry` compile that triggered auto-optimize silently fell back to the prebuilt archives, and ad-hoc builds needed `PERRY_NO_AUTO_OPTIMIZE=1` as a workaround. + + Twelve errors, from two causes, both violations of the contract `common/mod.rs` states in prose: *"Always-on code that references it must also be `#[cfg(feature = "async-runtime")]`-gated."* + + **One** was #7745's omission, exactly as the issue diagnosed: the `js_set_native_events_dispatch` registration referenced `crate::events` without the `#[cfg(feature = "bundled-events")]` that gates the module. The neighbouring registrations in the same function are gated (`database-sqlite` on the next line), which is what makes it an omission rather than a decision. + + **Eleven** were `worker_threads` — always-on, and referencing `common::async_bridge` across five files. Neither obvious repair works: two sites are value-producing (`js_promise_new_for_native_resolution`), so `#[cfg]` on the statement leaves nothing to return; and gating the whole `worker_threads` module is worse, because it has no feature of its own, so its FFI symbols would vanish from the stripped archive and a program importing `node:worker_threads` would fail to LINK — trading a build error for the #7629 family of failure. + + So `worker_threads/async_shim.rs` provides the four entry points in both configurations: forwarding to `async_bridge` when it is compiled in, and settling **inline** when it is not. That is not invented semantics — the queue exists to hand work to the pump, and with no pump there is nothing to hand it to, so doing the same work synchronously reaches the same observable end state. The pinning `js_promise_new_for_native_resolution` performs is likewise a consequence of deferral, and an inline settle spans no collection point, so a plain `js_promise_new` is its correct counterpart. + + Verified in BOTH directions — `cargo build -p perry-stdlib` and `--no-default-features` each build clean — because the first cut of the shim accidentally imported itself, which only the default-features build could see. diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index 4ebae896d5..5bfd6d08bc 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -665,6 +665,15 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { ); // Module-level `events.*` helpers reached indirectly (captured value, // type-erased receiver, spread call) — see `js_events_native_dispatch`. + // + // #7764: gated to match `pub mod events`, which is `bundled-events`. #7745 + // added this line ungated, so `--no-default-features` — the configuration + // the auto-optimize relink builds with — stopped compiling, and every + // `perry` compile that triggers auto-optimize silently fell back to the + // prebuilt archives. The neighbouring registrations are gated the same way + // (`database-sqlite` on the next line), which is what makes this an + // omission rather than a decision. + #[cfg(feature = "bundled-events")] perry_runtime::js_set_native_events_dispatch(crate::events::js_events_native_dispatch); #[cfg(feature = "database-sqlite")] perry_runtime::js_set_native_sqlite_dispatch(crate::sqlite::js_node_sqlite_native_dispatch); diff --git a/crates/perry-stdlib/src/worker_threads.rs b/crates/perry-stdlib/src/worker_threads.rs index 02c82bcb37..f69e9f9289 100644 --- a/crates/perry-stdlib/src/worker_threads.rs +++ b/crates/perry-stdlib/src/worker_threads.rs @@ -21,6 +21,8 @@ use perry_runtime::thread::{ }; use perry_runtime::value::JSValue; +// #7764: async-bridge entry points that exist in both feature configurations. +mod async_shim; mod broadcast_channel; mod channel_pump; mod direct_message; @@ -968,7 +970,8 @@ pub extern "C" fn js_worker_threads_worker_start_heap_profile(receiver: i64) -> } fn worker_terminate_by_id(worker_id: u64) -> f64 { - let promise = unsafe { crate::common::async_bridge::js_promise_new_for_native_resolution() }; + let promise = + unsafe { crate::worker_threads::async_shim::js_promise_new_for_native_resolution() }; let promise_ptr = promise as usize; let resolved_now = { let mut workers = WORKERS.lock().unwrap(); @@ -1100,7 +1103,7 @@ fn object_u64_field(value: f64, field_name: &str) -> Option { #[no_mangle] pub extern "C" fn js_worker_threads_message_channel_new() -> f64 { ensure_environment_data_gc_scanner(); - crate::common::async_bridge::ensure_pump_registered(); + crate::worker_threads::async_shim::ensure_pump_registered(); let (id1, id2) = NEXT_PORT_ID.with(|n| { let mut n = n.borrow_mut(); let a = *n; @@ -1136,7 +1139,7 @@ pub extern "C" fn js_worker_threads_message_channel_new() -> f64 { #[no_mangle] pub extern "C" fn js_worker_threads_worker_new(entry_ptr: i64, options: f64) -> f64 { ensure_worker_gc_scanner(); - crate::common::async_bridge::ensure_pump_registered(); + crate::worker_threads::async_shim::ensure_pump_registered(); let worker_id = NEXT_WORKER_ID.fetch_add(1, Ordering::Relaxed); let options_state = WorkerOptions::from_value(options); diff --git a/crates/perry-stdlib/src/worker_threads/async_shim.rs b/crates/perry-stdlib/src/worker_threads/async_shim.rs new file mode 100644 index 0000000000..76475252ff --- /dev/null +++ b/crates/perry-stdlib/src/worker_threads/async_shim.rs @@ -0,0 +1,80 @@ +//! #7764: the four `common::async_bridge` entry points `worker_threads` needs, +//! available in BOTH feature configurations. +//! +//! `common/mod.rs` states the contract this exists to satisfy: +//! +//! > Tokio-backed promise/runtime bridge — only needed when an async feature … +//! > pulls in `async-runtime`. **Always-on code that references it must also be +//! > `#[cfg(feature = "async-runtime")]`-gated.** +//! +//! `worker_threads` is always-on and referenced it in eleven places across five +//! files, so `cargo build -p perry-stdlib --no-default-features` did not +//! compile. That is the configuration the auto-optimize relink uses, so every +//! `perry` compile that triggered auto-optimize fell back to the prebuilt +//! archives with a warning, and ad-hoc builds needed `PERRY_NO_AUTO_OPTIMIZE=1`. +//! +//! Gating each call site individually was not an option: two of them are +//! value-producing (`js_promise_new_for_native_resolution`) and the rest settle +//! a promise, so `#[cfg]` on the statement leaves nothing to return. Gating the +//! whole `worker_threads` module was worse — it has no feature of its own, so +//! its FFI symbols would vanish from the stripped archive and a program that +//! imports `node:worker_threads` would fail to LINK, which is the #7629 family +//! of failure rather than a fix. +//! +//! So: forward when the bridge is compiled in, and settle INLINE when it is not. +//! That is not an invented semantic. The queue exists to hand work to the pump; +//! with no pump there is nothing to hand it to, and doing the same work +//! synchronously reaches the same observable end state (the promise settles). +//! The pinning `js_promise_new_for_native_resolution` performs is likewise a +//! consequence of deferral — it keeps the promise alive across the window +//! between creation and the pump's resolution — and an inline settle spans no +//! collection point, so a plain `js_promise_new` is the correct counterpart. + +#[cfg(feature = "async-runtime")] +pub(crate) use crate::common::async_bridge::{ + ensure_pump_registered, js_promise_new_for_native_resolution, queue_deferred_resolution, + queue_promise_resolution, +}; + +#[cfg(not(feature = "async-runtime"))] +mod inline { + /// No bridge means no pump to register. + pub(crate) fn ensure_pump_registered() {} + + /// # Safety + /// Mirrors `async_bridge::js_promise_new_for_native_resolution`. + /// + /// No pinning: pinning guards the deferral window, and there is none here. + pub(crate) unsafe fn js_promise_new_for_native_resolution() -> *mut perry_runtime::Promise { + perry_runtime::js_promise_new() + } + + /// Settle now rather than queueing for a pump that does not exist. + pub(crate) fn queue_promise_resolution(promise_ptr: usize, is_success: bool, result_bits: u64) { + if promise_ptr == 0 { + return; + } + let promise = promise_ptr as *mut perry_runtime::Promise; + let value = f64::from_bits(result_bits); + if is_success { + perry_runtime::js_promise_resolve(promise, value); + } else { + perry_runtime::js_promise_reject(promise, value); + } + } + + /// As above, running the converter inline. The `Send + 'static` bound is + /// kept so the two configurations accept the same call sites. + pub(crate) fn queue_deferred_resolution(promise_ptr: usize, is_success: bool, converter: F) + where + F: FnOnce() -> u64 + Send + 'static, + { + queue_promise_resolution(promise_ptr, is_success, converter()); + } +} + +#[cfg(not(feature = "async-runtime"))] +pub(crate) use inline::{ + ensure_pump_registered, js_promise_new_for_native_resolution, queue_deferred_resolution, + queue_promise_resolution, +}; diff --git a/crates/perry-stdlib/src/worker_threads/broadcast_channel.rs b/crates/perry-stdlib/src/worker_threads/broadcast_channel.rs index 3f083c2397..d121cf81a7 100644 --- a/crates/perry-stdlib/src/worker_threads/broadcast_channel.rs +++ b/crates/perry-stdlib/src/worker_threads/broadcast_channel.rs @@ -64,7 +64,7 @@ extern "C" fn broadcast_add_event_listener( let Some(cb_bits) = callback_bits_from_value(callback) else { return js_undefined(); }; - crate::common::async_bridge::ensure_pump_registered(); + super::async_shim::ensure_pump_registered(); BROADCAST_CHANNELS.with(|channels| { if let Some(state) = channels.borrow_mut().get_mut(&channel_id) { if event_name == "message" && !state.message_event_cbs.contains(&cb_bits) { @@ -99,7 +99,7 @@ extern "C" fn broadcast_remove_event_listener( #[no_mangle] pub extern "C" fn js_worker_threads_broadcast_channel_new(name: f64) -> f64 { ensure_environment_data_gc_scanner(); - crate::common::async_bridge::ensure_pump_registered(); + super::async_shim::ensure_pump_registered(); let id = NEXT_BROADCAST_ID.with(|n| { let mut n = n.borrow_mut(); let id = *n; diff --git a/crates/perry-stdlib/src/worker_threads/direct_message.rs b/crates/perry-stdlib/src/worker_threads/direct_message.rs index 4beda4e55b..c28a00ebc1 100644 --- a/crates/perry-stdlib/src/worker_threads/direct_message.rs +++ b/crates/perry-stdlib/src/worker_threads/direct_message.rs @@ -66,7 +66,7 @@ pub extern "C" fn js_worker_threads_post_message_to_thread( return rejected_worker_messaging_promise(WorkerMessagingError::Failed); }; - let promise = unsafe { crate::common::async_bridge::js_promise_new_for_native_resolution() }; + let promise = unsafe { super::async_shim::js_promise_new_for_native_resolution() }; let promise_ptr = promise as usize; let timeout = timeout_duration(timeout); if sender @@ -157,11 +157,7 @@ fn wait_for_direct_message_ack( match result { Ok(DirectMessageResult::Delivered) => { - crate::common::async_bridge::queue_promise_resolution( - promise_ptr, - true, - js_undefined_bits(), - ); + super::async_shim::queue_promise_resolution(promise_ptr, true, js_undefined_bits()); } Ok(DirectMessageResult::Failed) => { queue_worker_messaging_rejection(promise_ptr, WorkerMessagingError::Failed); @@ -171,7 +167,7 @@ fn wait_for_direct_message_ack( } fn queue_worker_messaging_rejection(promise_ptr: usize, error: WorkerMessagingError) { - crate::common::async_bridge::queue_deferred_resolution(promise_ptr, false, move || { + super::async_shim::queue_deferred_resolution(promise_ptr, false, move || { worker_messaging_error_value(error).to_bits() }); } diff --git a/crates/perry-stdlib/src/worker_threads/message_port.rs b/crates/perry-stdlib/src/worker_threads/message_port.rs index 053698dee1..5fa2ded0a7 100644 --- a/crates/perry-stdlib/src/worker_threads/message_port.rs +++ b/crates/perry-stdlib/src/worker_threads/message_port.rs @@ -134,7 +134,7 @@ extern "C" fn port_on(closure: *const ClosureHeader, event: f64, callback: f64) // the runtime pump would otherwise never be registered and `main` would // return before any queued `message` is delivered. Register it here (mirrors // readline #347), so the event loop ticks and drains the inboxes. - crate::common::async_bridge::ensure_pump_registered(); + super::async_shim::ensure_pump_registered(); MESSAGE_PORTS.with(|ports| { if let Some(state) = ports.borrow_mut().get_mut(&port_id) { match event_name.as_str() { @@ -186,7 +186,7 @@ extern "C" fn port_add_event_listener( let Some(cb_bits) = callback_bits_from_value(callback) else { return js_undefined(); }; - crate::common::async_bridge::ensure_pump_registered(); + super::async_shim::ensure_pump_registered(); MESSAGE_PORTS.with(|ports| { if let Some(state) = ports.borrow_mut().get_mut(&port_id) { match event_name.as_str() { diff --git a/crates/perry-stdlib/src/worker_threads/worker_pump.rs b/crates/perry-stdlib/src/worker_threads/worker_pump.rs index be64b88e5c..a1386d0c1f 100644 --- a/crates/perry-stdlib/src/worker_threads/worker_pump.rs +++ b/crates/perry-stdlib/src/worker_threads/worker_pump.rs @@ -121,7 +121,7 @@ pub extern "C" fn js_worker_threads_process_pending() -> i32 { }; dispatch_worker_event(worker_id, "exit", Some(code as f64)); if let Some(promise) = terminate_promise { - crate::common::async_bridge::queue_promise_resolution( + super::async_shim::queue_promise_resolution( promise, true, (code as f64).to_bits(),