From 4da84c1910d66936bb1f8b0768f6ec90cae651cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 03:26:08 +0200 Subject: [PATCH] fix(zlib): route captured exports to external backend --- crates/perry-ext-zlib/src/lib.rs | 200 +++++++++++++++++- crates/perry-ext-zlib/src/stream.rs | 6 + crates/perry-stdlib/Cargo.toml | 8 +- .../perry-stdlib/src/common/dispatch/init.rs | 16 ++ .../src/common/dispatch/method_dispatch.rs | 114 ++++++---- test-parity/known_failures.json | 9 - 6 files changed, 295 insertions(+), 58 deletions(-) diff --git a/crates/perry-ext-zlib/src/lib.rs b/crates/perry-ext-zlib/src/lib.rs index 068d0c4b25..dd87b0f220 100644 --- a/crates/perry-ext-zlib/src/lib.rs +++ b/crates/perry-ext-zlib/src/lib.rs @@ -293,10 +293,181 @@ pub unsafe extern "C" fn js_zlib_inflate(data_value: f64, callback_value: f64) { stream::queue_one_shot_callback(data_value, callback_value, "Inflate", inflate_bytes); } +/// `zlib.deflateRaw(data, callback) -> undefined`. +/// +/// # Safety +/// `data_value` and `callback_value` are raw NaN-boxed JS values. +#[no_mangle] +pub unsafe extern "C" fn js_zlib_deflate_raw(data_value: f64, callback_value: f64) { + stream::queue_one_shot_callback(data_value, callback_value, "DeflateRaw", |data| { + deflate_raw_bytes_with(data, Compression::default()) + }); +} + +/// `zlib.inflateRaw(data, callback) -> undefined`. +/// +/// # Safety +/// `data_value` and `callback_value` are raw NaN-boxed JS values. +#[no_mangle] +pub unsafe extern "C" fn js_zlib_inflate_raw(data_value: f64, callback_value: f64) { + stream::queue_one_shot_callback(data_value, callback_value, "InflateRaw", inflate_raw_bytes); +} + +/// `zlib.unzip(data, callback) -> undefined`. +/// +/// # Safety +/// `data_value` and `callback_value` are raw NaN-boxed JS values. +#[no_mangle] +pub unsafe extern "C" fn js_zlib_unzip(data_value: f64, callback_value: f64) { + stream::queue_one_shot_callback(data_value, callback_value, "Unzip", unzip_bytes); +} + +/// Dispatch a captured `node:zlib` export through the external zlib archive. +/// +/// Optimized builds strip the bundled codecs from `perry-stdlib` and link this +/// crate instead. Direct calls still target the exported `js_zlib_*` symbols, +/// while value calls such as `util.promisify(zlib.gzip)` enter the runtime's +/// native-module dispatcher. This is the external counterpart of +/// `perry_stdlib::zlib::js_zlib_native_dispatch`, keeping both call paths on +/// the same implementation. +/// +/// # Safety +/// `method` and `args` must be valid for their corresponding lengths. Every +/// argument is a raw NaN-boxed JS value. +#[no_mangle] +pub unsafe extern "C" fn js_ext_zlib_native_dispatch( + method: *const u8, + method_len: usize, + args: *const f64, + args_len: usize, +) -> f64 { + let undefined = f64::from_bits(perry_ffi::JsValue::UNDEFINED.bits()); + if method.is_null() || method_len == 0 { + return undefined; + } + let name = std::str::from_utf8(std::slice::from_raw_parts(method, method_len)).unwrap_or(""); + let arg = |index: usize| -> f64 { + if index < args_len && !args.is_null() { + *args.add(index) + } else { + undefined + } + }; + let pointer_value = |ptr: *mut BufferHeader| -> f64 { + if ptr.is_null() { + undefined + } else { + f64::from_bits(perry_ffi::JsValue::from_object_ptr(ptr).bits()) + } + }; + let handle_value = |handle: i64| -> f64 { + f64::from_bits(perry_ffi::JsValue::from_object_ptr(handle as usize as *mut u8).bits()) + }; + let callback = || arg(args_len.saturating_sub(1)); + + match name { + "gzipSync" => pointer_value(js_zlib_gzip_sync(arg(0).to_bits() as i64, arg(1))), + "gunzipSync" => pointer_value(js_zlib_gunzip_sync(arg(0).to_bits() as i64)), + "deflateSync" => pointer_value(js_zlib_deflate_sync(arg(0).to_bits() as i64, arg(1))), + "inflateSync" => pointer_value(js_zlib_inflate_sync(arg(0).to_bits() as i64)), + "deflateRawSync" => pointer_value(js_zlib_deflate_raw_sync(arg(0), arg(1))), + "inflateRawSync" => pointer_value(js_zlib_inflate_raw_sync(arg(0))), + "unzipSync" => pointer_value(js_zlib_unzip_sync(arg(0))), + "brotliCompressSync" => { + pointer_value(js_zlib_brotli_compress_sync(arg(0).to_bits() as i64)) + } + "brotliDecompressSync" => { + pointer_value(js_zlib_brotli_decompress_sync(arg(0).to_bits() as i64)) + } + "zstdCompressSync" => pointer_value(js_zlib_zstd_compress_sync(arg(0), arg(1))), + "zstdDecompressSync" => pointer_value(js_zlib_zstd_decompress_sync(arg(0), arg(1))), + "crc32" => js_zlib_crc32(arg(0), if args_len >= 2 { arg(1) } else { 0.0 }), + "gzip" => { + js_zlib_gzip(arg(0), callback()); + undefined + } + "gunzip" => { + js_zlib_gunzip(arg(0), callback()); + undefined + } + "deflate" => { + js_zlib_deflate(arg(0), callback()); + undefined + } + "inflate" => { + js_zlib_inflate(arg(0), callback()); + undefined + } + "deflateRaw" => { + js_zlib_deflate_raw(arg(0), callback()); + undefined + } + "inflateRaw" => { + js_zlib_inflate_raw(arg(0), callback()); + undefined + } + "unzip" => { + js_zlib_unzip(arg(0), callback()); + undefined + } + "brotliCompress" => { + js_zlib_brotli_compress(arg(0), callback()); + undefined + } + "brotliDecompress" => { + js_zlib_brotli_decompress(arg(0), callback()); + undefined + } + "zstdCompress" => { + js_zlib_zstd_compress(arg(0), callback()); + undefined + } + "zstdDecompress" => { + js_zlib_zstd_decompress(arg(0), callback()); + undefined + } + "createGzip" => handle_value(js_zlib_create_gzip(arg(0))), + "createGunzip" => handle_value(js_zlib_create_gunzip(arg(0))), + "createDeflate" => handle_value(js_zlib_create_deflate(arg(0))), + "createInflate" => handle_value(js_zlib_create_inflate(arg(0))), + "createDeflateRaw" => handle_value(js_zlib_create_deflate_raw(arg(0))), + "createInflateRaw" => handle_value(js_zlib_create_inflate_raw(arg(0))), + "createUnzip" => handle_value(js_zlib_create_unzip(arg(0))), + "createBrotliCompress" => handle_value(js_zlib_create_brotli_compress(arg(0))), + "createBrotliDecompress" => handle_value(js_zlib_create_brotli_decompress(arg(0))), + "createZstdCompress" => handle_value(js_zlib_create_zstd_compress(arg(0))), + "createZstdDecompress" => handle_value(js_zlib_create_zstd_decompress(arg(0))), + _ => undefined, + } +} + #[cfg(test)] mod tests { use super::*; - use perry_ffi::{alloc_string, JsString}; + use perry_ffi::{ + alloc_closure, alloc_string, read_buffer_bytes, register_closure_arity, JsString, JsValue, + RawClosureHeader, + }; + use std::cell::Cell; + + thread_local! { + static DISPATCH_CALLBACK_FIRED: Cell = const { Cell::new(false) }; + static DISPATCH_CALLBACK_OK: Cell = const { Cell::new(false) }; + } + + extern "C" fn record_dispatch_callback( + _closure: *const RawClosureHeader, + err: f64, + value: f64, + ) -> f64 { + DISPATCH_CALLBACK_FIRED.with(|fired| fired.set(true)); + let err_is_null = err.to_bits() == JsValue::NULL.bits(); + let output = JsValue::from_bits(value.to_bits()).as_pointer::(); + let output_is_gzip = + read_buffer_bytes(output).is_some_and(|bytes| bytes.starts_with(&[0x1f, 0x8b])); + DISPATCH_CALLBACK_OK.with(|ok| ok.set(err_is_null && output_is_gzip)); + f64::from_bits(JsValue::UNDEFINED.bits()) + } #[test] fn gzip_then_gunzip_round_trips_text() { @@ -358,6 +529,33 @@ mod tests { assert_eq!(crc32_bytes_with_seed(b"", 0), 0); } + #[test] + fn external_native_dispatch_routes_async_gzip_callback() { + DISPATCH_CALLBACK_FIRED.with(|fired| fired.set(false)); + DISPATCH_CALLBACK_OK.with(|ok| ok.set(false)); + + register_closure_arity(record_dispatch_callback as *const u8, 2); + let callback = alloc_closure(record_dispatch_callback as *const u8, 0); + assert!(!callback.is_null()); + + let input = alloc_buffer(b"captured zlib export"); + assert!(!input.is_null()); + let args = [ + f64::from_bits(JsValue::from_object_ptr(input).bits()), + f64::from_bits(JsValue::from_object_ptr(callback).bits()), + ]; + let method = b"gzip"; + let result = unsafe { + js_ext_zlib_native_dispatch(method.as_ptr(), method.len(), args.as_ptr(), args.len()) + }; + + assert_eq!(result.to_bits(), JsValue::UNDEFINED.bits()); + assert_eq!(js_ext_zlib_has_active_handles(), 1); + assert_eq!(unsafe { js_ext_zlib_process_pending() }, 1); + assert!(DISPATCH_CALLBACK_FIRED.with(Cell::get)); + assert!(DISPATCH_CALLBACK_OK.with(Cell::get)); + } + // End-to-end TS smoke tests cover the FFI Buffer allocation path. // Unit tests stay scoped to pure-Rust gzip / gunzip correctness above. #[test] diff --git a/crates/perry-ext-zlib/src/stream.rs b/crates/perry-ext-zlib/src/stream.rs index 7bddffa30e..717208fc9f 100644 --- a/crates/perry-ext-zlib/src/stream.rs +++ b/crates/perry-ext-zlib/src/stream.rs @@ -570,6 +570,12 @@ fn scan_zlib_roots(visitor: &mut GcRootVisitor<'_>) { } fn create_stream(codec: Codec, level: Compression) -> i64 { + // External zlib owns its event queue, so register it directly with the + // runtime when the first stream is created. In particular, do not rely on + // perry-stdlib's async pump registration: zlib streams are synchronous and + // forcing the Tokio runtime just to deliver their deferred events is both + // unnecessary and unsafe in stripped well-known-wrapper builds. + ensure_aux_pump_registered(); ensure_gc_scanner_registered(); let mut s = statics().lock().unwrap(); let id = s.next_id; diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index ffcd946aad..54f7f20b84 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -147,9 +147,11 @@ external-net-pump = ["async-runtime"] # stream-event queue (`js_ext_zlib_process_pending` / # `js_ext_zlib_has_active_handles`), and routes lost-static-type # `gz.write()` / `.on()` / `.pipe()` calls through -# `js_ext_zlib_dispatch_method`. Mirrors `external-net-pump`. No async -# runtime needed (zlib compression is synchronous), but the pump call -# sites live in the async-bridge module which is `async-runtime`-gated. +# `js_ext_zlib_dispatch_method`. It also registers +# `js_ext_zlib_native_dispatch` for captured module exports such as +# `util.promisify(zlib.gzip)`. Mirrors `external-net-pump`. No async runtime +# needed (zlib compression is synchronous), but the pump call sites live in +# the async-bridge module which is `async-runtime`-gated. external-zlib-pump = ["async-runtime"] # Activated by `optimized_libs::build_optimized_libs` (v0.5.714) when diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index 5bfd6d08bc..cc1b289db2 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -660,6 +660,22 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { perry_runtime::buffer::js_set_crypto_key_death_hook(crate::webcrypto::crypto_key_buffer_died); #[cfg(feature = "compression-gzip")] perry_runtime::js_set_native_zlib_dispatch(crate::zlib::js_zlib_native_dispatch); + // Optimized builds route `node:zlib` to perry-ext-zlib and compile the + // bundled codec module out. Captured exports (`const gzip = zlib.gzip`) and + // `util.promisify(zlib.gzip)` still enter the runtime's by-name dispatcher, + // so install the external archive's mirror when it is the active backend. + #[cfg(all(feature = "external-zlib-pump", not(feature = "compression-gzip")))] + { + extern "C" { + fn js_ext_zlib_native_dispatch( + method: *const u8, + method_len: usize, + args: *const f64, + args_len: usize, + ) -> f64; + } + perry_runtime::js_set_native_zlib_dispatch(js_ext_zlib_native_dispatch); + } perry_runtime::js_set_native_querystring_dispatch( crate::querystring::js_querystring_native_dispatch, ); diff --git a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs index 12270318a9..eb2562dab8 100644 --- a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs @@ -1,6 +1,64 @@ use super::super::handle::*; use super::*; +/// Route external zlib stream methods before the generic dispatcher creates +/// owned method/argument copies. The external implementation is synchronous: +/// it consumes string/buffer arguments before returning and copies callbacks or +/// pipe destinations into its rooted registries. Keeping this FFI boundary +/// allocation-free on the stdlib side also avoids freeing a temporary with the +/// wrong private allocator shim in stripped well-known-wrapper links. +#[cfg(feature = "external-zlib-pump")] +unsafe fn try_dispatch_external_zlib_stream( + handle: i64, + method_name_ptr: *const u8, + method_name_len: usize, + args_ptr: *const f64, + args_len: usize, +) -> Option { + if method_name_ptr.is_null() || method_name_len == 0 { + return None; + } + let method_bytes = std::slice::from_raw_parts(method_name_ptr, method_name_len); + let method_name = std::str::from_utf8(method_bytes).ok()?; + if !matches!( + method_name, + "write" + | "end" + | "on" + | "once" + | "addListener" + | "pipe" + | "flush" + | "params" + | "reset" + | "close" + | "destroy" + ) { + return None; + } + + extern "C" { + fn js_ext_zlib_is_stream_handle(handle: i64) -> i32; + fn js_ext_zlib_dispatch_method( + handle: i64, + method_ptr: *const u8, + method_len: usize, + args_ptr: *const f64, + args_len: usize, + ) -> f64; + } + if js_ext_zlib_is_stream_handle(handle) == 0 { + return None; + } + Some(js_ext_zlib_dispatch_method( + handle, + method_name_ptr, + method_name_len, + args_ptr, + args_len, + )) +} + /// Dispatch a method call on a handle-based object. #[no_mangle] pub unsafe extern "C" fn js_handle_method_dispatch( @@ -10,6 +68,17 @@ pub unsafe extern "C" fn js_handle_method_dispatch( args_ptr: *const f64, args_len: usize, ) -> f64 { + #[cfg(feature = "external-zlib-pump")] + if let Some(value) = try_dispatch_external_zlib_stream( + handle, + method_name_ptr, + method_name_len, + args_ptr, + args_len, + ) { + return value; + } + let method_name_owned = if method_name_ptr.is_null() || method_name_len == 0 { String::new() } else { @@ -439,51 +508,6 @@ pub unsafe extern "C" fn js_handle_method_dispatch( return dispatch_zlib_stream(handle, method_name, &args); } - // External zlib path (#1843): when the well-known flip routes `node:zlib` - // to perry-ext-zlib, the stream handle + dispatch live in perry-ext-zlib. - // Same registry-gated contract; the per-method match runs inside - // `js_ext_zlib_dispatch_method`. This may coexist with `compression` in - // no-auto test builds that use the full stdlib plus external archives. - #[cfg(feature = "external-zlib-pump")] - if matches!( - method_name, - "write" - | "end" - | "on" - | "once" - | "addListener" - | "pipe" - | "flush" - | "params" - | "reset" - | "close" - | "destroy" - ) { - extern "C" { - fn js_ext_zlib_is_stream_handle(handle: i64) -> i32; - fn js_ext_zlib_dispatch_method( - handle: i64, - method_ptr: *const u8, - method_len: usize, - args_ptr: *const f64, - args_len: usize, - ) -> f64; - } - if unsafe { js_ext_zlib_is_stream_handle(handle) } != 0 { - // Register the stdlib pump (#1843) — see the bundled arm above. - crate::common::async_bridge::ensure_pump_registered(); - return unsafe { - js_ext_zlib_dispatch_method( - handle, - method_name.as_ptr(), - method_name.len(), - args.as_ptr(), - args.len(), - ) - }; - } - } - #[cfg(feature = "external-http-client-pump")] if let Some(value) = unsafe { super::super::dispatch_http::dispatch_client_request_method(handle, method_name, &args) diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index 377fb161db..9cf9db1d7b 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -892,15 +892,6 @@ "linux" ] }, - "test_parity_zlib": { - "issue": "8271", - "added": "2026-08-17", - "category": "bug-open", - "reason": "The initial compile failure was caused by missing js_zlib_unzip_sync/js_zlib_crc32 exports in perry-ext-zlib; those exports are now implemented. The full fixture next exits 13 at await promisify(zlib.gzip): a direct callback probe fires, but the util.promisify wrapper remains unsettled. Independent promisify bridge gap; confirmed in the #8271 audit.", - "platforms": [ - "linux" - ] - }, "test_pdf_create_smoke": { "issue": "8271", "added": "2026-08-17",