Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog.d/7029-stdlib-cherry-pick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**Stdlib cherry-pick: auto-optimized binaries only link the stdlib subsystems the program actually uses** — two coarse inclusion points split into finer-grained units:

- **`crypto` is no longer force-enabled for every stdlib-linking build.** The auto-optimize driver used to insert perry-stdlib's `crypto` feature unconditionally (a leftover safety net from the old `perry/updater` → `js_crypto_ed25519_verify` extern), so a program that only imported `node:zlib` still linked RSA/EC/Ed25519/Ed448/ML-KEM/x509/JWT/bcrypt/argon2. The feature now joins only via the import mapping, the `crypto.*`-builtin/WebCrypto HIR gates, or a new codegen prefix net: any emitted `js_crypto_*` / `js_webcrypto_*` call recorded by `perry_codegen::ext_registry` flips the feature (with an object-cache-replayable marker, so warm caches reproduce the flip). An `async-runtime` floor stays forced — the always-on worker_threads/readline bridge needs it, exactly as it (transitively) always did.
- **`compression` split per codec**: `compression-gzip` (flate2 base, gates `pub mod zlib`), `compression-brotli`, and `compression-zstd` (each implying the base); the `compression` umbrella remains as their union, so default/`full` builds and `--features compression` callers are unchanged. `import 'node:zlib'` selects only the gzip/deflate family; Brotli/zstd back-ends are added when HIR usage detection sees a matching API token (`brotli*`/`BROTLI_*`, `zstd*`/`Zstd*`/`ZSTD_*`), or unconditionally when deferred dynamic code could name a codec at runtime. When the well-known flip routes `node:zlib` to `perry-ext-zlib`, the sub-features are dropped along with the base so the ext crate's `js_zlib_*` symbols can't be duplicated.

Measured on macOS arm64 (stripped release binaries, auto-optimize path): gzip-only `node:zlib` program 9,629,736 B → 6,618,416 B (−31.3%); `node:zlib` + `node:events` mid-size program 9,662,968 B → 6,668,160 B (−31.0%); `crypto.randomUUID()` builtin program unchanged at 7.86 MB (correctly still links crypto); hello-world unchanged (runtime-only link, no stdlib). Feature strings feed `auto_optimized_cache_key`, so the new sets produce fresh build stamps automatically.
91 changes: 91 additions & 0 deletions crates/perry-codegen/src/ext_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,36 @@ pub(crate) fn record_ffi_call(symbol: &str) {
return;
}
}

// Prefix safety net (stdlib cherry-pick): the auto-optimize driver no
// longer force-enables perry-stdlib's `crypto` feature for every build,
// so ANY codegen-emitted call into the node:crypto / WebCrypto surface
// must flip the feature here — the import mapping and the
// `uses_crypto_builtins` HIR gate cover the known shapes, and this rule
// covers everything else (compiled-package lowering, future HIR
// variants) without maintaining a per-symbol table: every FFI defined
// by perry-stdlib's crypto/webcrypto modules carries one of these two
// prefixes. `js_webcrypto_illegal_constructor` is excluded — it lives
// in perry-runtime (object/global_this/ctor_thunks.rs), so emitting it
// needs no stdlib feature. The MODULE_CAPTURE marker is the prefix
// itself: replaying it through this function (object-cache manifest,
// #6439) re-enters this arm and reproduces the same owner.
if symbol.starts_with("js_crypto_")
|| (symbol.starts_with("js_webcrypto_") && symbol != "js_webcrypto_illegal_constructor")
{
let owner = OwnerKind::Stdlib {
feature: Some("crypto"),
};
{
let mut guard = USED_PROVIDERS.lock().expect("USED_PROVIDERS poisoned");
guard.get_or_insert_with(HashSet::new).insert(owner);
}
MODULE_CAPTURE.with(|cell| {
if let Some(set) = cell.borrow_mut().as_mut() {
set.insert("js_crypto_");
}
});
}
}

/// Start capturing this thread's registry-symbol emissions. Call
Expand Down Expand Up @@ -872,6 +902,67 @@ mod tests {
}
}

