From 91e75a1813337805f03c67ad1c86972989ac7b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 03:43:34 +0200 Subject: [PATCH] fix(stdlib): drive active HTTP servers on fast waits --- changelog.d/8569-next-dylib-provider-host.md | 12 +++++ .../perry-stdlib/src/common/async_bridge.rs | 50 ++++++++++++++++--- tests/test_next_app_route_dylib.sh | 6 ++- 3 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 changelog.d/8569-next-dylib-provider-host.md diff --git a/changelog.d/8569-next-dylib-provider-host.md b/changelog.d/8569-next-dylib-provider-host.md new file mode 100644 index 0000000000..558d81f0cc --- /dev/null +++ b/changelog.d/8569-next-dylib-provider-host.md @@ -0,0 +1,12 @@ +## Keep HTTP servers live during microtask-heavy startup + +The single-thread async wait driver's fast path now counts a listening external +HTTP server as native work. Previously it only drove Tokio for blocking tasks +and HTTP clients. If JavaScript kept queuing microtasks after `server.listen()`, +`js_wait_for_event` stayed on that fast path while the server's accept task sat +unpolled. The production Next App Route provider gate therefore depended on the +accept task winning its initial spawn race and usually parked without serving +its first request. + +`perry-stdlib` now gives the reactor a bounded turn while an external HTTP +server is active, and its async-bridge unit tests pin that liveness condition. diff --git a/crates/perry-stdlib/src/common/async_bridge.rs b/crates/perry-stdlib/src/common/async_bridge.rs index dadf377788..1c234de96d 100644 --- a/crates/perry-stdlib/src/common/async_bridge.rs +++ b/crates/perry-stdlib/src/common/async_bridge.rs @@ -342,16 +342,21 @@ extern "C" fn stdlib_wait_wake() { /// Wait-driver FAST side — a brief native drive invoked by `js_wait_for_event` /// when JS work is pending (a notify or queued microtasks). On the single-thread /// runtime, in-flight native tasks (a fetch's reqwest `send`, its h2 connection -/// driver, sibling fetches) run ONLY inside a tick; under constant JS promise -/// churn the fast-path is taken every iteration, so without this they are starved -/// forever (the bundle hang). When something native IS in flight, drive one short -/// (1 ms) tick: `block_on` drains the run queue (starts freshly-spawned sibling -/// fetches) and parks briefly on the I/O reactor (advancing TLS/h2 round-trips), -/// ending early if a native result is queued. No-op when nothing native is in -/// flight, so pure-JS-async pays only an atomic load. +/// driver, sibling fetches, or a server accept loop) run ONLY inside a tick; +/// under constant JS promise churn the fast-path is taken every iteration, so +/// without this they are starved forever (the bundle hang). When something +/// native IS in flight, drive one short (1 ms) tick: `block_on` drains the run +/// queue (starts freshly-spawned tasks) and parks briefly on the I/O reactor +/// (advancing TLS/h2 round-trips and accepting server connections), ending early +/// if a native result is queued. No-op when nothing native is in flight, so +/// pure-JS-async pays only atomic loads. extern "C" fn stdlib_fast_drive() { let n = EXT_BLOCKING_TASKS_INFLIGHT.load(Ordering::Acquire); - let native = n > 0 || ext_http_client_inflight_fast(); + let native = native_fast_drive_needed( + n, + ext_http_client_inflight_fast(), + ext_http_server_active_fast(), + ); if !native { return; } @@ -375,6 +380,27 @@ fn ext_http_client_inflight_fast() -> bool { false } +#[cfg(feature = "external-http-server-pump")] +fn ext_http_server_active_fast() -> bool { + extern "C" { + fn js_node_http_server_has_active() -> i32; + } + unsafe { js_node_http_server_has_active() != 0 } +} +#[cfg(not(feature = "external-http-server-pump"))] +fn ext_http_server_active_fast() -> bool { + false +} + +#[inline] +fn native_fast_drive_needed( + blocking_tasks_inflight: usize, + http_client_inflight: bool, + http_server_active: bool, +) -> bool { + blocking_tasks_inflight > 0 || http_client_inflight || http_server_active +} + /// Queue a promise resolution to be processed later /// NOTE: Only use this for simple values (numbers, booleans, undefined, null) /// that don't involve pointer allocations. For complex values like arrays, @@ -995,6 +1021,14 @@ mod tests { PENDING_DEFERRED.lock().unwrap().clear(); } + #[test] + fn active_http_server_keeps_the_fast_wait_path_driving_native_tasks() { + assert!(!native_fast_drive_needed(0, false, false)); + assert!(native_fast_drive_needed(0, false, true)); + assert!(native_fast_drive_needed(0, true, false)); + assert!(native_fast_drive_needed(1, false, false)); + } + #[test] fn async_bridge_pending_resolution_scanner_emits_promise_and_result_roots() { clear_pending(); diff --git a/tests/test_next_app_route_dylib.sh b/tests/test_next_app_route_dylib.sh index 31d168159f..6f93d6a44b 100755 --- a/tests/test_next_app_route_dylib.sh +++ b/tests/test_next_app_route_dylib.sh @@ -316,7 +316,11 @@ for cold_start in $(seq 1 "$cold_starts"); do ready=false for _ in $(seq 1 240); do - if curl --fail --silent --output /dev/null \ + # A bound listener can accept the TCP connection before its Tokio + # accept task has received a reactor turn. Bound each probe so a + # provider regression fails with the host log instead of parking this + # gate forever inside curl (#8381). + if curl --fail --silent --max-time 1 --output /dev/null \ "http://127.0.0.1:$port/api/benchmark?id=ready&iterations=1"; then ready=true break