From 129b053a3ec95130e44161f55e6a828f2b60d518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 15:35:36 +0200 Subject: [PATCH 1/4] fix(test): C host + two-level namespace for the Next App Route dylib gate (#8205) The gate aborted on its first request: the Rust provider-host exported rustc's System-allocator shim, and the stdlib provider image was linked with -flat_namespace, so its __rust_dealloc import bound to the host's shim while the runtime image's shim is mimalloc. The first cross-image Vec drop in js_node_http_server_process_pending freed a mimalloc pointer with libsystem free() and aborted. Replace provider-host.rs with a C host (same load order, flags, probe check and event loop; no Rust allocator shim in the executable) and drop -flat_namespace -interposable from provider-linker.sh so the stdlib image binds its runtime imports two-level to libperry_runtime.dylib. --- tests/fixtures/next-app-route/provider-host.c | 91 ++++++++++++++++++ .../fixtures/next-app-route/provider-host.rs | 96 ------------------- .../next-app-route/provider-linker.sh | 10 +- tests/test_next_app_route_dylib.sh | 5 +- 4 files changed, 104 insertions(+), 98 deletions(-) create mode 100644 tests/fixtures/next-app-route/provider-host.c delete mode 100644 tests/fixtures/next-app-route/provider-host.rs diff --git a/tests/fixtures/next-app-route/provider-host.c b/tests/fixtures/next-app-route/provider-host.c new file mode 100644 index 0000000000..6b8aba84c6 --- /dev/null +++ b/tests/fixtures/next-app-route/provider-host.c @@ -0,0 +1,91 @@ +/* + * dlopen host for the production Next App Route dylib gate + * (tests/test_next_app_route_dylib.sh). + * + * Deliberately C, not Rust (#8205). A Rust executable carries rustc's + * allocator shim (`__rust_alloc` / `__rust_dealloc` / ...), backed by the + * System allocator. The stdlib provider image imports those same shim symbols + * and expects the runtime image's mimalloc-backed definitions; when the main + * executable also defines them, a flat lookup binds the stdlib image to the + * host's shim, and the first cross-image `Vec` drop frees a mimalloc pointer + * with libsystem `free()` and aborts. A C host defines no Rust shim, so the + * only definitions in the process are the runtime image's. + * + * Load order and flags are the contract the gate asserts: both providers are + * process-global and eagerly relocated before the app; the app itself is + * loaded RTLD_LOCAL with eager relocation, so an unresolved Perry ABI symbol + * fails at load time rather than on the first request that reaches it. + */ +#include +#include +#include +#include +#include + +typedef void (*void_fn)(void); +typedef int (*tick_fn)(void); +typedef size_t (*probe_fn)(void); + +static void *open_image(const char *path, int mode) { + void *handle = dlopen(path, mode); + if (handle == NULL) { + fprintf(stderr, "dlopen failed: %s: %s\n", path, dlerror()); + exit(1); + } + return handle; +} + +static void *symbol(void *handle, const char *name) { + dlerror(); + void *address = dlsym(handle, name); + const char *error = dlerror(); + if (address == NULL || error != NULL) { + fprintf(stderr, "dlsym failed: %s: %s\n", name, + error != NULL ? error : "null address"); + exit(1); + } + return address; +} + +int main(int argc, char **argv) { + if (argc != 4) { + fprintf(stderr, "usage: provider-host runtime stdlib app\n"); + return 1; + } + + void *runtime = open_image(argv[1], RTLD_NOW | RTLD_GLOBAL); + void *stdlib = open_image(argv[2], RTLD_NOW | RTLD_GLOBAL); + void *app = open_image(argv[3], RTLD_NOW | RTLD_LOCAL); + + void_fn gc_init = (void_fn)symbol(runtime, "js_gc_init"); + /* The stdlib provider must bind its stateful runtime calls to the runtime + image the host loaded, not to a private runtime copy of its own. */ + probe_fn provider_probe = + (probe_fn)symbol(stdlib, "next_app_route_provider_runtime_probe"); + if (provider_probe() != (size_t)(uintptr_t)gc_init) { + fprintf(stderr, "stdlib provider is bound to a different runtime image\n"); + return 1; + } + + void_fn module_init = (void_fn)symbol(app, "perry_module_init"); + tick_fn run_microtasks = + (tick_fn)symbol(runtime, "js_promise_run_microtasks_event_loop"); + tick_fn timer_tick = (tick_fn)symbol(runtime, "js_timer_tick"); + tick_fn callback_timer_tick = + (tick_fn)symbol(runtime, "js_callback_timer_tick"); + tick_fn interval_timer_tick = + (tick_fn)symbol(runtime, "js_interval_timer_tick"); + void_fn run_stdlib_pump = (void_fn)symbol(runtime, "js_run_stdlib_pump"); + void_fn wait_for_event = (void_fn)symbol(runtime, "js_wait_for_event"); + + gc_init(); + module_init(); + for (;;) { + run_microtasks(); + timer_tick(); + callback_timer_tick(); + interval_timer_tick(); + run_stdlib_pump(); + wait_for_event(); + } +} diff --git a/tests/fixtures/next-app-route/provider-host.rs b/tests/fixtures/next-app-route/provider-host.rs deleted file mode 100644 index 75acdd8808..0000000000 --- a/tests/fixtures/next-app-route/provider-host.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::ffi::{c_char, c_int, c_void, CString}; - -const RTLD_NOW: c_int = 2; -#[cfg(target_os = "macos")] -const RTLD_GLOBAL: c_int = 8; -#[cfg(target_os = "linux")] -const RTLD_GLOBAL: c_int = 0x100; -#[cfg(target_os = "macos")] -const RTLD_LOCAL: c_int = 4; -#[cfg(target_os = "linux")] -const RTLD_LOCAL: c_int = 0; - -unsafe extern "C" { - fn dlopen(path: *const c_char, mode: c_int) -> *mut c_void; - fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; - fn dlerror() -> *const c_char; -} - -type VoidFn = unsafe extern "C" fn(); -type TickFn = unsafe extern "C" fn() -> i32; -type ProbeFn = unsafe extern "C" fn() -> usize; - -fn loader_error(context: &str) -> String { - let message = unsafe { - let error = dlerror(); - if error.is_null() { - "unknown loader error".to_string() - } else { - std::ffi::CStr::from_ptr(error) - .to_string_lossy() - .into_owned() - } - }; - format!("{context}: {message}") -} - -fn open(path: &str, mode: c_int) -> Result<*mut c_void, String> { - let path = CString::new(path).map_err(|_| "library path contains NUL".to_string())?; - let handle = unsafe { dlopen(path.as_ptr(), mode) }; - if handle.is_null() { - Err(loader_error("dlopen failed")) - } else { - Ok(handle) - } -} - -unsafe fn symbol(handle: *mut c_void, name: &str) -> Result { - let name = CString::new(name).map_err(|_| "symbol name contains NUL".to_string())?; - let address = unsafe { dlsym(handle, name.as_ptr()) }; - if address.is_null() { - return Err(loader_error("dlsym failed")); - } - Ok(unsafe { std::mem::transmute_copy(&address) }) -} - -fn main() -> Result<(), String> { - let arguments: Vec = std::env::args().collect(); - if arguments.len() != 4 { - return Err("usage: provider-host runtime stdlib app".into()); - } - - let runtime = open(&arguments[1], RTLD_NOW | RTLD_GLOBAL)?; - let stdlib = open(&arguments[2], RTLD_NOW | RTLD_GLOBAL)?; - let app = open(&arguments[3], RTLD_NOW | RTLD_LOCAL)?; - - let gc_init: VoidFn = unsafe { symbol(runtime, "js_gc_init")? }; - let provider_probe: ProbeFn = - unsafe { symbol(stdlib, "next_app_route_provider_runtime_probe")? }; - if unsafe { provider_probe() } != gc_init as usize { - return Err("stdlib provider is bound to a different runtime image".into()); - } - - let module_init: VoidFn = unsafe { symbol(app, "perry_module_init")? }; - let run_microtasks: TickFn = - unsafe { symbol(runtime, "js_promise_run_microtasks_event_loop")? }; - let timer_tick: TickFn = unsafe { symbol(runtime, "js_timer_tick")? }; - let callback_timer_tick: TickFn = unsafe { symbol(runtime, "js_callback_timer_tick")? }; - let interval_timer_tick: TickFn = unsafe { symbol(runtime, "js_interval_timer_tick")? }; - let run_stdlib_pump: VoidFn = unsafe { symbol(runtime, "js_run_stdlib_pump")? }; - let wait_for_event: VoidFn = unsafe { symbol(runtime, "js_wait_for_event")? }; - - unsafe { - gc_init(); - module_init(); - } - loop { - unsafe { - run_microtasks(); - timer_tick(); - callback_timer_tick(); - interval_timer_tick(); - run_stdlib_pump(); - wait_for_event(); - } - } -} diff --git a/tests/fixtures/next-app-route/provider-linker.sh b/tests/fixtures/next-app-route/provider-linker.sh index 0a8e3726f8..5d8f856cf8 100755 --- a/tests/fixtures/next-app-route/provider-linker.sh +++ b/tests/fixtures/next-app-route/provider-linker.sh @@ -103,9 +103,17 @@ if [[ "$host_os" == Darwin ]]; then while IFS= read -r symbol; do arguments+=("-Wl,-u,$symbol") done <"$selected" + # Two-level namespace, on purpose (#8205): every undefined symbol in this + # image is bound at link time to the image that defines it, so the stdlib + # provider's `__rust_alloc`/`__rust_dealloc` imports resolve to the runtime + # dylib's mimalloc-backed shim no matter what else the process defines. A + # `-flat_namespace` link would instead bind them at load time to the FIRST + # definition in the process, which for a Rust host executable is its own + # System-allocator shim — a mimalloc buffer freed by libsystem, `abort()` + # on the first cross-image `Vec` drop. arguments+=( '-Wl,-exported_symbols_list' "-Wl,$exports" - '-Wl,-rpath,@loader_path' '-Wl,-flat_namespace' '-Wl,-interposable' + '-Wl,-rpath,@loader_path' ) else version_script="$scratch/exports.map" diff --git a/tests/test_next_app_route_dylib.sh b/tests/test_next_app_route_dylib.sh index 7661606f8a..42a0317e72 100755 --- a/tests/test_next_app_route_dylib.sh +++ b/tests/test_next_app_route_dylib.sh @@ -265,8 +265,11 @@ if [[ -s "$missing_symbols" ]]; then exit 1 fi +# The host is C, not Rust (#8205): a Rust executable would carry rustc's +# System-allocator shim, and the stdlib provider's `__rust_dealloc` import must +# reach the runtime image's mimalloc-backed shim instead. See provider-host.c. host="$scratch/provider-host" -rustc --edition 2021 -O "$fixture/provider-host.rs" -o "$host" +"$real_cc" -O2 -o "$host" "$fixture/provider-host.c" -ldl provider_abi=$(shasum -a 256 "$available_symbols" | awk '{print $1}') echo "Provider ABI hash: $provider_abi" From d5493658cb3a6189b6c090f11f5e62b4c88361bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 15:40:10 +0200 Subject: [PATCH 2/4] changelog: fragment for #8209 --- changelog.d/8209-next-gate-c-host-two-level.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 changelog.d/8209-next-gate-c-host-two-level.md diff --git a/changelog.d/8209-next-gate-c-host-two-level.md b/changelog.d/8209-next-gate-c-host-two-level.md new file mode 100644 index 0000000000..3bccfc01f5 --- /dev/null +++ b/changelog.d/8209-next-gate-c-host-two-level.md @@ -0,0 +1,15 @@ +### Testing + +- `tests/test_next_app_route_dylib.sh` (the #8161 Next App Route dylib gate) no + longer aborts on its first request (#8205). The Rust `provider-host.rs` exported + rustc's System-allocator shim, and `provider-linker.sh` linked the stdlib + provider image with `-flat_namespace`, so the image's `__rust_dealloc` import + bound to the host's shim while the runtime image's shim is mimalloc — the first + cross-image `Vec` drop in `js_node_http_server_process_pending` freed a + mimalloc pointer with libsystem `free()`. The host is now C + (`tests/fixtures/next-app-route/provider-host.c`; same load order, flags, probe + check and event loop, no Rust shim in the executable) and the stdlib image is + linked two-level so its runtime imports bind to `libperry_runtime.dylib`. The + gate's ABI check, forbidden-diagnostic grep and bypass guard are unchanged; the + 100-batch loop can still surface #8163's default-GC `TypeError` until that + lands. From 721e5f554a851f96f5139790e0b8d5c43f19ea2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 16:19:30 +0200 Subject: [PATCH 3/4] fix(test): serve the dylib gate host from the fixture root, not .next/server With the abort gone, every request 500'd: Next opens .next/routes-manifest.json relative to cwd, which does not exist under .next/server. The chunk-require reason for that cwd is obsolete since #8146; the release fixture serves from the package root and is the layout with production evidence. --- tests/test_next_app_route_dylib.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_next_app_route_dylib.sh b/tests/test_next_app_route_dylib.sh index 42a0317e72..dc9ee3b6e9 100755 --- a/tests/test_next_app_route_dylib.sh +++ b/tests/test_next_app_route_dylib.sh @@ -276,9 +276,15 @@ echo "Provider ABI hash: $provider_abi" for cold_start in $(seq 1 "$cold_starts"); do host_log="$scratch/host-$cold_start.log" ( - # Next's generated webpack runtime resolves `./chunks/*.js` from the - # production server root when it loads an on-demand route chunk. - cd "$fixture/.next/server" + # Run from the fixture root, the release-tier layout with production + # evidence (tests/release/packages/next-app-route/fixture.sh): Next + # resolves `.next/routes-manifest.json` against the working directory + # on every request, so serving from `.next/server` 500s each request + # with ENOENT. The old reason to sit in `.next/server` — the webpack + # runtime resolving `./chunks/*.js` from the server root — is gone: + # computed relative chunk requires resolve against the route bundle + # since #8146. + cd "$fixture" if [[ "$host_os" == Darwin ]]; then PORT="$port" HOSTNAME=127.0.0.1 DYLD_LIBRARY_PATH="$providers" \ "$host" "$runtime_library" "$stdlib_library" "$app" From f259dbd8b4c154606a4d42af6f16f87f5aed78bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 16:41:35 +0200 Subject: [PATCH 4/4] fix(test): exec the provider host so kill reaches it, not its subshell Each cold start launched the host inside a (cd; host) & subshell and killed the subshell pid; the host survived orphaned, kept serving, and held the port, so a second cold start could never bind. Observed directly: a leaked provider-host with ppid 1 still listening after the gate exited. --- tests/test_next_app_route_dylib.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_next_app_route_dylib.sh b/tests/test_next_app_route_dylib.sh index dc9ee3b6e9..f3751b626f 100755 --- a/tests/test_next_app_route_dylib.sh +++ b/tests/test_next_app_route_dylib.sh @@ -285,12 +285,17 @@ for cold_start in $(seq 1 "$cold_starts"); do # computed relative chunk requires resolve against the route bundle # since #8146. cd "$fixture" + # `exec` so `$host_pid` below is the host process itself, not this + # subshell. Killing the subshell leaves the host running (observed: + # an orphaned provider-host still serving port $port after the gate + # exited), and a survivor holds the port, so cold start 2 can never + # bind. if [[ "$host_os" == Darwin ]]; then PORT="$port" HOSTNAME=127.0.0.1 DYLD_LIBRARY_PATH="$providers" \ - "$host" "$runtime_library" "$stdlib_library" "$app" + exec "$host" "$runtime_library" "$stdlib_library" "$app" else PORT="$port" HOSTNAME=127.0.0.1 LD_LIBRARY_PATH="$providers" \ - "$host" "$runtime_library" "$stdlib_library" "$app" + exec "$host" "$runtime_library" "$stdlib_library" "$app" fi ) >"$host_log" 2>&1 & host_pid=$!