/// Stdlib cherry-pick: the driver no longer force-enables perry-stdlib's
/// `crypto` feature, so every codegen-emitted `js_crypto_*` /
/// `js_webcrypto_*` call must flip it through the prefix net — including
/// the `"js_crypto_"` marker persisted to (and replayed from) the object
/// cache's FFI manifest. `js_webcrypto_illegal_constructor` lives in
/// perry-runtime and must NOT flip the feature.
#[test]
fn emitted_crypto_symbols_route_to_stdlib_crypto_feature() {
let _guard = PROVIDER_TEST_LOCK
.lock()
.expect("provider test lock poisoned");
let crypto_owner = OwnerKind::Stdlib {
feature: Some("crypto"),
};
for symbol in [
"js_crypto_sha256",
"js_crypto_create_hash",
"js_crypto_ed25519_verify",
"js_webcrypto_digest",
// Object-cache replay marker (see record_ffi_call).
"js_crypto_",
] {
assert_symbol_routes_to(symbol, crypto_owner);
}

let _ = take_used_providers();
record_ffi_call("js_webcrypto_illegal_constructor");
let got = take_used_providers();
assert!(
!got.contains(&crypto_owner),
"js_webcrypto_illegal_constructor is a perry-runtime thunk and must \
not flip the stdlib crypto feature, got {got:?}"
);
}

/// The prefix net must persist a replayable marker in the per-module
/// capture so a warm object cache reproduces the same feature flip
/// (#6439 shape).
#[test]
fn crypto_prefix_net_marker_survives_module_capture_replay() {
let _guard = PROVIDER_TEST_LOCK
.lock()
.expect("provider test lock poisoned");
let _ = take_used_providers();

begin_module_capture();
record_ffi_call("js_crypto_pbkdf2");
let captured = take_module_capture();
assert_eq!(captured, vec!["js_crypto_"]);

let _ = take_used_providers();
replay_ffi_symbols(captured);
let got = take_used_providers();
assert!(
got.contains(&OwnerKind::Stdlib {
feature: Some("crypto")
}),
"replaying the captured marker must reproduce the crypto flip, got {got:?}"
);
}

/// #5140 regression: `new EventEmitter()` / `.on` / `.emit` /
/// `.removeAllListeners` lower to `js_event_emitter_*` helpers off the
/// class name alone, so a program that imports `EventEmitter` from a
Expand Down
27 changes: 23 additions & 4 deletions crates/perry-stdlib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -248,10 +248,29 @@ bundled-jsonwebtoken = ["dep:jsonwebtoken", "dep:p256", "dep:rsa", "dep:spki"]
# routes to perry-ext-ethers when `import 'ethers'` is detected.
bundled-ethers = []

# Compression (zlib + brotli)
# `brotli` rides under the same `compression` umbrella; needed by
# axios's `zlib.createBrotliDecompress` feature-check (#axios e2e).
compression = ["dep:flate2", "dep:brotli", "dep:zstd"]
# Compression (node:zlib) — per-codec split (stdlib cherry-pick).
# `compression-gzip` is the base codec family (gzip/deflate/inflate/
# unzip/crc32 via flate2) and gates `pub mod zlib` itself; the Brotli
# and zstd codecs layer on top and imply it (the zlib module's stream
# framework, options validation and pump are shared). The auto-optimize
# feature computation maps `import 'node:zlib'` to `compression-gzip`
# and adds `compression-brotli` / `compression-zstd` only when the HIR
# references a Brotli / zstd API token (see
# `collect_modules/feature_detect.rs`), falling back to the full
# umbrella when deferred dynamic code could call codecs by runtime
# strings. The `compression` umbrella stays as the union for
# backwards compatibility (`--features compression` and the default
# `full` build are byte-identical to the pre-split archive).
# `async-runtime` is explicit here (not inherited): zlib's deferred
# stream/one-shot events are queued through `common::async_bridge`'s
# pump, which only compiles under `async-runtime`. Historically the
# auto-optimize path always enabled it transitively (the force-added
# `crypto` feature implied it), so `--features compression` never
# compiled standalone — now it does.
compression-gzip = ["dep:flate2", "async-runtime"]
compression-brotli = ["compression-gzip", "dep:brotli"]
compression-zstd = ["compression-gzip", "dep:zstd"]
compression = ["compression-gzip", "compression-brotli", "compression-zstd"]

# Email (nodemailer) — `email` umbrella retained for
# backwards-compat; v0.5.558's well-known flip toggles
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-stdlib/src/common/async_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ pub extern "C" fn js_stdlib_process_pending() -> i32 {
// buffer input across `.write()` and queue 'data'/'end' on `.end()`;
// drained + dispatched to listeners (and forwarded to `.pipe()` dests)
// here on the main thread. Bundled path (perry-stdlib's own zlib mod):
#[cfg(feature = "compression")]
#[cfg(feature = "compression-gzip")]
{
count += unsafe { crate::zlib::js_zlib_process_pending() };
}
Expand Down Expand Up @@ -862,7 +862,7 @@ pub extern "C" fn js_stdlib_has_active_handles() -> i32 {
// 'data'/'end' events are still waiting to be drained, so a purely-
// synchronous `createGzip().write(x).end()` program doesn't exit before
// its listeners fire. Bundled path:
#[cfg(feature = "compression")]
#[cfg(feature = "compression-gzip")]
{
if crate::zlib::js_zlib_has_active_handles() != 0 {
return 1;
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-stdlib/src/common/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ pub(crate) use fastify_net_zlib::dispatch_external_net_socket;
not(target_os = "android")
))]
pub(crate) use fastify_net_zlib::dispatch_net_socket;
#[cfg(feature = "compression")]
#[cfg(feature = "compression-gzip")]
pub(crate) use fastify_net_zlib::dispatch_zlib_stream;

pub(crate) type EventEmitterOn = unsafe extern "C" fn(i64, i64, i64) -> i64;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ pub(crate) unsafe fn dispatch_net_socket(handle: i64, method: &str, args: &[f64]
/// `.close` lose their static type and arrive here. Compression is synchronous
/// and buffered in the runtime: `.write()` accumulates input, `.end()` runs the
/// codec and queues 'data'/'end' onto the deferred-event pump.
#[cfg(feature = "compression")]
#[cfg(feature = "compression-gzip")]
pub(crate) unsafe fn dispatch_zlib_stream(handle: i64, method: &str, args: &[f64]) -> f64 {
fn unbox_to_i64(v: f64) -> i64 {
(v.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-stdlib/src/common/dispatch/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() {
// address inherits the dead key's material).
#[cfg(feature = "crypto")]
perry_runtime::buffer::js_set_crypto_key_death_hook(crate::webcrypto::crypto_key_buffer_died);
#[cfg(feature = "compression")]
#[cfg(feature = "compression-gzip")]
perry_runtime::js_set_native_zlib_dispatch(crate::zlib::js_zlib_native_dispatch);
perry_runtime::js_set_native_querystring_dispatch(
crate::querystring::js_querystring_native_dispatch,
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-stdlib/src/common/dispatch/method_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ pub unsafe extern "C" fn js_handle_method_dispatch(
// Gated on the registry AND the method vocabulary so a handle-id reused
// across another subsystem's registry can't misroute (handle id-spaces
// aren't unified — see the long comment above).
#[cfg(feature = "compression")]
#[cfg(feature = "compression-gzip")]
if matches!(
method_name,
"write"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ pub unsafe extern "C" fn js_handle_property_dispatch(
// "function". The actual call dispatch is HANDLE_METHOD_DISPATCH
// (above), but feature-checks read through the property table — we
// bind a closure here so the typeof short-circuit sees "function".
#[cfg(feature = "compression")]
#[cfg(feature = "compression-gzip")]
if crate::zlib::is_zlib_stream_handle(handle) {
if property_name == "bytesWritten" {
return crate::zlib::zlib_stream_bytes_written(handle);
Expand Down
8 changes: 6 additions & 2 deletions crates/perry-stdlib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,13 @@ pub mod crypto_e2e;
pub use crypto_e2e::*;

// === Compression ===
#[cfg(feature = "compression")]
// Gated on `compression-gzip` (the base codec family) rather than the
// `compression` umbrella so the auto-optimize rebuild can cherry-pick
// codecs: `compression-brotli` / `compression-zstd` imply
// `compression-gzip`, and `compression` is the union of all three.
#[cfg(feature = "compression-gzip")]
pub mod zlib;
#[cfg(feature = "compression")]
#[cfg(feature = "compression-gzip")]
pub use zlib::*;

// === Email ===
Expand Down
Loading
Loading