From b275c2e04a487179c0006d16f000c10f33dc4241 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 18:24:34 +0000 Subject: [PATCH 01/19] fix(http): rebuild client-pump stdlib for node:http under PERRY_NO_AUTO_OPTIMIZE (#10466) --- .../compile/optimized_libs/no_auto.rs | 153 +++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/crates/perry/src/commands/compile/optimized_libs/no_auto.rs b/crates/perry/src/commands/compile/optimized_libs/no_auto.rs index 5ce63789ac..9ea0b16f8e 100644 --- a/crates/perry/src/commands/compile/optimized_libs/no_auto.rs +++ b/crates/perry/src/commands/compile/optimized_libs/no_auto.rs @@ -42,8 +42,9 @@ pub(crate) fn resolve_no_auto_optimized_libs( if matches!(format, OutputFormat::Text) && verbose > 0 { eprintln!(" auto-optimize: skipped; using prebuilt target/release/libperry_*.a"); } + let iteration_set = well_known_iteration_set(ctx); let well_known_libs = if std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_none() { - resolve_prebuilt_ext_libs(&well_known_iteration_set(ctx), target, format, verbose) + resolve_prebuilt_ext_libs(&iteration_set, target, format, verbose) } else { Vec::new() }; @@ -61,6 +62,34 @@ pub(crate) fn resolve_no_auto_optimized_libs( } else { (None, None) }; + // #10466 — the prebuilt `libperry_stdlib.a` is built with the default + // `full` feature set, which deliberately excludes + // `external-http-client-pump` (adding it to `full` would force every + // no-auto program, HTTP client or not, to link `libperry_ext_http.a` — + // see the Cargo.toml comment on `full`, #5983/#8587). Without that + // feature, perry-stdlib's dynamic-dispatch fallbacks for the + // `node:http`/`node:https` CLIENT surface (`res.headers`, `res.req`, + // `res.pipe()`, `req.setHeader()`, `req.setTimeout()`, …) don't exist in + // the linked archive at all — they read `undefined` with no compile-time + // warning. When the program imports `http`/`https`, rebuild just + // perry-stdlib-static with that feature added on top of `full`, the same + // on-demand-rebuild shape `build_optional_runtime` uses for `wasm-host`. + // A prior wasm/native-addon rebuild above already producing a stdlib + // archive (Windows) takes precedence; this only fills the common case + // where `stdlib` is still `None`. + let stdlib = stdlib.or_else(|| { + let imports_http_client = iteration_set.iter().any(|m| { + matches!( + m.strip_prefix("node:").unwrap_or(m.as_str()), + "http" | "https" + ) + }); + if imports_http_client { + build_http_client_pump_stdlib(target, format, verbose) + } else { + None + } + }); OptimizedLibs { runtime, stdlib, @@ -70,6 +99,128 @@ pub(crate) fn resolve_no_auto_optimized_libs( } } +/// #10466 — on-demand rebuild of `perry-stdlib-static` alone (default `full` +/// features plus `external-http-client-pump`) into a dedicated target dir, +/// so the no-auto path's client-side `node:http`/`node:https` dynamic +/// dispatch (`res.headers`/`res.req`/`res.pipe()`/`req.setHeader()`/…) has +/// somewhere to link against without forcing every other no-auto program to +/// carry `libperry_ext_http.a`. Mirrors `build_optional_runtime`'s +/// `wasm-host` rebuild; returns `None` on any failure (no source on disk, no +/// cargo, build error) so the caller falls back to the prebuilt full stdlib +/// (same #10466 gap, not a new failure mode). +fn build_http_client_pump_stdlib( + target: Option<&str>, + format: OutputFormat, + verbose: u8, +) -> Option { + let workspace_root = cargo_target_dir_path(find_perry_workspace_root()?); + let crate_dir = workspace_root.join("crates").join("perry-stdlib-static"); + if !crate_dir.is_dir() { + if matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!( + " http-client-pump (no-auto): skipping stdlib rebuild — crate source not found at {}", + crate_dir.display() + ); + } + return None; + } + + if matches!(format, OutputFormat::Text) { + println!( + " http-client-pump (no-auto): rebuilding stdlib with external-http-client-pump feature" + ); + } + + // Dedicated target dir so the prebuilt libperry_stdlib.a in + // target/release is not overwritten. Cargo's incremental cache makes + // repeat builds a no-op. + let relative_target_dir = PathBuf::from("target").join("perry-no-auto-http-pump"); + let pump_target_dir = cargo_target_dir_path(workspace_root.join(&relative_target_dir)); + let cargo_target_dir = if cfg!(windows) { + relative_target_dir + } else { + pump_target_dir.clone() + }; + + let mut cargo_cmd = Command::new("cargo"); + cargo_cmd + .current_dir(&workspace_root) + .env("CARGO_TARGET_DIR", &cargo_target_dir) + .arg("build") + .arg("--release") + .arg("-p") + .arg("perry-stdlib-static") + .arg("--features") + .arg("perry-stdlib/external-http-client-pump"); + if let Some(triple) = rust_target_triple(target) { + cargo_cmd.arg("--target").arg(triple); + } + if is_android_target(target) { + if let Some(ndk) = std::env::var_os("ANDROID_NDK_HOME") { + for (k, v) in android_cross_env(std::path::Path::new(&ndk), target) { + cargo_cmd.env(k, v); + } + } + } + if matches!(target, Some("harmonyos") | Some("harmonyos-simulator")) { + match find_harmonyos_sdk() { + Some(sdk) => { + for (k, v) in harmonyos_cross_env(&sdk, target) { + cargo_cmd.env(k, v); + } + } + None => { + if matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!( + " http-client-pump (no-auto): skipping stdlib rebuild — OHOS SDK not found (set OHOS_SDK_HOME)" + ); + } + return None; + } + } + } + + match super::super::tool_output::run_internal_tool(&mut cargo_cmd, verbose) { + Ok(status) if status.success() => {} + Ok(status) => { + if matches!(format, OutputFormat::Text) { + eprintln!( + " http-client-pump (no-auto): cargo build for http-client-pump stdlib failed ({status})" + ); + } + return None; + } + Err(err) => { + if matches!(format, OutputFormat::Text) { + eprintln!(" http-client-pump (no-auto): failed to spawn cargo ({err})"); + } + return None; + } + } + + let lib_name = if is_windows_target(target) { + "perry_stdlib.lib" + } else { + "libperry_stdlib.a" + }; + let mut release_dir = pump_target_dir; + if let Some(triple) = rust_target_triple(target) { + release_dir = release_dir.join(triple); + } + let release_dir = release_dir.join("release"); + let stdlib = release_dir.join(lib_name); + if !stdlib.exists() { + if matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!( + " http-client-pump (no-auto): cargo finished but {lib_name} was not produced at {}", + stdlib.display() + ); + } + return None; + } + Some(stdlib) +} + /// Build `perry-runtime-static` with default features + `perry-runtime/wasm-host` /// into a dedicated target dir so the prebuilt `libperry_runtime.a` is not /// clobbered. Windows also builds `perry-stdlib-static` in the same graph and From 1885b8ea6ea4b49ffd09a5dd8f14dd041ebefd60 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 19:16:53 +0000 Subject: [PATCH 02/19] fix(http): rebuild perry-ext-http alongside the http-client-pump stdlib (tokio unification) --- .../compile/optimized_libs/no_auto.rs | 105 +++++++++++------- 1 file changed, 65 insertions(+), 40 deletions(-) diff --git a/crates/perry/src/commands/compile/optimized_libs/no_auto.rs b/crates/perry/src/commands/compile/optimized_libs/no_auto.rs index 9ea0b16f8e..8bbce7b833 100644 --- a/crates/perry/src/commands/compile/optimized_libs/no_auto.rs +++ b/crates/perry/src/commands/compile/optimized_libs/no_auto.rs @@ -43,7 +43,7 @@ pub(crate) fn resolve_no_auto_optimized_libs( eprintln!(" auto-optimize: skipped; using prebuilt target/release/libperry_*.a"); } let iteration_set = well_known_iteration_set(ctx); - let well_known_libs = if std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_none() { + let mut well_known_libs = if std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_none() { resolve_prebuilt_ext_libs(&iteration_set, target, format, verbose) } else { Vec::new() @@ -68,15 +68,20 @@ pub(crate) fn resolve_no_auto_optimized_libs( // no-auto program, HTTP client or not, to link `libperry_ext_http.a` — // see the Cargo.toml comment on `full`, #5983/#8587). Without that // feature, perry-stdlib's dynamic-dispatch fallbacks for the - // `node:http`/`node:https` CLIENT surface (`res.headers`, `res.req`, - // `res.pipe()`, `req.setHeader()`, `req.setTimeout()`, …) don't exist in - // the linked archive at all — they read `undefined` with no compile-time - // warning. When the program imports `http`/`https`, rebuild just - // perry-stdlib-static with that feature added on top of `full`, the same - // on-demand-rebuild shape `build_optional_runtime` uses for `wasm-host`. - // A prior wasm/native-addon rebuild above already producing a stdlib - // archive (Windows) takes precedence; this only fills the common case - // where `stdlib` is still `None`. + // `node:http`/`node:https` CLIENT surface (`res.pipe()`, `req.setHeader()`, + // `req.setTimeout()`, …) don't exist in the linked archive at all — they + // read `undefined` with no compile-time warning. When the program + // imports `http`/`https`, rebuild perry-stdlib-static with that feature + // added on top of `full`, the same on-demand-rebuild shape + // `build_optional_runtime` uses for `wasm-host` — AND, in the SAME cargo + // invocation, `perry-ext-http` itself: two archives built in separate + // cargo invocations can bundle different tokio compilations even from an + // identical Cargo.lock (`runtime_compat.rs`'s link-time guard exists + // exactly for this), so a stdlib-only rebuild would leave the fresh + // stdlib archive unlinkable against whatever `libperry_ext_http.a` + // `resolve_prebuilt_ext_libs` found on disk. A prior wasm/native-addon + // rebuild above already producing a stdlib archive (Windows) takes + // precedence; this only fills the common case where `stdlib` is `None`. let stdlib = stdlib.or_else(|| { let imports_http_client = iteration_set.iter().any(|m| { matches!( @@ -84,11 +89,18 @@ pub(crate) fn resolve_no_auto_optimized_libs( "http" | "https" ) }); - if imports_http_client { - build_http_client_pump_stdlib(target, format, verbose) - } else { - None + if !imports_http_client { + return None; } + let (stdlib_path, ext_http_path) = build_http_client_pump_stdlib(target, format, verbose)?; + // Replace whatever `libperry_ext_http.a` `resolve_prebuilt_ext_libs` + // found (built in a different cargo invocation, so a different + // tokio compilation) with the one just built alongside this stdlib, + // in the same invocation — the pair the link-time guard requires. + let ext_http_name = ext_http_path.file_name().map(|n| n.to_owned()); + well_known_libs.retain(|p| p.file_name() != ext_http_name.as_deref()); + well_known_libs.push(ext_http_path); + Some(stdlib_path) }); OptimizedLibs { runtime, @@ -99,27 +111,35 @@ pub(crate) fn resolve_no_auto_optimized_libs( } } -/// #10466 — on-demand rebuild of `perry-stdlib-static` alone (default `full` +/// #10466 — on-demand rebuild of `perry-stdlib-static` (default `full` /// features plus `external-http-client-pump`) into a dedicated target dir, /// so the no-auto path's client-side `node:http`/`node:https` dynamic -/// dispatch (`res.headers`/`res.req`/`res.pipe()`/`req.setHeader()`/…) has +/// dispatch (`res.pipe()`/`req.setHeader()`/`req.setTimeout()`/…) has /// somewhere to link against without forcing every other no-auto program to -/// carry `libperry_ext_http.a`. Mirrors `build_optional_runtime`'s -/// `wasm-host` rebuild; returns `None` on any failure (no source on disk, no -/// cargo, build error) so the caller falls back to the prebuilt full stdlib -/// (same #10466 gap, not a new failure mode). +/// carry `libperry_ext_http.a`. `perry-ext-http` is rebuilt **in the same +/// cargo invocation** — two archives from separate invocations can bundle +/// different tokio compilations even off an identical `Cargo.lock` +/// (`runtime_compat.rs`'s link-time guard exists exactly for this pair), so +/// a stdlib-only rebuild would leave the fresh stdlib unlinkable against +/// whatever `libperry_ext_http.a` `resolve_prebuilt_ext_libs` found on disk. +/// Mirrors `build_optional_runtime`'s `wasm-host` rebuild; returns `None` on +/// any failure (no source on disk, no cargo, build error) so the caller +/// falls back to the prebuilt full stdlib (same #10466 gap, not a new +/// failure mode). Returns `(stdlib_archive, ext_http_archive)`. fn build_http_client_pump_stdlib( target: Option<&str>, format: OutputFormat, verbose: u8, -) -> Option { +) -> Option<(PathBuf, PathBuf)> { let workspace_root = cargo_target_dir_path(find_perry_workspace_root()?); - let crate_dir = workspace_root.join("crates").join("perry-stdlib-static"); - if !crate_dir.is_dir() { + let stdlib_crate_dir = workspace_root.join("crates").join("perry-stdlib-static"); + let ext_http_crate_dir = workspace_root.join("crates").join("perry-ext-http"); + if !stdlib_crate_dir.is_dir() || !ext_http_crate_dir.is_dir() { if matches!(format, OutputFormat::Text) && verbose > 0 { eprintln!( - " http-client-pump (no-auto): skipping stdlib rebuild — crate source not found at {}", - crate_dir.display() + " http-client-pump (no-auto): skipping rebuild — crate source not found at {} or {}", + stdlib_crate_dir.display(), + ext_http_crate_dir.display() ); } return None; @@ -127,7 +147,7 @@ fn build_http_client_pump_stdlib( if matches!(format, OutputFormat::Text) { println!( - " http-client-pump (no-auto): rebuilding stdlib with external-http-client-pump feature" + " http-client-pump (no-auto): rebuilding stdlib (external-http-client-pump) + perry-ext-http together" ); } @@ -150,6 +170,8 @@ fn build_http_client_pump_stdlib( .arg("--release") .arg("-p") .arg("perry-stdlib-static") + .arg("-p") + .arg("perry-ext-http") .arg("--features") .arg("perry-stdlib/external-http-client-pump"); if let Some(triple) = rust_target_triple(target) { @@ -172,7 +194,7 @@ fn build_http_client_pump_stdlib( None => { if matches!(format, OutputFormat::Text) && verbose > 0 { eprintln!( - " http-client-pump (no-auto): skipping stdlib rebuild — OHOS SDK not found (set OHOS_SDK_HOME)" + " http-client-pump (no-auto): skipping rebuild — OHOS SDK not found (set OHOS_SDK_HOME)" ); } return None; @@ -185,7 +207,7 @@ fn build_http_client_pump_stdlib( Ok(status) => { if matches!(format, OutputFormat::Text) { eprintln!( - " http-client-pump (no-auto): cargo build for http-client-pump stdlib failed ({status})" + " http-client-pump (no-auto): cargo build for http-client-pump stdlib+ext-http failed ({status})" ); } return None; @@ -198,27 +220,30 @@ fn build_http_client_pump_stdlib( } } - let lib_name = if is_windows_target(target) { - "perry_stdlib.lib" + let (stdlib_name, ext_http_name) = if is_windows_target(target) { + ("perry_stdlib.lib", "perry_ext_http.lib") } else { - "libperry_stdlib.a" + ("libperry_stdlib.a", "libperry_ext_http.a") }; let mut release_dir = pump_target_dir; if let Some(triple) = rust_target_triple(target) { release_dir = release_dir.join(triple); } let release_dir = release_dir.join("release"); - let stdlib = release_dir.join(lib_name); - if !stdlib.exists() { - if matches!(format, OutputFormat::Text) && verbose > 0 { - eprintln!( - " http-client-pump (no-auto): cargo finished but {lib_name} was not produced at {}", - stdlib.display() - ); + let stdlib = release_dir.join(stdlib_name); + let ext_http = release_dir.join(ext_http_name); + for path in [&stdlib, &ext_http] { + if !path.exists() { + if matches!(format, OutputFormat::Text) && verbose > 0 { + eprintln!( + " http-client-pump (no-auto): cargo finished but {} was not produced", + path.display() + ); + } + return None; } - return None; } - Some(stdlib) + Some((stdlib, ext_http)) } /// Build `perry-runtime-static` with default features + `perry-runtime/wasm-host` From b8001a1dc7bd49ef54b7b4e84830efde82181618 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 20:28:08 +0000 Subject: [PATCH 03/19] changelog: #10667 --- changelog.d/10667-no-auto-http-client-pump.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/10667-no-auto-http-client-pump.md diff --git a/changelog.d/10667-no-auto-http-client-pump.md b/changelog.d/10667-no-auto-http-client-pump.md new file mode 100644 index 0000000000..efc7fcffa2 --- /dev/null +++ b/changelog.d/10667-no-auto-http-client-pump.md @@ -0,0 +1,9 @@ +Fixed `node:http`/`node:https` client dynamic dispatch (`res.pipe()`, `req.setHeader()`, `req.setTimeout()`, and +the rest of the client `IncomingMessage`/`ClientRequest` fallback surface) being silently absent under +`PERRY_NO_AUTO_OPTIMIZE=1`: the prebuilt stdlib archive is built with the default `full` feature set, which +deliberately excludes `external-http-client-pump` (folding it into `full` would force every no-auto program to +carry `libperry_ext_http.a`). When the program imports `http`/`https`, the no-auto path now rebuilds +`perry-stdlib-static` with that feature on top of `full`, in the same cargo invocation as `perry-ext-http` itself +(two archives built in separate invocations can carry different tokio compilations even off an identical +`Cargo.lock`, which the existing link-time guard in `runtime_compat.rs` refuses to link). Mirrors the on-demand +`wasm-host` rebuild `build_optional_runtime` already does for `WebAssembly.*` support (#10466). From d1c101121d689d73915512d2772d533e24a28658 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 20:48:41 +0000 Subject: [PATCH 04/19] fix(test): update no-auto well-known test for the http-client-pump rebuild trigger --- .../commands/compile/optimized_libs/tests.rs | 62 ++++++++++++++----- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index ebb015fd59..b4e86c6c4f 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -788,16 +788,18 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { let old_disable_well_known = std::env::var("PERRY_DISABLE_WELL_KNOWN").ok(); let dir = tempfile::tempdir().expect("tempdir"); - let http = - super::super::well_known::lookup_well_known("http").expect("http well-known binding"); + // #10466 — deliberately NOT "http"/"https" here: importing either now + // triggers `build_http_client_pump_stdlib`'s on-demand rebuild (a real + // cargo invocation), which this test's fake `PERRY_LIB_DIR` archives + // (raw `!\n` placeholders, not real cargo output) can't stand in + // for, and which would turn this fast unit test into a slow, real build. + // That new behavior has its own coverage below + // (`no_auto_http_client_import_rebuilds_pump_stdlib_with_ext_http`). + // This test's job is unrelated: confirm `resolve_prebuilt_ext_libs` still + // finds multiple well-known archives via `PERRY_LIB_DIR` when no rebuild + // trigger is present. let net = super::super::well_known::lookup_well_known("net").expect("net well-known binding"); let ws = super::super::well_known::lookup_well_known("ws").expect("ws well-known binding"); - let http_lib = dir - .path() - .join(super::super::well_known::ext_staticlib_filename( - &http.lib, - rust_target_triple(None), - )); let net_lib = dir .path() .join(super::super::well_known::ext_staticlib_filename( @@ -810,7 +812,6 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { &ws.lib, rust_target_triple(None), )); - std::fs::write(&http_lib, b"!\n").expect("write fake http archive"); std::fs::write(&net_lib, b"!\n").expect("write fake net archive"); std::fs::write(&ws_lib, b"!\n").expect("write fake ws archive"); @@ -822,7 +823,6 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { set_env_var("PERRY_DISABLE_WELL_KNOWN", None); let mut ctx = CompilationContext::new(dir.path().to_path_buf()); - ctx.native_module_imports.insert("http".to_string()); ctx.native_module_imports.insert("net".to_string()); ctx.native_module_imports.insert("ws".to_string()); let libs = resolve_no_auto_optimized_libs(&ctx, None, OutputFormat::Json, 0); @@ -836,11 +836,6 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { assert_eq!(libs.runtime, None); assert_eq!(libs.stdlib, None); - assert!( - libs.well_known_libs.contains(&http_lib), - "expected no-auto well-known libs to include {http_lib:?}, got {:?}", - libs.well_known_libs - ); assert!( libs.well_known_libs.contains(&net_lib), "expected no-auto well-known libs to include {net_lib:?}, got {:?}", @@ -853,6 +848,43 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { ); } +/// #10466 — the flip side of the test above: when the program DOES import +/// `http`, no-auto now rebuilds `perry-stdlib-static` (with +/// `external-http-client-pump`) and `perry-ext-http` together, and the +/// rebuilt `perry-ext-http` archive takes the place of whatever +/// `resolve_prebuilt_ext_libs` would otherwise have found on disk for it. +/// This does a real (if small) cargo build, so it's slower than the rest of +/// this file — that's the trade-off for exercising the actual rebuild path +/// rather than re-asserting the pass-through plumbing against a mock. +#[test] +fn no_auto_http_client_import_rebuilds_pump_stdlib_with_ext_http() { + let _guard = env_lock(); + let mut ctx = CompilationContext::new( + find_perry_workspace_root().expect("workspace root for this checkout"), + ); + ctx.native_module_imports.insert("http".to_string()); + let libs = resolve_no_auto_optimized_libs(&ctx, None, OutputFormat::Json, 0); + + let stdlib = libs + .stdlib + .as_ref() + .expect("http import should trigger the http-client-pump stdlib rebuild"); + assert!( + stdlib.ends_with("libperry_stdlib.a") || stdlib.ends_with("perry_stdlib.lib"), + "unexpected stdlib archive name: {stdlib:?}" + ); + let ext_http_in_well_known = libs.well_known_libs.iter().any(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.contains("perry_ext_http")) + }); + assert!( + ext_http_in_well_known, + "expected the freshly-rebuilt perry-ext-http archive in well_known_libs, got {:?}", + libs.well_known_libs + ); +} + #[cfg(windows)] #[test] fn cargo_target_dir_strips_windows_verbatim_prefixes() { From c1be91939af38809a43e7cfc57a8f731eabc4466 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 18 Sep 2026 17:49:45 +0000 Subject: [PATCH 05/19] perf(gc): don't enumerate child slots for objects that have none (#10362) The copying minor, the full mark and the remembered-set rebuild each enumerate child slots for every object they trace, including objects that have none to enumerate. Finding that out costs ~206 instructions per object: iterator construction (61), the descriptor body (127), the worklist push, and the drain entry with its cold header read. `gc_object_yields_no_child_slots` answers the question from the header word the caller has already loaded, so those objects are never pushed. Three of the four terms fold into one mask compare on `_reserved`; the term order is measured rather than chosen, and the comment says so. Measured with `perf stat -e instructions:u`, min-of-5, same SHA in both arms: gc3 -7.10%, w20000 -5.75%, w5000 -4.70%, leafarr -4.22%, w1000 -2.25% Call counts, per consumer: copying minor 2,800,337 -> 1,520,316 (-45.7%) full mark 2,400,630 -> 1,200,319 (-50.0%) remembered-set rebuild 980,690 -> 490,345 (-50.0%) Peak RSS on gc3 -4.4%, from the smaller worklist. Collection counts are identical on all seven fixtures, so this perturbs no pacing. Three fixtures regress: oldyoung +0.11%, dist16_ptr +0.12%, rec16_ptr +0.06%. This is structural, not noise. The predicate costs O(traced objects) while the win is O(qualifying objects), and oldyoung's pointer-free population is objects rather than arrays -- they pass the mask compare, fail the type test, and so pay both terms while qualifying for neither. In the full mark the skip is additionally gated on proxy tracing being inactive. A pointer-free payload is still handed to gc_observe_traced_value while a proxy is being traced, so skipping it there would collect a live proxy's target. The minor and the rebuild are unconditional; that asymmetry is deliberate. --- crates/perry-runtime/src/gc/copying.rs | 14 +- crates/perry-runtime/src/gc/layout.rs | 70 ++++++ .../src/gc/tests/layout_trace.rs | 20 +- crates/perry-runtime/src/gc/tests/mod.rs | 1 + .../src/gc/tests/zero_slot_skip.rs | 230 ++++++++++++++++++ crates/perry-runtime/src/gc/trace.rs | 56 ++++- crates/perry-runtime/src/gc/verify.rs | 6 + 7 files changed, 390 insertions(+), 7 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/zero_slot_skip.rs diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 7a510d1d64..54751c82cc 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -608,7 +608,19 @@ impl CopyingNurseryCollector { (*header).gc_flags &= !GC_FLAG_MARKED; gc_type_after_payload_move((*header).obj_type, old_user as usize, new_user as usize); - self.worklist.push(new_header); + // #10362: an object that provably yields no child slot is marked and + // moved, but not QUEUED — the drain would build an iterator and find + // nothing. See `gc_object_yields_no_child_slots` for what "no child + // slot" has to mean for this to be sound; the copying minor needs no + // proxy term because it ignores `PointerFreeRange`. + // + // `moved_headers` below is NOT part of this and must keep EVERY + // survivor: `clear_marks` walks it, so a header missing from it carries + // GC_FLAG_MARKED past the end of the cycle and reads as live to the + // next full sweep. Only the worklist push is skipped. + if !gc_object_yields_no_child_slots(new_header) { + self.worklist.push(new_header); + } self.survival_push(); if let Some(d) = self.survival.as_mut() { d.record((*new_header).obj_type, total, promote); diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index cb5e79282a..5726984e28 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -481,6 +481,76 @@ pub(super) unsafe fn layout_header_for_user(user_ptr: usize) -> Option<*mut GcHe } } +/// True when a traced object provably yields NO child slot to any collector +/// walk, so the walk can be SKIPPED rather than performed and found empty. On a +/// chain-node heap half the traced objects are of this shape. +/// +/// This is a claim about four independent edge sources, and every one of them +/// needs its own term. `GC_LAYOUT_POINTER_FREE` alone is NOT enough, because it +/// describes the PAYLOAD and nothing else: +/// +/// * **the payload** — `GC_LAYOUT_POINTER_FREE`, which +/// `heap_payload_slot_selection` already trusts to skip the whole payload +/// without consulting a mask; +/// * **the kind's prefix and meta edges** — the reason for the kind term, and +/// the reason it comes first. `gc_child_slots` builds `ArrayElements` as +/// `new(header, None, range)`: no prefix, no meta, no meta2. Every other +/// layout kind carries at least one. `ObjectFields` carries the meta record, +/// which #6812 records as "fatal for the spill buffer, reachable through meta +/// alone"; `RegExpFields` and `ObjectMeta` carry a prefix and two meta edges +/// each. And POINTER_FREE is emphatically not an array-only bit: a closure is +/// ALLOCATED pointer-free (`symbol/properties.rs`, #7154) and only leaves that +/// state when a capture store records a pointer, and a typed object whose +/// shape has an empty pointer mask acquires it (`gc/layout/typed_shape.rs`). +/// Skipping either would drop edges the payload bit says nothing about — the +/// closure's dynamic property values and static `.prototype`, the object's +/// meta record, shape `keys` edge and overflow fields; +/// * **the array's named-property reserve slots** — `GC_ARRAY_NAMED_PROPS`, +/// which live in front of element 0, outside every layout range; +/// * **a residual `Object.setPrototypeOf` entry** — the per-owner header bit +/// from #10611, which is what makes this affordable to ask per object. +/// +/// A FORWARDED header is never skippable, whatever its layout: array growth +/// installs PERMANENT forwarding stubs, and walking the stub is what propagates +/// liveness across the hop (#6228). The same guard on the sibling leaf skip in +/// `gc/trace.rs` is there for this reason. +/// +/// NOT SUFFICIENT ON ITS OWN FOR THE FULL MARK. `gc/trace.rs` reads every word +/// of a pointer-free payload through `proxy::gc_observe_traced_value` when a +/// proxy trace is active, because a proxy id is a `POINTER_TAG` value in the +/// proxy-id band rather than a heap pointer — which is precisely why the layout +/// mask is entitled to call a payload holding one pointer free. The full mark's +/// call site therefore ANDs in `!proxy_trace_active`; the copying minor and the +/// remembered-set rebuild both ignore `PointerFreeRange` and need no such term. +#[inline] +pub(crate) unsafe fn gc_object_yields_no_child_slots(header: *const GcHeader) -> bool { + // ORDER IS LOAD-BEARING, and it is a measurement, not a preference. Every + // object the copying minor moves asks this, and most say no; a first + // version that asked the type table first cost +0.07% to +0.12% on the + // three fixtures where almost nothing qualifies. The three header-word + // terms fold into ONE mask compare on a word `move_young` has already + // loaded, so a non-candidate is rejected in two instructions. + let reserved = (*header)._reserved; + if reserved + & (GC_LAYOUT_STATE_MASK + | crate::gc::GC_ARRAY_NAMED_PROPS + | crate::gc::GC_RESIDUAL_PROTO_OWNER) + != GC_LAYOUT_POINTER_FREE + { + return false; + } + if (*header).gc_flags & GC_FLAG_FORWARDED != 0 { + return false; + } + // Keyed on the TYPE rather than the rewrite kind, so the surviving path is + // a byte compare instead of a table load. This is conservative in the safe + // direction: a future type that also had no prefix/meta edge and no + // uncovered sibling would simply not be admitted here, costing a walk it + // could have skipped. `the_array_type_still_pairs_with_the_prefix_free_ + // layout_kind` pins the two table facts this leans on. + (*header).obj_type == crate::gc::GC_TYPE_ARRAY +} + #[inline] pub(crate) unsafe fn layout_init_pointer_free(user_ptr: *mut u8) { let Some(header) = layout_header_for_user(user_ptr as usize) else { diff --git a/crates/perry-runtime/src/gc/tests/layout_trace.rs b/crates/perry-runtime/src/gc/tests/layout_trace.rs index ff187248aa..0bbf10e94e 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace.rs @@ -229,11 +229,21 @@ fn test_raw_numeric_array_layout_transfers_on_copying_minor_and_skips_payload() } assert_eq!(test_layout_pointer_slot_count(after, 4), Some(0)); assert_eq!(test_heap_child_slot_count(after as *mut u8), 0); - assert!( - trace.layout_scans.raw_numeric_array_slots_skipped >= 4, - "copied raw numeric array payload should be skipped by layout scan: {:?}", - trace.layout_scans - ); + // #10362 changed WHERE this payload stops being scanned, and therefore what + // the evidence for it is. The layout-scan counters are charged BY the walk; + // a copied raw-numeric array is now not walked at all, so + // `raw_numeric_array_slots_skipped` no longer counts it. The subject of this + // test is unchanged and in fact stronger — the payload is not scanned — so + // it is asserted against the mechanism that now decides it, which is + // falsifiable in a way `>= 0` would not be. + unsafe { + assert!( + crate::gc::gc_object_yields_no_child_slots(header), + "a copied raw numeric array must be admitted by the zero-slot skip, \ + which is what now keeps its payload off the scan: reserved={:#x}", + (*header)._reserved + ); + } } #[test] diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 7a7e8a70e3..63d50b4fa6 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -97,3 +97,4 @@ mod u8_inline_cache; mod weak_read_barrier; mod young_leaf_route; mod young_log_tests; +mod zero_slot_skip; diff --git a/crates/perry-runtime/src/gc/tests/zero_slot_skip.rs b/crates/perry-runtime/src/gc/tests/zero_slot_skip.rs new file mode 100644 index 0000000000..792dbb85d8 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/zero_slot_skip.rs @@ -0,0 +1,230 @@ +//! The zero-slot skip (#10362): an object that provably yields no child slot is +//! marked and moved but never queued for a walk that would find nothing. +//! +//! Skipping a walk is skipping every edge that walk would have produced, so the +//! witnesses here are organised by EDGE SOURCE, not by fixture. Each term of +//! `gc_object_yields_no_child_slots` gets a case that fails without it, and the +//! full mark's extra `!proxy_trace_active` term — the one no ordinary GC +//! fixture can see — gets a real collection and a sabotaged twin. + +use super::super::trace::zero_slot_skip_sabotage; +use super::super::*; +use super::support::*; + +fn full_collect() { + let trigger = GcTriggerSnapshot { + kind: GcTriggerKind::Manual, + steps_before: Some(GcStepSnapshot::current()), + }; + let _ = GcCycleState::new_full(trigger).run_to_completion(); +} + +fn alloc_proxy_endpoint() -> (*mut u8, f64) { + let ptr = gc_malloc( + std::mem::size_of::(), + GC_TYPE_CLOSURE, + ); + unsafe { + init_test_closure(ptr); + } + (ptr, f64::from_bits(ptr_bits(ptr as usize))) +} + +/// A plain pointer-free array: the population the skip exists for. +unsafe fn pointer_free_array(length: u32) -> (*mut crate::array::ArrayHeader, *mut u64) { + let (arr, elements) = alloc_old_test_array(length); + layout_init_pointer_free(arr as *mut u8); + (arr, elements) +} + +unsafe fn header_of(user: usize) -> *mut GcHeader { + header_from_user_ptr(user as *const u8) as *mut GcHeader +} + +// ------------------------------------------------------------ the predicate -- + +/// The predicate keys on `GC_TYPE_ARRAY` for speed, which is only sound while +/// that type is the one whose rewrite arm has no uncovered sibling and whose +/// layout kind has no prefix or meta edge. Both are table facts, so both are +/// pinned here rather than argued in a comment. +#[test] +fn the_array_type_still_pairs_with_the_prefix_free_layout_kind() { + assert_eq!( + gc_type_rewrite_descriptor_kind(GC_TYPE_ARRAY), + GcRewriteDescriptorKind::Array, + "the skip assumes GC_TYPE_ARRAY takes the Array rewrite arm, whose only \ + siblings are named props and the residual prototype" + ); + assert_eq!( + gc_type_layout_slot_kind(GC_TYPE_ARRAY), + GcLayoutSlotKind::ArrayElements, + "the skip assumes GC_TYPE_ARRAY's layout kind yields no prefix or meta \ + child edge, which is what gc_child_slots builds for ArrayElements" + ); +} + +#[test] +fn a_plain_pointer_free_array_is_admitted() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (arr, _) = pointer_free_array(4); + assert!( + gc_object_yields_no_child_slots(header_of(arr as usize)), + "a pointer-free array with no named props and no residual prototype \ + is exactly the population this skip is for" + ); + } +} + +#[test] +fn an_array_that_still_holds_pointers_is_refused() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (arr, _) = alloc_old_test_array(4); + assert!( + !gc_object_yields_no_child_slots(header_of(arr as usize)), + "without GC_LAYOUT_POINTER_FREE the payload may hold anything" + ); + } +} + +#[test] +fn named_properties_refuse_the_skip() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (arr, _) = pointer_free_array(4); + let header = header_of(arr as usize); + assert!(gc_object_yields_no_child_slots(header), "premise"); + (*header)._reserved |= crate::gc::GC_ARRAY_NAMED_PROPS; + assert!( + !gc_object_yields_no_child_slots(header), + "named-property reserve slots sit in front of element 0, outside \ + every layout range, so POINTER_FREE says nothing about them" + ); + } +} + +#[test] +fn a_residual_prototype_owner_refuses_the_skip() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (arr, _) = pointer_free_array(4); + let header = header_of(arr as usize); + assert!(gc_object_yields_no_child_slots(header), "premise"); + (*header)._reserved |= crate::gc::GC_RESIDUAL_PROTO_OWNER; + assert!( + !gc_object_yields_no_child_slots(header), + "an explicit Object.setPrototypeOf value is a child edge of its \ + owner whatever the payload holds (#10493)" + ); + } +} + +#[test] +fn a_forwarded_array_refuses_the_skip() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (arr, _) = pointer_free_array(4); + let header = header_of(arr as usize); + assert!(gc_object_yields_no_child_slots(header), "premise"); + (*header).gc_flags |= GC_FLAG_FORWARDED; + assert!( + !gc_object_yields_no_child_slots(header), + "array growth installs PERMANENT forwarding stubs and walking the \ + stub is what propagates liveness across the hop (#6228)" + ); + } +} + +/// The kind term, and the reason it is a term at all: `GC_LAYOUT_POINTER_FREE` +/// is NOT an array-only bit. A closure is allocated pointer-free +/// (`symbol/properties.rs`, #7154) and a typed object with an empty pointer mask +/// acquires it. Both carry child edges outside the payload, so admitting them +/// on the payload bit alone would drop those edges silently. +#[test] +fn a_pointer_free_non_array_is_refused_whatever_its_payload_says() { + let _guard = GcTestIsolationGuard::new(); + unsafe { + let (obj, _) = alloc_old_test_object(1); + layout_init_pointer_free(obj as *mut u8); + let obj_header = header_of(obj as usize); + assert_eq!( + (*obj_header)._reserved & GC_LAYOUT_STATE_MASK, + GC_LAYOUT_POINTER_FREE, + "premise: the object really is marked pointer-free" + ); + assert!( + !gc_object_yields_no_child_slots(obj_header), + "an object carries the meta record edge, the shape keys edge and \ + its overflow fields, none of which the payload bit describes" + ); + + let (closure_ptr, _) = alloc_proxy_endpoint(); + layout_init_pointer_free(closure_ptr); + assert!( + !gc_object_yields_no_child_slots(header_of(closure_ptr as usize)), + "a closure is ALLOCATED pointer-free and still has dynamic property \ + values and a static prototype edge" + ); + } +} + +// --------------------------------------------- the full mark's proxy term --- + +/// A live proxy reachable ONLY through a pointer-free array, which is itself +/// reached as a FIELD (so the mark takes `mark_field_into_worklist`, the skip +/// site, rather than the root path). Returns whether the proxy survived. +fn proxy_behind_a_pointer_free_array(sabotaged: bool) -> bool { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let (_target_ptr, target) = alloc_proxy_endpoint(); + let (_handler_ptr, handler) = alloc_proxy_endpoint(); + let proxy = crate::proxy::js_proxy_new(target, handler); + + let (arr, elements) = unsafe { pointer_free_array(1) }; + unsafe { + *elements = proxy.to_bits(); + assert!( + gc_object_yields_no_child_slots(header_of(arr as usize)), + "premise: the carrier must be a skip candidate, or this proves nothing" + ); + } + // Reached as a FIELD, not as a root: the skip lives in + // `mark_field_into_worklist`, and the root path does not go through it. + let (holder, fields) = unsafe { alloc_old_test_array(1) }; + unsafe { + *fields = ptr_bits(arr as usize); + layout_init_all_pointer_slots(holder as *mut u8); + } + js_shadow_slot_set(0, ptr_bits(holder as usize)); + + { + let _sabotage = sabotaged.then(zero_slot_skip_sabotage::Guard::arm); + full_collect(); + } + let live = crate::proxy::test_proxy_slot_is_live(proxy); + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); + live +} + +#[test] +fn a_proxy_behind_a_pointer_free_array_survives_a_full_trace() { + assert!( + proxy_behind_a_pointer_free_array(false), + "the full mark must still read every word of a pointer-free payload \ + while a proxy trace is active: a proxy id is a POINTER_TAG value in \ + the proxy-id band, not a heap pointer, which is why the layout mask \ + calls that payload pointer-free in the first place" + ); +} + +#[test] +fn sabotaging_the_proxy_gate_strands_that_proxys_target() { + assert!( + !proxy_behind_a_pointer_free_array(true), + "with the !proxy_trace_active term removed the array is skipped, the \ + registry entry is never observed, gc_finish_full_trace prunes it and \ + a LIVE proxy loses its target and handler. If this twin ever passes, \ + the term is unwitnessed." + ); +} diff --git a/crates/perry-runtime/src/gc/trace.rs b/crates/perry-runtime/src/gc/trace.rs index 669dde6f88..18894b7c90 100644 --- a/crates/perry-runtime/src/gc/trace.rs +++ b/crates/perry-runtime/src/gc/trace.rs @@ -1359,8 +1359,27 @@ pub(super) unsafe fn mark_field_into_worklist( let forwarded = flags & GC_FLAG_FORWARDED != 0; #[cfg(test)] let forwarded = flags & GC_FLAG_FORWARDED != 0 && !leaf_mark_sabotage::ignoring_forwarding(); + // #10362: a pointer-free array yields no slot either, and on a chain-node + // heap it is half the traced objects — the obj_type-keyed leaf test above + // cannot see them, because they are arrays and not the strings it was + // written for. + // + // ONLY WHEN NO PROXY TRACE IS ACTIVE. `trace_heap_rewrite_slots` reads + // every word of a POINTER-FREE payload through `gc_observe_traced_value` + // when `proxy_trace_active`, because a proxy id is a POINTER_TAG value in + // the proxy-id band and not a heap pointer — which is exactly why the + // layout mask calls that payload pointer free. Skipping the object would + // leave the entry unobserved, `gc_finish_full_trace` would prune it, and a + // LIVE proxy's target and handler would be collected. The other two + // consumers of this predicate ignore `PointerFreeRange` and carry no such + // term; the asymmetry is deliberate. + #[cfg(not(test))] + let proxy_gate = proxy_trace_active; + #[cfg(test)] + let proxy_gate = proxy_trace_active && !zero_slot_skip_sabotage::respecting_proxy_gate(); if !forwarded - && gc_type_rewrite_descriptor_kind((*header).obj_type) == GcRewriteDescriptorKind::Leaf + && (gc_type_rewrite_descriptor_kind((*header).obj_type) == GcRewriteDescriptorKind::Leaf + || (!proxy_gate && gc_object_yields_no_child_slots(header))) { return true; } @@ -1373,6 +1392,41 @@ pub(super) unsafe fn mark_field_into_worklist( true } +/// Sabotage switches for the zero-slot skip (#10362). Test builds only. +/// +/// `respecting_proxy_gate` DISARMS the `!proxy_trace_active` term, i.e. makes +/// the full mark skip a pointer-free array even while a proxy trace is running. +/// That is the defect the gate exists to prevent, and +/// `gc::tests::zero_slot_skip` requires it to strand a live proxy's target. +#[cfg(test)] +pub(crate) mod zero_slot_skip_sabotage { + use std::cell::Cell; + + thread_local! { + static IGNORE_PROXY_GATE: Cell = const { Cell::new(false) }; + } + + #[inline] + pub(crate) fn respecting_proxy_gate() -> bool { + IGNORE_PROXY_GATE.with(Cell::get) + } + + pub(crate) struct Guard(bool); + + impl Guard { + pub(crate) fn arm() -> Self { + Self(IGNORE_PROXY_GATE.with(|s| s.replace(true))) + } + } + + impl Drop for Guard { + fn drop(&mut self) { + let prior = self.0; + IGNORE_PROXY_GATE.with(|s| s.set(prior)); + } + } +} + /// Sabotage switch for the leaf-mark test: a forwarded pointer-free object is /// not queued either, so its forwarding hop is never followed. Test builds /// only. diff --git a/crates/perry-runtime/src/gc/verify.rs b/crates/perry-runtime/src/gc/verify.rs index 48a642a65d..12e94ba7d0 100644 --- a/crates/perry-runtime/src/gc/verify.rs +++ b/crates/perry-runtime/src/gc/verify.rs @@ -303,6 +303,12 @@ pub(super) unsafe fn remember_evacuated_old_copy_young_slots( if !crate::arena::pointer_in_old_gen(user_ptr as usize) { return; } + // #10362: no child slot means no old->young edge to remember. This pass + // ignores `PointerFreeRange`, so unlike the full mark it needs no proxy + // term. + if crate::gc::gc_object_yields_no_child_slots(header) { + return; + } visit_gc_rewrite_slots(header, |slot| unsafe { if crate::weakref::is_weak_target_trace_slot(header, slot.slot) { return; From 9d890afed225df71806eea0381a30167a2118151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 08:56:36 +0200 Subject: [PATCH 06/19] changelog: fragment for #10669 --- changelog.d/10669-zero-slot-skip.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/10669-zero-slot-skip.md diff --git a/changelog.d/10669-zero-slot-skip.md b/changelog.d/10669-zero-slot-skip.md new file mode 100644 index 0000000000..c9757c2763 --- /dev/null +++ b/changelog.d/10669-zero-slot-skip.md @@ -0,0 +1 @@ +Skip child-slot enumeration for objects that cannot have child slots. The copying minor, the full mark and the remembered-set rebuild each paid ~206 instructions per zero-slot object — iterator construction, the descriptor body, a worklist push and a drain entry — only to discover there was nothing to visit. The full mark and the remembered-set rebuild now walk half as many objects; gc3 spends 7.1% fewer instructions and 4.4% less peak RSS. From 402f775ca2a77c973faa310f0d8ca6ecf4d6a1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 21:34:49 +0200 Subject: [PATCH 07/19] perf(runtime): stop double-scanning ASCII-ness and buffering the concat memo probe concat_byte_parts (the s + t fast path for two statically-typed string operands) scanned both operands for ASCII-ness twice - once via bytes_all_ascii up front, again on the heap path via l_slice.is_ascii() && r_slice.is_ascii() - with the first scan's answer sitting unused in scope. A new sibling of str_bytes_from_jsvalue, str_bytes_ascii_from_jsvalue, computes the bit once and threads it through; bytes_all_ascii itself switches from a byte-at-a-time loop to <[u8]>::is_ascii() (word-at-a-time, total over arbitrary byte strings). An earlier version of this change also tried to read a heap string's ASCII-ness straight off its header (utf16_len == byte_len, free) instead of scanning at all. That is unsound and was caught in review before landing: Perry heap-string payloads are not guaranteed valid UTF-8 (WTF-8 lone surrogates, Buffer.toString of arbitrary bytes, FFI blobs - #6085), and a payload ending in a truncated multi-byte lead byte can coincide on utf16_len == byte_len without being ASCII (compute_utf16_len_wtf8 charges a truncated lead its full nominal unit count while the payload holds fewer bytes than that sequence declares - string/compare.rs's utf16_cmp_bytes doc names the identical hazard). The header check survives only as a negative filter (utf16_len != byte_len soundly proves non-ASCII, unconditionally, not just for well-formed input); utf16_len == byte_len is ambiguous and always falls back to a real scan. js_string_concat_value's memo-admission gate had the identical exposure independently and is fixed the same way. No TypeScript-reachable path that constructs such a payload was found: Buffer.toString (all seven encodings), TextDecoder.decode, and every bun:ffi string-returning path all validate via str::from_utf8/from_utf8_lossy (or are fed a Rust &str, valid by construction) before ever calling js_string_from_bytes. The fix stands regardless, since js_string_from_bytes is a pub extern "C" entry point whose own contract must hold for any bytes. Regression tests are therefore Rust-level against hand-built malformed StringHeaders (the same technique string/compare.rs's own corpus and tests_guard_page.rs already use) rather than a gap test - all three fail against the reverted code, confirmed by temporarily reintroducing it. The short-concat memo's probe also assembled both operands into a stack buffer just to hand the hash/lookup helpers one contiguous slice. FNV-1a is a streaming hash and the byte compare can run in two parts, so concat_memo_hash_parts / concat_memo_slot_and_tag_parts / concat_memo_lookup_parts replace the buffer with direct two-slice hashing and lookup; the single-slice js_string_concat_value memo probe now goes through the same two-slice primitives. The memo probe's own break-even hit rate (governed by the probe's hash/lookup/admit cost, not by the ASCII-determination fix) barely moved: ~54% before this fix, ~50.6% after, both measured by forcing the governor on/off via a temporary env knob (since removed). MEMO_MIN_HIT_SHIFT stays 1 (50%, still the closest power-of-two floor to either number) - it moved from the original 2 (25%, never measured) in this same change. Measured (differential instruction-count probe, bare-loop control flat in both arms, base and arm built in the same session): 640-distinct short concat 548 -> 403 instructions/concat (-26.5%), a 73-byte memo-ineligible concat 644 -> 471 (-26.8%), a 100%-memo-hit workload 319 -> 318 (unchanged, within noise). Covered by test-files/test_gap_string_concat_memo_ascii_header.ts (byte-for-byte against node) and GC stress on a concat-heavy fixture (117,923 copying minors, 14,739 retired from-space sets quarantined, no fault) confirming the memo's GC roots survive evacuation under the new two-slice storage. --- .../string-concat-memo-ascii-header.md | 104 ++++++++ crates/perry-runtime/src/string/concat.rs | 229 ++++++++++++------ crates/perry-runtime/src/string/mod.rs | 64 +++++ crates/perry-runtime/src/string/tests.rs | 131 ++++++++++ ...est_gap_string_concat_memo_ascii_header.ts | 186 ++++++++++++++ 5 files changed, 637 insertions(+), 77 deletions(-) create mode 100644 changelog.d/string-concat-memo-ascii-header.md create mode 100644 test-files/test_gap_string_concat_memo_ascii_header.ts diff --git a/changelog.d/string-concat-memo-ascii-header.md b/changelog.d/string-concat-memo-ascii-header.md new file mode 100644 index 0000000000..c99f6ac1ef --- /dev/null +++ b/changelog.d/string-concat-memo-ascii-header.md @@ -0,0 +1,104 @@ +**String concat: stop re-scanning bytes for ASCII-ness twice, and stop +building a scratch buffer just to hash it** — 640-distinct short-string +concat down 26.5% (548 → 403 instructions/concat), a memo-ineligible 73-byte +concat down 26.8% (644 → 471), a 100%-memo-hit workload unchanged (319 → +318, within noise), measured with a differential instruction-count probe +(median-of-7, base vs arm built in the same session; bare-loop control flat +at ~3 in both). + +`concat_byte_parts` (the `s + t` fast path for two statically-typed string +operands, `perry-runtime/src/string/concat.rs`) had three defects, all in the +same neighborhood: + +1. It scanned both operands for ASCII-ness via `bytes_all_ascii` up front, + then scanned them *again* on the heap path via `l_slice.is_ascii() && + r_slice.is_ascii()` — with the first scan's answer (`both_ascii`) sitting + in scope, unused. Fixed by computing the bit once, in the caller (the new + `str_bytes_ascii_from_jsvalue`, a sibling of `str_bytes_from_jsvalue`), + and threading it through. +2. `bytes_all_ascii` scanned byte-at-a-time (`.iter().all(|&b| b < 0x80)`). + `<[u8]>::is_ascii()` inspects the same bytes word-at-a-time and is total + over arbitrary byte strings, valid or not (see the soundness note below — + that "total over arbitrary bytes" property is why it's the only sound + choice here, not just the faster one). Switching to it — `bytes_all_ascii`'s + body, and `str_bytes_ascii_from_jsvalue`'s scan — is most of this change's + win on `long73`: that workload's improvement is mostly the scan itself + getting faster over ~140 bytes/concat, not any trick that avoids it. +3. The short-concat memo's probe assembled both operands into a 12-byte stack + buffer just to hand the hash/lookup helpers one contiguous slice. FNV-1a is + a streaming hash (`fnv(a ++ b)` needs no buffer, just fold `a` then `b`), + and the byte compare on a hit/miss can run in the same two parts against + the cached entry. `concat_memo_hash_parts` / `concat_memo_slot_and_tag_parts` + / `concat_memo_lookup_parts` replace the buffer with direct two-slice + hashing and lookup; the single-slice `js_string_concat_value` ("prefix" + + i) memo probe is now implemented in terms of the same two-slice + primitives. + +**An earlier version of this change also tried to skip the scan entirely**, +by reading a heap string's ASCII-ness straight off its header (`utf16_len == +byte_len`, already computed at construction, so free). That is unsound, and +was caught in review before landing: Perry heap-string payloads are not +guaranteed valid UTF-8 (WTF-8 lone surrogates, `Buffer.toString` of +arbitrary bytes, FFI blobs — #6085), and a payload ending in a truncated +multi-byte lead byte can coincide on `utf16_len == byte_len` without being +ASCII — `compute_utf16_len_wtf8` charges a truncated lead its full nominal +unit count while the payload holds fewer bytes than that sequence declares +(`[0xC3]`, a lone 2-byte lead, records `utf16_len == 1 == byte_len`; +`string/compare.rs`'s `utf16_cmp_bytes` doc names the identical hazard and +pins the identical corpus for its own ASCII fast path — this change's +`str_bytes_ascii_from_jsvalue` doc now cross-references it). The header +check survives only as a NEGATIVE filter: `utf16_len != byte_len` soundly +proves non-ASCII with no scan needed, because `compute_utf16_len_wtf8` +counts exactly one unit per byte for any run of bytes `< 0x80` — the +contrapositive holds unconditionally, not just for well-formed input — but +`utf16_len == byte_len` is ambiguous and always falls back to a real +`is_ascii()` scan. `js_string_concat_value`'s memo-admission gate had the +identical exposure independently: `prefix_u16 == prefix_blen` was treated as +sufficient on its own and the `bytes_all_ascii` check right after it deleted +as redundant with it; restored, with the same negative-filter-then-scan +reasoning documented at the call site. + +No TypeScript-reachable path that constructs such a payload was found: +`Buffer.toString` (all seven encodings, `buffer/encode.rs`), +`TextDecoder.decode` (`text.rs`), and every `bun:ffi` string-returning path +(`read_cstring_value`, `dlopen.rs`'s `CString`/`cstring` conversions) all +validate via `str::from_utf8`/`from_utf8_lossy` (or are fed a Rust `&str`, +valid by construction) before ever calling `js_string_from_bytes` — `#609` +closed these same construction sites for a related UB hazard, and the fix +happens to guarantee well-formed output too. The fix stands regardless: +relying on an invariant this tree already documents as unsound is the wrong +foundation, and `js_string_from_bytes` is a `pub extern "C"` entry point +whose own contract must hold for any bytes, whether or not today's call +graph happens to always validate first. Regression tests are therefore +Rust-level, against hand-built malformed `StringHeader`s — the same +technique `string/compare.rs`'s own corpus and `tests_guard_page.rs` already +use — rather than a gap test: +`ascii_probe_falls_back_to_a_scan_when_the_header_lies`, +`concat_memo_declines_a_prefix_whose_header_lies_about_being_ascii`, +`concat_box_reports_not_well_formed_for_a_malformed_operand_either_side` +(`perry-runtime/src/string/tests.rs`) — all three fail against the reverted +(unsound) code, confirmed by temporarily reintroducing it and reverting +back. + +The memo probe's own break-even hit rate (governed by defect 3's mechanics — +hash/lookup/admit cost — not by the ASCII determination defects 1/2 changed) +barely moved between the unsound and sound paths: ~54% with the unsound +header shortcut, ~50.6% with the sound negative-filter-then-scan, both +measured by forcing the governor on/off via a temporary env knob (since +removed). `MEMO_MIN_HIT_SHIFT` stays `1` (50%, still the closest +power-of-two floor to either number) — it moved from the original `2` (25%, +chosen as a plausible fraction, never measured against the probe's own cost) +in this same change, which is what made the floor worth re-deriving at all. + +Covered by `test-files/test_gap_string_concat_memo_ascii_header.ts` +(byte-for-byte against node): ASCII boundary lengths crossing the SSO (5) and +memo (12) ceilings, 2/3/4-byte non-ASCII operands, a surrogate pair formed +*across* the join boundary and one that deliberately isn't (reverse order), +empty operands, and repeated-identical concats (both split two different +ways) to exercise the memo's "seen twice" admission and its `===` identity. +GC stress (`PERRY_GC_SCHEDULE_SEED=1` and `=42`, `RATE=1`, +`PROTECT_FROMSPACE=1`, `VERIFY_EVACUATION=1`, `FROMSPACE_SCAN_ABORT=1`) on a +concat-heavy fixture ran 117,923 copying minors and quarantined 14,739 +retired from-space sets with no fault and output still matching node, +confirming the memo's GC roots (`scan_concat_memo_roots_mut`) survive +evacuation under the new two-slice storage. diff --git a/crates/perry-runtime/src/string/concat.rs b/crates/perry-runtime/src/string/concat.rs index 3f4c1ef80f..ce30ed6839 100644 --- a/crates/perry-runtime/src/string/concat.rs +++ b/crates/perry-runtime/src/string/concat.rs @@ -104,15 +104,18 @@ pub(crate) fn canonicalize_surrogate_pairs(ptr: *mut StringHeader) -> *mut Strin /// True when the `len` bytes at `data` are all ASCII (`< 0x80`), or the slice /// is empty/null. Used to decide whether a concat result may be stored inline -/// through the concat helpers' ASCII SSO fast path. +/// through the concat helpers' ASCII SSO fast path. `<[u8]>::is_ascii` +/// inspects the bytes word-at-a-time and is total over arbitrary byte +/// strings — the only sound way to answer this for a Perry heap-string +/// payload, which is not guaranteed valid UTF-8 (see +/// [`str_bytes_ascii_from_jsvalue`](super::str_bytes_ascii_from_jsvalue)'s +/// doc for why the header's `utf16_len == byte_len` cannot stand in for it). #[inline] fn bytes_all_ascii(data: *const u8, len: u32) -> bool { if data.is_null() || len == 0 { return true; } - unsafe { std::slice::from_raw_parts(data, len as usize) } - .iter() - .all(|&b| b < 0x80) + unsafe { std::slice::from_raw_parts(data, len as usize) }.is_ascii() } /// `ptr::copy_nonoverlapping` with a byte loop for short payloads: the libc @@ -194,21 +197,24 @@ pub extern "C" fn js_string_concat_box(l_value: f64, r_value: f64) -> f64 { // NaN-boxed — keeps the dynamic arm. One side must still be a REAL // string, so the annotation-lie semantics of the dynamic arm are // unchanged for number+number. + // Digits from `fast_itoa_u32` are always ASCII (`'0'..='9'`, no sign — the + // admission range below is non-negative), so this arm's third tuple + // element is a constant `true`, never a scan. #[inline] - fn itoa_operand(bits_value: f64, buf: &mut [u8; 32]) -> Option<(*const u8, u32)> { + fn itoa_operand(bits_value: f64, buf: &mut [u8; 32]) -> Option<(*const u8, u32, bool)> { let bits = bits_value.to_bits(); let tag = bits >> 48; let is_plain_f64 = tag < 0x7FF8 || (tag == 0x7FF8 && (bits & 0x000F_FFFF_FFFF_FFFF) == 0); if is_plain_f64 && bits_value.fract() == 0.0 && (0.0..=999_999_999.0).contains(&bits_value) { let len = fast_itoa_u32(bits_value as u32, buf); - Some((buf.as_ptr(), len as u32)) + Some((buf.as_ptr(), len as u32, true)) } else { None } } - let l_str = str_bytes_from_jsvalue(l_value, &mut scratch_l); - let r_str = str_bytes_from_jsvalue(r_value, &mut scratch_r); + let l_str = str_bytes_ascii_from_jsvalue(l_value, &mut scratch_l); + let r_str = str_bytes_ascii_from_jsvalue(r_value, &mut scratch_r); if let (Some(l), Some(r)) = (l_str, r_str) { // Two real strings: straight to assembly, no number buffer touched // (the itoa scratch below would cost this path a 32-byte memset). @@ -231,9 +237,9 @@ pub extern "C" fn js_string_concat_box(l_value: f64, r_value: f64) -> f64 { } _ => {} } - // `str_bytes_from_jsvalue` returns `None` for exactly the non-string - // values, so every remaining pair — number+number included — is the - // annotation-lie arm and nothing else. + // `str_bytes_ascii_from_jsvalue` returns `None` for exactly the + // non-string values, so every remaining pair — number+number included — + // is the annotation-lie arm and nothing else. unsafe { crate::value::js_dynamic_string_or_number_add(l_value, r_value) } } @@ -266,8 +272,34 @@ const CONCAT_MEMO_MAX_BYTES: u32 = 12; // Candidates per governor window. const MEMO_WINDOW: u32 = 4096; -// Earn the probe: at least a quarter of a window's candidates must hit. -const MEMO_MIN_HIT_SHIFT: u32 = 2; +// Earn the probe: at least half a window's candidates must hit. +// +// This was `2` (a 25% floor) — chosen as a plausible fraction, never measured +// against the probe's own cost. A differential instruction-count probe +// (`"abcdefgN" + "xJJ"`, N ∈ 8, JJ ∈ 0..79 — 640 distinct 11-byte results +// against the memo's 512 slots, vs an 8-distinct 10-byte set that hits +// ~100%) against the SAME binary with the governor's decision forced ON/OFF +// via a temporary env knob (since removed) put the break-even — the hit rate +// at which the memo's probe cost equals its allocation savings — at: +// +// before F0/F1/F2 (double ASCII scan + stack-buffer memo probe): ~69-72% +// after F0/F1 + F2's UNSOUND positive header-ASCII path (since reverted, +// see `str_bytes_ascii_from_jsvalue`'s doc): ~54% +// after F0/F1 + F2 corrected to a sound header-negative-filter +// -then-scan (current code): ~50.6% +// +// (`cost / (cost + save)`, reading `cost` off the low-hit-rate workload and +// `save` off the ~100%-hit one — both relative to the same memo-off +// baseline, which the `long73` memo-ineligible control confirmed was flat +// across the forced on/off runs, so the two workloads' allocation-path costs +// are comparable). The break-even barely moved between the unsound and +// sound versions of F2, because this governor times the MEMO PROBE itself +// (hash/lookup/admit, F1's concern) — not the ASCII determination that +// gates whether `concat_byte_parts` reaches the probe at all, which F2 +// changed. `1` (a 50% floor) was already the closest power-of-two to the +// unsound path's ~54%, and it is still the closest power-of-two to the +// sound path's ~50.6% — no change from the F2 fix. +const MEMO_MIN_HIT_SHIFT: u32 = 1; // A hostile workload ends up probing one window in 2^8 rather than one in two. const MEMO_MAX_BACKOFF: u32 = 8; @@ -393,11 +425,33 @@ crate::perry_thread_local! { const { std::cell::UnsafeCell::new([std::ptr::null_mut(); CONCAT_MEMO_SIZE]) }; } -/// Slot and admission tag from one hash walk. The tag is a different slice of -/// the same digest, so two keys sharing a slot rarely share a tag. +/// FNV-1a over concatenated content `a ++ b`, without materialising the +/// concatenation. FNV-1a is a streaming hash — folding in `a`'s bytes then +/// `b`'s bytes is bit-identical to folding in `(a ++ b)`'s bytes — so a +/// two-operand walk needs no scratch buffer at all. This is the memo's +/// analogue of the intern table's `fnv1a_concat`, over raw byte slices +/// instead of `StringHeader` pointers (the memo's operands may be an SSO +/// scratch view, not a heap header). #[inline] -fn concat_memo_slot_and_tag(bytes: &[u8]) -> (usize, u8) { - let h = concat_memo_hash(bytes); +fn concat_memo_hash_parts(a: &[u8], b: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &byte in a { + h ^= byte as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + for &byte in b { + h ^= byte as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + h +} + +/// Slot and admission tag from one hash walk over `a ++ b`. The tag is a +/// different slice of the same digest, so two keys sharing a slot rarely +/// share a tag. +#[inline] +fn concat_memo_slot_and_tag_parts(a: &[u8], b: &[u8]) -> (usize, u8) { + let h = concat_memo_hash_parts(a, b); // FNV-1a avalanches poorly in its high bits, so slicing a tag straight out // of `h >> 32` gave two distinct keys the same tag about half the time — // measured 256,516 admissions in 501,000 probes where ~1/128 was intended, @@ -413,38 +467,45 @@ fn concat_memo_slot_and_tag(bytes: &[u8]) -> (usize, u8) { ) } +/// A cached string whose content is exactly `a ++ b`, or null. The compare is +/// done in the same two parts, against the cached entry's payload — no +/// scratch buffer, and a hash collision is a miss, never a wrong answer. #[inline] -fn concat_memo_hash(bytes: &[u8]) -> u64 { - // FNV-1a over the result bytes. Content-addressed, so two different - // operand splits that produce the same string share one entry. - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for &b in bytes { - h ^= b as u64; - h = h.wrapping_mul(0x100_0000_01b3); - } - h -} - -/// A cached string with exactly these bytes, or null. The byte compare makes -/// a hash collision a miss, never a wrong answer. -#[inline] -fn concat_memo_lookup(slot: usize, bytes: &[u8]) -> *mut StringHeader { +fn concat_memo_lookup_parts(slot: usize, a: &[u8], b: &[u8]) -> *mut StringHeader { let cached = CONCAT_MEMO.with(|c| unsafe { (*c.get())[slot] }); if cached.is_null() { return std::ptr::null_mut(); } unsafe { - if (*cached).byte_len as usize != bytes.len() { + if (*cached).byte_len as usize != a.len() + b.len() { return std::ptr::null_mut(); } let data = crate::string::string_data(cached); - if std::slice::from_raw_parts(data, bytes.len()) != bytes { + if !a.is_empty() && std::slice::from_raw_parts(data, a.len()) != a { + return std::ptr::null_mut(); + } + if !b.is_empty() && std::slice::from_raw_parts(data.add(a.len()), b.len()) != b { return std::ptr::null_mut(); } } cached } +/// Single-slice callers (the `"prefix" + i` arm in +/// [`js_string_concat_value`], which already has its two pieces contiguous +/// in a scratch buffer by the time it probes) go through the two-slice +/// primitives with an empty second operand — one hash/lookup definition, +/// not two. +#[inline] +fn concat_memo_slot_and_tag(bytes: &[u8]) -> (usize, u8) { + concat_memo_slot_and_tag_parts(bytes, &[]) +} + +#[inline] +fn concat_memo_lookup(slot: usize, bytes: &[u8]) -> *mut StringHeader { + concat_memo_lookup_parts(slot, bytes, &[]) +} + #[inline] fn concat_memo_insert(slot: usize, ptr: *mut StringHeader) { CONCAT_MEMO.with(|c| unsafe { @@ -482,16 +543,39 @@ pub(crate) fn test_clear_concat_memo() { }); } +/// Byte view over a `(ptr, len)` operand, empty for a null/zero-length one. +/// `slice::from_raw_parts` requires a non-null, aligned pointer even at +/// length 0, so the null check must come first. +/// +/// # Safety +/// `ptr` must be valid for `len` bytes when non-null. +#[inline(always)] +unsafe fn operand_byte_slice<'a>(ptr: *const u8, len: u32) -> &'a [u8] { + if ptr.is_null() || len == 0 { + &[] + } else { + std::slice::from_raw_parts(ptr, len as usize) + } +} + /// Shared tail of [`js_string_concat_box`]: assemble two raw byte slices /// (each a real string's payload or an itoa'd integer) into an SSO immediate /// when the total fits five ASCII bytes, a heap `StringHeader` otherwise. +/// +/// The third tuple element is whether that operand is pure ASCII, computed +/// once by the caller — see +/// [`str_bytes_ascii_from_jsvalue`](super::str_bytes_ascii_from_jsvalue) for +/// how (a sound header-filter-then-scan for a heap string, a plain scan for +/// an SSO one, or a constant `true` for an itoa'd operand). Taking it as a +/// precomputed bit here, instead of re-deriving it with a fresh byte scan, is +/// F0: this function used to scan both operands for ASCII-ness twice (once +/// via `bytes_all_ascii` up front, again via `l_slice.is_ascii() && +/// r_slice.is_ascii()` on the heap path below with `both_ascii` sitting +/// unused in scope) — one real scan per operand now, not two. #[inline(always)] -fn concat_byte_parts(l: (*const u8, u32), r: (*const u8, u32)) -> f64 { +fn concat_byte_parts(l: (*const u8, u32, bool), r: (*const u8, u32, bool)) -> f64 { let total_blen = l.1 + r.1; - - // Keep the existing ASCII-only concat fast path. Non-ASCII results use - // the heap path, which also handles WTF-8 surrogate-pair boundaries. - let both_ascii = bytes_all_ascii(l.0, l.1) && bytes_all_ascii(r.0, r.1); + let both_ascii = l.2 && r.2; // SSO fast path — assemble the result inline when it fits (≤ 5 // bytes). Pure bit arithmetic, no heap touch. @@ -509,33 +593,28 @@ fn concat_byte_parts(l: (*const u8, u32), r: (*const u8, u32)) -> f64 { } } - // Memo probe, ahead of the allocation: assemble the result into a stack - // buffer and look it up by content. Restricted to short ASCII results, so - // `flags`/`utf16_len` are trivially `0`/`total_blen` and the surrogate - // canonicalization below is a no-op — the cached string is bit-identical - // to what the heap path would have built. + // Byte views over both operands — used by the memo probe below and by + // the heap path's copy (and, on the non-ASCII arm only, its UTF-16/flags + // walk). Built once and shared, rather than re-derived per use. + let l_slice: &[u8] = unsafe { operand_byte_slice(l.0, l.1) }; + let r_slice: &[u8] = unsafe { operand_byte_slice(r.0, r.1) }; + + // Memo probe, ahead of the allocation: hash and look up `l_slice ++ + // r_slice` directly (F1 — no stack buffer to materialise the + // concatenation just to ask about it; FNV-1a is a streaming hash and the + // compare runs in the same two parts against the cached entry). Restricted + // to short ASCII results, so `flags`/`utf16_len` are trivially + // `0`/`total_blen` and the surrogate canonicalization below is a no-op — + // the cached string is bit-identical to what the heap path would have + // built. let memoizable = both_ascii && total_blen <= CONCAT_MEMO_MAX_BYTES && concat_memo_should_probe(); - let mut memo_buf = [0u8; CONCAT_MEMO_MAX_BYTES as usize]; let mut memo_slot = 0usize; let mut memo_admitted = false; if memoizable { - unsafe { - if l.1 > 0 { - std::ptr::copy_nonoverlapping(l.0, memo_buf.as_mut_ptr(), l.1 as usize); - } - if r.1 > 0 { - std::ptr::copy_nonoverlapping( - r.0, - memo_buf.as_mut_ptr().add(l.1 as usize), - r.1 as usize, - ); - } - } - let bytes = &memo_buf[..total_blen as usize]; - let (slot, tag) = concat_memo_slot_and_tag(bytes); + let (slot, tag) = concat_memo_slot_and_tag_parts(l_slice, r_slice); memo_slot = slot; - let hit = concat_memo_lookup(memo_slot, bytes); + let hit = concat_memo_lookup_parts(memo_slot, l_slice, r_slice); if !hit.is_null() { concat_memo_note_hit(); return f64::from_bits(crate::value::JSValue::string_ptr(hit).bits()); @@ -546,26 +625,11 @@ fn concat_byte_parts(l: (*const u8, u32), r: (*const u8, u32)) -> f64 { } // Heap path — allocate a StringHeader and memcpy. Decode both - // operands' byte slices via `str_bytes_from_jsvalue` (already done + // operands' byte slices via `str_bytes_ascii_from_jsvalue` (already done // above) and write directly into the new header's payload region. let (ptr, data_ptr) = string_storage_alloc(total_blen); unsafe { - // ASCII-fast utf16 length: count bytes < 0x80 in both slices in - // one pass. Most concat results are pure ASCII (number formatting, - // ID building, slug construction, etc.); falling back to the - // full Grisu-style codepoint walk for non-ASCII keeps spec - // compliance for the edge case. - let l_slice = if !l.0.is_null() { - std::slice::from_raw_parts(l.0, l.1 as usize) - } else { - &[] - }; - let r_slice = if !r.0.is_null() { - std::slice::from_raw_parts(r.0, r.1 as usize) - } else { - &[] - }; - let (utf16_len, flags) = if l_slice.is_ascii() && r_slice.is_ascii() { + let (utf16_len, flags) = if both_ascii { (total_blen, 0) } else { // Sum each operand's UTF-16 length independently (concatenating two @@ -794,6 +858,17 @@ pub extern "C" fn js_string_concat_value( // and heap-allocates. Restricted to a plain ASCII prefix so the cached // string is bit-identical to what the block below would build // (flags == 0, utf16_len == byte_len). + // + // `prefix_u16 == prefix_blen` is NOT the runtime's ASCII predicate — + // it is necessary but not sufficient: a truncated multi-byte lead + // byte can make a non-ASCII `prefix` coincide on `utf16_len == + // byte_len` too (see `str_bytes_ascii_from_jsvalue`'s doc in + // `string/mod.rs`, and `string/compare.rs`'s `utf16_cmp_bytes` doc, + // for the exact mechanism and a concrete payload). It DOES soundly + // rule out non-ASCII when the lengths differ, so it stays first in + // the chain as a free short-circuit — but when it's true, the + // `bytes_all_ascii` scan below is still required, not redundant + // with it. let memoizable = total_blen <= CONCAT_MEMO_MAX_BYTES as usize && is_valid_string_ptr(prefix) && prefix_u16 == prefix_blen diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 670455727d..b82f892d51 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -926,6 +926,70 @@ pub fn str_bytes_from_jsvalue( None } +/// Sibling of [`str_bytes_from_jsvalue`] that additionally reports whether the +/// operand is pure ASCII. +/// +/// - Heap `STRING_TAG`: Perry heap-string payloads are **not guaranteed valid +/// UTF-8** (WTF-8 lone surrogates, `Buffer.toString` of arbitrary bytes, FFI +/// blobs — #6085), so the header's `utf16_len == byte_len` can only be used +/// as a one-directional filter, never as the answer: +/// - `utf16_len != byte_len` ⟹ **definitely not ASCII**, no scan needed. +/// This direction is unconditional, not a well-formedness assumption: +/// [`compute_utf16_len_wtf8`] advances exactly one byte and adds exactly +/// one unit per iteration whenever it sees a byte `< 0x80`, so a payload +/// of nothing but such bytes always produces `utf16_len == byte_len` +/// exactly — the contrapositive holds for *any* byte content, valid or +/// not. +/// - `utf16_len == byte_len` does **not** imply ASCII: a truncated +/// multi-byte lead byte is charged its full nominal unit count by +/// [`compute_utf16_len_wtf8`] while the payload holds fewer bytes than +/// that sequence would need, so a short malformed payload can coincide — +/// `[0xC3]` (a lone 2-byte lead) records `utf16_len == 1 == byte_len`, +/// and `[0xF0, 0x41]` (a truncated 4-byte lead followed by an unrelated +/// byte) records `utf16_len == 2 == byte_len` — both non-ASCII. See +/// `string/compare.rs`'s `utf16_cmp_bytes` doc, which documents the same +/// hazard for the same reason. When the header is this ambiguous, fall +/// back to an actual byte scan (`<[u8]>::is_ascii`, word-at-a-time, total +/// over arbitrary bytes — no validity precondition at all). +/// - Inline `SHORT_STRING_TAG`: [`JSValue::try_short_string`] stores whatever +/// bytes it's given verbatim, with no ASCII requirement, and there is no +/// header standing in for the scan — always run `is_ascii()` on the +/// already-materialised ≤5-byte scratch, which is trivial at that size. +/// +/// Left as a separate function (not a shared implementation with +/// `str_bytes_from_jsvalue`) so the latter's ~50 other call sites pay no new +/// cost for a bit they don't use. +#[inline] +pub fn str_bytes_ascii_from_jsvalue( + value: f64, + scratch: &mut [u8; crate::value::SHORT_STRING_MAX_LEN], +) -> Option<(*const u8, u32, bool)> { + let bits = value.to_bits(); + let jsval = crate::value::JSValue::from_bits(bits); + unsafe { + if jsval.is_short_string() { + let n = jsval.short_string_to_buf(scratch); + let ascii = scratch[..n].is_ascii(); + return Some((scratch.as_ptr(), n as u32, ascii)); + } + if jsval.is_string() { + let hdr = jsval.as_string_ptr(); + if hdr.is_null() { + return Some((std::ptr::null(), 0, true)); + } + let data = string_data(hdr); + let byte_len = (*hdr).byte_len; + // `!=` proves non-ASCII outright (see doc above); `==` is + // ambiguous — a truncated/malformed lead byte can coincidentally + // match — so only THAT arm pays for the real scan. + let ascii = (*hdr).utf16_len == byte_len + && std::slice::from_raw_parts(data, byte_len as usize).is_ascii(); + return Some((data, byte_len, ascii)); + } + } + None +} + /// Fast path: create a string from bytes known to be pure ASCII. /// Skips the `compute_utf16_len` byte scan — sets utf16_len = byte_len directly. #[inline] diff --git a/crates/perry-runtime/src/string/tests.rs b/crates/perry-runtime/src/string/tests.rs index f581570e00..362925c21b 100644 --- a/crates/perry-runtime/src/string/tests.rs +++ b/crates/perry-runtime/src/string/tests.rs @@ -1197,6 +1197,137 @@ fn concat_memo_declines_non_ascii_prefixes() { } } +// ── #6085-class regression: a header that LIES about being ASCII ────────── +// +// Perry heap-string payloads are not guaranteed valid UTF-8 (WTF-8 lone +// surrogates, `Buffer.toString` of arbitrary bytes, FFI blobs — #6085). The +// header's `utf16_len == byte_len` predicate is sound as a NEGATIVE filter +// (unequal ⟹ definitely not ASCII) but not as a positive one: a payload +// ending in a truncated multi-byte lead byte can coincide on equal lengths +// without being ASCII — `compute_utf16_len_wtf8` charges a truncated lead +// its full nominal unit count while the payload holds fewer bytes than that +// sequence declares. `[0xC3]` (a lone 2-byte lead) and `[0xF0, 0x41]` (a +// truncated 4-byte lead followed by an unrelated byte) both report +// `utf16_len == byte_len` while being non-ASCII — the exact pair +// `string/compare.rs`'s `cached_utf16_len_predicate_would_misclassify_these` +// pins for the same reason. (`[0x80]`, a bare continuation byte, is NOT in +// this class: `compute_utf16_len_wtf8` skips it as "continuation byte in +// lead position" without counting a unit, so it reports `utf16_len == 0 != +// byte_len == 1` — the negative filter already catches it correctly, no +// scan needed.) +// +// I could not find a TypeScript-reachable path that constructs such a +// payload today: every raw-bytes-to-string channel that could plausibly +// carry attacker/arbitrary bytes — `Buffer.toString` (all seven encodings, +// `buffer/encode.rs`), `TextDecoder.decode` (`text.rs::decode_bytes`), and +// every `bun:ffi` string-returning path (`read_cstring_value`, +// `dlopen.rs`'s `CString`/`cstring` conversions) — validates via +// `str::from_utf8`/`from_utf8_lossy` (or is fed a Rust `&str`, valid by +// construction) before ever calling `js_string_from_bytes`; `#609` closed +// the same construction sites for a related UB hazard and the fix happens +// to guarantee well-formed output too. So these are Rust-level regression +// tests against `js_string_from_bytes` directly (the same technique +// `string/compare.rs`'s own corpus and `tests_guard_page.rs` use) rather +// than a gap test: `js_string_from_bytes` is a `pub extern "C"` entry point +// whose own contract must hold for any bytes, whether or not today's call +// graph happens to always validate first. + +/// [`str_bytes_ascii_from_jsvalue`] must not trust the header's equal-lengths +/// coincidence — it must fall back to a real (word-at-a-time, always sound) +/// byte scan whenever the header is this ambiguous. +#[test] +fn ascii_probe_falls_back_to_a_scan_when_the_header_lies() { + for bytes in [&[0xC3u8][..], &[0xF0u8, 0x41][..]] { + let hdr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + unsafe { + assert_eq!( + (*hdr).utf16_len, + (*hdr).byte_len, + "{bytes:?}: header must (wrongly) report equal lengths, \ + or this test is not exercising the hazard" + ); + } + let value = f64::from_bits(crate::value::JSValue::string_ptr(hdr).bits()); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let (_ptr, len, ascii) = str_bytes_ascii_from_jsvalue(value, &mut scratch) + .expect("a real string operand must decode"); + assert_eq!(len, bytes.len() as u32); + assert!(!ascii, "{bytes:?} is not ASCII"); + } +} + +/// The `"prefix" + i` memo probe ([`js_string_concat_value`]'s memoizable +/// gate) must not memoize off a header-lying prefix either: `prefix_u16 == +/// prefix_blen` is a necessary pre-filter, not the ASCII predicate — the +/// `bytes_all_ascii` scan after it is what actually decides. +#[test] +fn concat_memo_declines_a_prefix_whose_header_lies_about_being_ascii() { + let _lock = crate::gc::global_side_table_test_lock(); + crate::string::concat::test_clear_concat_memo(); + crate::string::concat::test_reset_memo_governor(); + + let malformed: &[u8] = &[0xC3]; + let prefix = js_string_from_bytes(malformed.as_ptr(), malformed.len() as u32); + unsafe { + assert_eq!( + (*prefix).utf16_len, + (*prefix).byte_len, + "premise: header must (wrongly) report equal lengths" + ); + } + // Same 3-call shape as `concat_memo_returns_one_object_for_equal_results`: + // the doorkeeper admits a result only on its SECOND sighting, so a + // 2-call probe cannot distinguish "declined outright" from "memoizable, + // just not admitted yet" — the second and third calls are the pair that + // would share identity if this prefix were (wrongly) memoized. + let _first = crate::string::js_string_concat_value(prefix, 1.0); + let second = crate::string::js_string_concat_value(prefix, 1.0); + let third = crate::string::js_string_concat_value(prefix, 1.0); + assert_ne!( + second as usize, third as usize, + "a header-lying malformed prefix must not be memoized" + ); +} + +/// The observable divergence the (now-fixed) header-trick bug produced: +/// concatenating a malformed operand with an ordinary ASCII string, on +/// either side, must come out `isWellFormed() === false` — the same answer +/// [`js_string_concat`] (the general, always-scanning path) gives — instead +/// of silently taking the ASCII fast path and reporting well-formed. +#[test] +fn concat_box_reports_not_well_formed_for_a_malformed_operand_either_side() { + let heap_bytes = |b: &[u8]| { + let p = js_string_from_bytes(b.as_ptr(), b.len() as u32); + f64::from_bits(crate::value::JSValue::string_ptr(p).bits()) + }; + let heap_str = |s: &str| heap_bytes(s.as_bytes()); + + for malformed in [&[0xC3u8][..], &[0xF0u8, 0x41][..]] { + for (l, r, order) in [ + (heap_bytes(malformed), heap_str("hello"), "malformed+ascii"), + (heap_str("hello"), heap_bytes(malformed), "ascii+malformed"), + ] { + let result = js_string_concat_box(l, r); + let jsval = crate::value::JSValue::from_bits(result.to_bits()); + assert!( + jsval.is_string(), + "{malformed:?} {order}: both operands are real strings, \ + concat must not fall through to the dynamic-add arm" + ); + let ptr = jsval.as_string_ptr(); + assert!( + !ptr.is_null(), + "{malformed:?} {order}: empty-string sentinel unexpected here" + ); + let well_formed = crate::value::js_is_truthy(js_string_is_well_formed(ptr)); + assert_eq!( + well_formed, 0, + "{malformed:?} {order}: concat result must report isWellFormed() === false" + ); + } + } +} + /// #9391: the memo must stop PROBING when it stops paying. /// /// `bench_gc_pressure` builds half a million distinct `"item_" + i` strings. diff --git a/test-files/test_gap_string_concat_memo_ascii_header.ts b/test-files/test_gap_string_concat_memo_ascii_header.ts new file mode 100644 index 0000000000..92d35f057f --- /dev/null +++ b/test-files/test_gap_string_concat_memo_ascii_header.ts @@ -0,0 +1,186 @@ +// Coverage for the string-concat perf fix (perry-runtime/src/string/concat.rs +// + string/mod.rs): the ASCII-ness of a concat operand is now read off the +// StringHeader (`utf16_len == byte_len`) instead of re-scanning bytes, the +// short-concat memo probe hashes/looks-up two operand slices directly +// instead of assembling them into a scratch buffer first, and the memo +// governor's minimum-hit-rate floor changed. None of that may change any +// observable result: every case here is compared byte-for-byte against +// `node --experimental-strip-types`. +// +// Values are built from runtime state (array/loop indices), never folded to +// a compile-time constant, so codegen must actually reach the runtime concat +// paths under test. + +function codes(s: string): string { + let out = ""; + for (let i = 0; i < s.length; i++) out += (i ? "+" : "") + s.charCodeAt(i).toString(16); + return out; +} + +// --------------------------------------------------------------------------- +// 1. ASCII string+string concat across the SSO (5) and memo (12) byte +// ceilings — exercises concat_byte_parts's SSO fast path, memo path, and +// heap path in one sweep. +// --------------------------------------------------------------------------- +const lensA = [0, 0, 2, 3, 6, 6, 10, 36]; +const lensB = [0, 1, 3, 3, 6, 7, 10, 37]; +for (let i = 0; i < lensA.length; i++) { + const a = "x".repeat(lensA[i]); + const b = "y".repeat(lensB[i]); + const r = a + b; + console.log("ss", lensA[i], lensB[i], r.length, r); +} + +// Same boundary set, but string+number (js_string_concat_value / +// js_value_concat_string) and number+string, so the "prefix" + i arm's +// memoizable gate (also touched by this fix) is covered too. +const prefixLens = [0, 1, 4, 5, 6, 11, 12, 13, 20]; +for (let i = 0; i < prefixLens.length; i++) { + const prefix = "p".repeat(prefixLens[i]); + const withNum = prefix + i; + const numWith = i + prefix; + console.log("sn", prefixLens[i], withNum.length, withNum, numWith.length, numWith); +} + +// --------------------------------------------------------------------------- +// 2. Non-ASCII, valid (well-formed) UTF-16 — 2-byte, 3-byte and 4-byte +// (astral, via a literal, not a joined surrogate pair) UTF-8 operands. +// These print directly: valid Unicode encodes identically to UTF-8 in +// both engines, so a byte-for-byte diff is a meaningful check on its own. +// --------------------------------------------------------------------------- +const twoByte = "é"; // U+00E9, 2 UTF-8 bytes, 1 UTF-16 unit +const threeByte = "€"; // U+20AC, 3 UTF-8 bytes, 1 UTF-16 unit +const fourByte = "😀"; // U+1F600, 4 UTF-8 bytes, 2 UTF-16 units (already a pair) +const nonAsciiCases: [string, string][] = [ + ["2b+ascii", twoByte + "ab"], + ["ascii+2b", "ab" + twoByte], + ["2b+2b", twoByte + twoByte], + ["3b+ascii", threeByte + "ab"], + ["ascii+3b", "ab" + threeByte], + ["3b+3b", threeByte + threeByte], + ["4b+ascii", fourByte + "ab"], + ["ascii+4b", "ab" + fourByte], + ["4b+4b", fourByte + fourByte], + ["mixed", "a" + twoByte + "b" + threeByte + "c" + fourByte + "d"], +]; +for (const [name, s] of nonAsciiCases) { + console.log("na", name, s.length, s, s.isWellFormed()); +} + +// string+number and number+string with a non-ASCII prefix/suffix, to hit +// js_string_concat_value / js_value_concat_string's non-ASCII path. +for (let i = 0; i < 3; i++) { + const withNum = threeByte + i; + const numWith = i + fourByte; + console.log("nan", i, withNum.length, withNum, numWith.length, numWith); +} + +// --------------------------------------------------------------------------- +// 3. Lone surrogates and a surrogate pair formed ACROSS the join boundary. +// Raw lone-surrogate content is reported via charCodeAt (codes()) or +// JSON.stringify — both are byte-safe (JSON.stringify escapes an +// unpaired surrogate as \uXXXX rather than emitting it raw), matching +// the pattern used elsewhere in this suite (#9431). A properly merged +// astral pair is well-formed Unicode and is printed directly. +// --------------------------------------------------------------------------- +const hi = "\uD83D"; // lone high surrogate +const lo = "\uDE00"; // lone low surrogate — hi+lo is exactly 😀 (U+1F600) + +// Pair formed directly across the join boundary: canonicalize_surrogate_pairs +// must merge it, so this is well-formed and safe to print raw. +const paired = hi + lo; +console.log("pair-direct", paired.length, paired, paired.isWellFormed(), paired.codePointAt(0)); + +// Reverse order never forms a pair (low-before-high is not a valid pair) — +// must remain two lone surrogates. +const reversed = lo + hi; +console.log( + "pair-reversed", + reversed.length, + codes(reversed), + reversed.isWellFormed(), + JSON.stringify(reversed), +); + +// A pair split across TWO separate concatenations, then joined by a THIRD: +// "a" + hi built first, lo + "b" built second, then those two results +// concatenated — the pair only becomes adjacent at the last join. +const left = "a" + hi; +const right = lo + "b"; +const rejoined = left + right; +console.log( + "pair-split-rejoin", + left.length, + right.length, + rejoined.length, + rejoined, + rejoined.isWellFormed(), + rejoined.codePointAt(1), +); + +// A lone surrogate with ASCII on both sides never forms a pair — stays lone, +// flag preserved through the concat. +const loneMid = "x" + hi + "y"; +console.log("lone-mid", loneMid.length, codes(loneMid), loneMid.isWellFormed(), JSON.stringify(loneMid)); + +// Two highs in a row: no valid pair (high+high is not low-after-high). +const twoHighs = hi + hi; +console.log("two-highs", twoHighs.length, codes(twoHighs), twoHighs.isWellFormed()); + +// --------------------------------------------------------------------------- +// 4. Empty operands on both sides of both concat forms. +// --------------------------------------------------------------------------- +console.log("empty-both", ("" + "").length, JSON.stringify("" + "")); +console.log("empty-left", ("" + "z").length, "" + "z"); +console.log("empty-right", ("z" + "").length, "z" + ""); +console.log("empty-num", ("" + 0).length, "" + 0, (0 + "").length, 0 + ""); + +// --------------------------------------------------------------------------- +// 5. Repeated identical concat results — forces the memo doorkeeper's +// "seen twice" admission and then real hits, and checks `===` identity +// across independently-built equal results (the memo must never change +// observable semantics: value equality is unaffected either way, but a +// hash-collision or admission bug would surface as a wrong `.length` or +// a `false` here). +// --------------------------------------------------------------------------- +let memoFailures = 0; +const memoResults: string[] = []; +for (let i = 0; i < 40; i++) { + // Same content, two different operand splits — "ab" + "cdef" and + // "abc" + "def" both yield "abcdef". + const viaSplitA = "ab" + "cdef".slice(0); + const viaSplitB = "abc".slice(0) + "def"; + if (viaSplitA !== viaSplitB) memoFailures++; + if (viaSplitA.length !== 6) memoFailures++; + memoResults.push(viaSplitA); +} +for (let i = 1; i < memoResults.length; i++) { + if (memoResults[i] !== memoResults[0]) memoFailures++; +} +console.log("memo-repeat-failures", memoFailures, memoResults.length, memoResults[0]); + +// A heap-forced (>SSO, <=memo-ceiling) equal pair built two different ways, +// repeated enough to admit, then compared for identity and content. +let memoHeapFailures = 0; +for (let i = 0; i < 40; i++) { + const a = "field_" + "ab".slice(0); // "field_ab", 8 bytes + const b = "field" + "_ab".slice(0); + if (a !== b || a.length !== 8 || a !== "field_ab") memoHeapFailures++; +} +console.log("memo-heap-repeat-failures", memoHeapFailures); + +// The "prefix" + i shape repeated with a REPEATED i, so the SAME result +// recurs (as opposed to section 1's sweep, which never repeats a value). +let memoNumFailures = 0; +const memoNumResults: string[] = []; +for (let rep = 0; rep < 30; rep++) { + const k = "row_" + 7; + if (k.length !== 5 || k !== "row_7") memoNumFailures++; + memoNumResults.push(k); +} +for (let i = 1; i < memoNumResults.length; i++) { + if (memoNumResults[i] !== memoNumResults[0]) memoNumFailures++; +} +console.log("memo-num-repeat-failures", memoNumFailures, memoNumResults[0]); + +console.log("done"); From a1fc31e3278b66e98fac104ec4eb9a09086f606a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 23:39:51 +0200 Subject: [PATCH 08/19] changelog: key fragment to #10672 --- ...o-ascii-header.md => 10672-string-concat-memo-ascii-header.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{string-concat-memo-ascii-header.md => 10672-string-concat-memo-ascii-header.md} (100%) diff --git a/changelog.d/string-concat-memo-ascii-header.md b/changelog.d/10672-string-concat-memo-ascii-header.md similarity index 100% rename from changelog.d/string-concat-memo-ascii-header.md rename to changelog.d/10672-string-concat-memo-ascii-header.md From 5b0bd156022e3cb842d46554395857c5985823a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 21:38:55 +0000 Subject: [PATCH 09/19] fix(cjs): defer conditional CommonJS require() init instead of hoisting (#10437) Perry's CJS->ESM wrap turned every literal require('S') in a wrapped file into a hoisted static import, eager-initializing the target regardless of whether the surrounding control flow ever reaches the call. function_local_specs only kept a require() lazy when every call site sat inside a function body; a top-level if/for/while/switch/try/ &&/?: guard (including pg's own if (forceNative) { require('./native') }) still forced eager init. Broaden the classification to also cover a control-flow block (if/for/while/switch/catch/with/else/try/do/finally) and a braceless/operator equivalent (cond && require(...), cond ? require(...) : x, for (...) require(...) with no block) -- matching Node's actual 'loads only when control flow reaches it' semantics. An ordinary object literal, class body, or bare grouping block does not count (the common module.exports = { fs: require('fs') } barrel shape stays eager), and a process.platform === '' guard (node-pty's Windows/Unix terminal split) is exempted since the platform is a compile-time-known build target, not a runtime unknown. This was the sole remaining blocker compiling pg from source: pg crashed at init with Cannot find module 'pg-native' even though its guarding forceNative check was false. Fixes #10437. --- .../compile/cjs_wrap/extract_requires.rs | 181 ++++++++++++++++-- .../src/commands/compile/collect_modules.rs | 22 ++- .../_helpers/gap10437_cjs_lazy_require.cjs | 88 +++++++++ test-files/_helpers/gap10437_counter.cjs | 2 + .../_helpers/gap10437_native_rethrow.cjs | 11 ++ test-files/_helpers/gap10437_side_a.cjs | 2 + test-files/_helpers/gap10437_side_b.cjs | 2 + test-files/_helpers/gap10437_side_c.cjs | 2 + test-files/_helpers/gap10437_side_d.cjs | 2 + test-files/_helpers/gap10437_side_e.cjs | 2 + test-files/_helpers/gap10437_side_f.cjs | 2 + test-files/_helpers/gap10437_side_g.cjs | 2 + test-files/_helpers/gap10437_side_h.cjs | 2 + ...st_gap_cjs_conditional_require_deferred.ts | 20 ++ 14 files changed, 311 insertions(+), 29 deletions(-) create mode 100644 test-files/_helpers/gap10437_cjs_lazy_require.cjs create mode 100644 test-files/_helpers/gap10437_counter.cjs create mode 100644 test-files/_helpers/gap10437_native_rethrow.cjs create mode 100644 test-files/_helpers/gap10437_side_a.cjs create mode 100644 test-files/_helpers/gap10437_side_b.cjs create mode 100644 test-files/_helpers/gap10437_side_c.cjs create mode 100644 test-files/_helpers/gap10437_side_d.cjs create mode 100644 test-files/_helpers/gap10437_side_e.cjs create mode 100644 test-files/_helpers/gap10437_side_f.cjs create mode 100644 test-files/_helpers/gap10437_side_g.cjs create mode 100644 test-files/_helpers/gap10437_side_h.cjs create mode 100644 test-files/test_gap_cjs_conditional_require_deferred.ts diff --git a/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs b/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs index cdcb336a1b..5e7b061c15 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs @@ -293,19 +293,40 @@ pub fn identifier_is_declared_binding(source: &str, name: &str) -> bool { false } -/// Next.js lazy-require classification (single forward pass). Returns the set -/// of specifiers whose EVERY `require('')` call site is lexically inside -/// a FUNCTION body — never at module top level, and never inside a top-level -/// control-flow block that runs at module load. Node loads such a module -/// lazily (only when the enclosing function runs), so Perry must not eager-init -/// it. +/// Deferred-require classification (single forward pass). Returns the set of +/// specifiers whose EVERY `require('')` call site is NOT guaranteed to +/// run the moment the module loads — a function body (never called, or called +/// later: #Next.js lazy-require), a control-flow block that may not run every +/// time its enclosing scope runs (`if`/`for`/`while`/`switch`/`catch`/`with`/ +/// `else`/`try`/`do`/`finally`), or a braceless/operator-guarded equivalent of +/// the same thing (`cond && require(...)`, `cond ? require(...) : x`, `for +/// (...) require(...)` with no block). Node only ever loads such a module when +/// control flow actually reaches the call, so Perry must not eager-init it +/// either (issue #10437: `pg` guards its optional `pg-native` binding exactly +/// this way, behind `if (forceNative) { require('./native') }`). /// -/// Conservative by construction: a spec with any top-level call site (including -/// top-level `if`/`for`/`try` blocks, which execute during module evaluation) -/// is excluded and keeps the default eager behavior. A misclassification is -/// self-correcting at runtime — the require shim triggers the target's init -/// when `require()` is actually called — so this only governs eager-init-loop -/// membership. +/// An ordinary object literal (`{ key: require(...) }`), a class body, or a +/// bare grouping block do NOT count — their contents run unconditionally +/// whenever the enclosing statement/expression is reached, same as top level, +/// so nesting inside one of those must not flip a spec to lazy (that would be +/// the common `module.exports = { fs: require('fs'), path: require('path') }` +/// barrel-export shape, which really is eager). +/// +/// The ternary ALTERNATE arm (`cond ? x : require(...)`) is deliberately NOT +/// matched — a bare `:` immediately before `require(` is indistinguishable +/// from an object-literal property value or a `switch` case label without a +/// real parse, and guessing wrong there risks the same barrel-export +/// misclassification the object-literal exclusion above avoids. That shape +/// keeps the conservative eager default (a known, narrow gap — not in scope +/// for #10437's reproduction). +/// +/// A false POSITIVE here (treating a genuinely-unconditional require as +/// conditional) is harmless: the require shim still triggers the target's +/// init at the exact point the call is lexically reached, which for an +/// unconditional call is essentially the same moment eager pre-init would +/// have run it. A false NEGATIVE (missing a genuinely-conditional call) is +/// the actual bug class — the target loads (and can throw) before its +/// guarding condition was ever evaluated. /// /// Brace/paren scanning runs on a comment/string/regex-masked copy (same /// length, code structure preserved) so literal braces never corrupt the scope @@ -337,30 +358,65 @@ pub fn function_local_specs(source: &str) -> std::collections::HashSet { return HashSet::new(); } + // #10437 followup: a spec whose ONLY conditionality is a + // `process.platform === /!== ''` if/else guard (either branch — + // e.g. node-pty's `./windowsTerminal` / `./unixTerminal` split) must NOT + // be downgraded to lazy by the broader control-flow classification below. + // The platform is a build TARGET resolved at compile time, not a runtime + // unknown — `wrap_commonjs_for_target`'s `inactive_platform_guarded_requires` + // already prunes the dead branch's spec outright for a known target, and + // the live branch's spec keeps the eager `_req_N` classification it had + // before this fix. Treating a compile-time-resolved platform check as + // conditional the way a genuinely runtime-unknown check (env var, + // arbitrary function result) is would only add needless deferral, not + // fix a bug — #10437 is about conditions Perry cannot resolve at compile + // time. + let platform_guarded_specs = process_platform_guarded_specs(source); + let mbytes = masked.as_bytes(); let is_ident = |c: u8| c == b'_' || c == b'$' || c.is_ascii_alphanumeric(); let control_keywords = ["if", "for", "while", "switch", "catch", "with", "else"]; + // Bare-keyword control blocks with no parens (`try {`, `else {`, `do {`, + // `} finally {`) — as opposed to an object literal / class body / plain + // grouping block, whose opening `{` is also not preceded by `)`/`=>` but + // whose contents are NOT conditional (see doc comment above). + let bare_control_keywords = ["try", "else", "do", "finally"]; #[derive(PartialEq)] enum Scope { + /// Function/method/arrow/IIFE body: reachability depends on whether, + /// and when, the function is ever called. Function, + /// A control-flow block that may not run every time its enclosing + /// scope runs. Block, + /// Anything else brace-delimited whose contents run unconditionally + /// when reached (object literal, class body, bare grouping block). + /// Nesting here does not itself make an enclosed `require()` + /// conditional. + Other, } let mut scopes: Vec = Vec::new(); - // spec → (seen any site, all sites so far in-function). + // spec → (seen any site, all sites so far conditionally-reached). let mut state: HashMap<&str, (bool, bool)> = HashMap::new(); let mut next_site = 0usize; - let in_function = |scopes: &[Scope]| scopes.contains(&Scope::Function); + let gates_reachability = |scopes: &[Scope]| { + scopes + .iter() + .any(|s| matches!(s, Scope::Function | Scope::Block)) + }; let mut i = 0usize; while i < mbytes.len() { // Record any require site at this offset before processing the char. while next_site < sites.len() && sites[next_site].0 == i { let (_, spec) = sites[next_site]; - let here = in_function(&scopes); + let conditional = !platform_guarded_specs.contains(spec) + && (gates_reachability(&scopes) + || site_is_conditionally_guarded(&masked, mbytes, i, &is_ident)); let e = state.entry(spec).or_insert((false, true)); e.0 = true; - e.1 = e.1 && here; + e.1 = e.1 && conditional; next_site += 1; } match mbytes[i] { @@ -381,7 +437,18 @@ pub fn function_local_specs(source: &str) -> std::collections::HashSet { Scope::Function } } else { - Scope::Block + // Not preceded by `)` or `=>`: a bare control keyword + // (`try`/`else`/`do`/`finally`) is conditional; an object + // literal, class body, or plain grouping block is not. + let mut w = p; + while w > 0 && is_ident(mbytes[w - 1]) { + w -= 1; + } + if bare_control_keywords.iter().any(|k| *k == &masked[w..p]) { + Scope::Block + } else { + Scope::Other + } }; scopes.push(kind); } @@ -395,16 +462,19 @@ pub fn function_local_specs(source: &str) -> std::collections::HashSet { // Any sites at EOF offset (defensive). while next_site < sites.len() { let (_, spec) = sites[next_site]; + let conditional = !platform_guarded_specs.contains(spec) + && (gates_reachability(&scopes) + || site_is_conditionally_guarded(&masked, mbytes, mbytes.len(), &is_ident)); let e = state.entry(spec).or_insert((false, true)); e.0 = true; - e.1 = e.1 && in_function(&scopes); + e.1 = e.1 && conditional; next_site += 1; } state .into_iter() - .filter_map(|(spec, (seen, all_in_fn))| { - if seen && all_in_fn { + .filter_map(|(spec, (seen, all_conditional))| { + if seen && all_conditional { Some(spec.to_string()) } else { None @@ -413,6 +483,77 @@ pub fn function_local_specs(source: &str) -> std::collections::HashSet { .collect() } +/// Is the `require(` call whose match starts at masked-source offset +/// `call_start` reached only conditionally by a nearby operator or a +/// braceless control-flow header, even though it has no enclosing `{ }` +/// scope of its own? Brace-scope tracking (above) can't see these shapes: +/// `cond && require(...)` / `cond || require(...)` / `cond ?? require(...)`, +/// the ternary CONSEQUENT arm `cond ? require(...) : x`, a braceless arrow +/// `() => require(...)`, and a braceless control-flow body — `if (...) +/// require(...)`, `for (...) require(...)`, `while (...) require(...)`, +/// `else require(...)`, `do require(...)`. +fn site_is_conditionally_guarded( + masked: &str, + mbytes: &[u8], + call_start: usize, + is_ident: &impl Fn(u8) -> bool, +) -> bool { + let mut p = call_start; + while p > 0 && (mbytes[p - 1] as char).is_whitespace() { + p -= 1; + } + if p == 0 { + return false; + } + if p >= 2 { + let two = &masked[p - 2..p]; + if two == "&&" || two == "||" || two == "??" || two == "=>" { + return true; + } + } + // Ternary consequent (`cond ? require(...) : x`) — a lone `?`, not the + // second char of `??` (already handled above). + if mbytes[p - 1] == b'?' && !(p >= 2 && mbytes[p - 2] == b'?') { + return true; + } + // Braceless control-flow header: `if (...)`, `for (...)`, `while (...)` + // immediately followed by the require call (no block). + if mbytes[p - 1] == b')' { + let head = matched_open_head(masked, mbytes, p - 1, is_ident); + return matches!(head.as_str(), "if" | "for" | "while"); + } + // Bare `else`/`do` immediately before, with no parens and no block. + let mut w = p; + while w > 0 && is_ident(mbytes[w - 1]) { + w -= 1; + } + matches!(&masked[w..p], "else" | "do") +} + +/// Every `require('')` specifier textually inside EITHER branch of a +/// `if (process.platform === /!== '') { … } else { … }` guard. +/// Mirrors the pattern `wrap.rs`'s `inactive_platform_guarded_requires` +/// matches to prune the DEAD branch's spec for a known build target — this +/// helper is target-independent and returns BOTH branches' specs, so the +/// LIVE branch's spec (which `inactive_platform_guarded_requires` keeps) can +/// be exempted from the general conditional-require classification above. +fn process_platform_guarded_specs(source: &str) -> std::collections::HashSet { + let re = perry_perex::tooling::Regex::new( + r#"(?s)if\s*\(\s*process\.platform\s*(?:===|!==)\s*['"][^'"]+['"]\s*\)\s*\{(?P.*?)\}\s*else\s*\{(?P.*?)\}"#, + ) + .unwrap(); + let mut specs = std::collections::HashSet::new(); + for cap in re.captures_iter(source) { + if let Some(then) = cap.name("then") { + specs.extend(extract_require_specifiers(then.as_str())); + } + if let Some(els) = cap.name("else") { + specs.extend(extract_require_specifiers(els.as_str())); + } + } + specs +} + /// Given the index of a `)` in the masked source, walk back to its matching /// `(` and return the identifier/keyword immediately before that `(`. fn matched_open_head( diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 320eb65545..a179e14cc5 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1777,15 +1777,19 @@ fn collect_module_one( } } - // Next.js lazy-require: the CJS→ESM wrap names a binding `_lazyreq_N` when - // every `require('S')` call site is inside a function body (lazy in Node). - // Tag the import so `classify_eager_modules` leaves the target Deferred — - // matching Node, which only loads such a module when the enclosing function - // runs (e.g. jsonwebtoken, required only inside Next.js's request handlers). - // The require shim triggers the target's `__init` on first `require()`, so - // an over-eager classification is self-correcting at runtime. Limited to - // Perry-compiled (`NativeCompiled`) targets — native stdlib / V8 modules - // have their own init paths. + // Deferred require (#10437, originally the Next.js lazy-require case): the + // CJS→ESM wrap names a binding `_lazyreq_N` when every `require('S')` call + // site is NOT guaranteed to run the moment the module loads — inside a + // function body (lazy in Node: jsonwebtoken, required only inside Next.js's + // request handlers), or inside a top-level control-flow block / braceless + // equivalent that may never run (`if (forceNative) { require('./native') }`, + // pg's optional native binding). Tag the import so `classify_eager_modules` + // leaves the target Deferred — matching Node, which only loads such a + // module when control flow actually reaches the call. The require shim + // triggers the target's `__init` at that same call site, so an over-eager + // classification is self-correcting at runtime (it just runs a bit early). + // Limited to Perry-compiled (`NativeCompiled`) targets — native stdlib / + // V8 modules have their own init paths. { for import in &mut hir_module.imports { if import.type_only diff --git a/test-files/_helpers/gap10437_cjs_lazy_require.cjs b/test-files/_helpers/gap10437_cjs_lazy_require.cjs new file mode 100644 index 0000000000..62ecf6644f --- /dev/null +++ b/test-files/_helpers/gap10437_cjs_lazy_require.cjs @@ -0,0 +1,88 @@ +'use strict' +// #10437: CommonJS `require()` outside a function is hoisted and run +// unconditionally at module init, including inside `if (false)` and other +// branches that never run. Every require below except H is inside a branch +// that never executes; only H's side-effect module should ever load, and it +// should load exactly at the point control flow reaches it (between the +// "before taken branch" and "after taken branch" log lines) — not before +// the first statement, and not before H's guarding condition was evaluated. +// +// This is the shape pg 8.22.0 hits verbatim: `lib/index.js` guards an +// optional native binding behind `if (forceNative) { require('./native') }`, +// and `./native` transitively requires the optional, often-uninstalled +// `pg-native`. The crash-form section below reproduces that two-hop shape +// with a target that genuinely does not resolve on disk. + +console.log('start') + +// A: literal false +if (false) { + require('./gap10437_side_a.cjs') +} +// B: short-circuit +false && require('./gap10437_side_b.cjs') +// C: runtime-false env check (pg's `if (forceNative)` shape) +if (process.env.PERRY_GAP10437_UNSET_C) { + require('./gap10437_side_c.cjs') +} +// D: ternary arm not taken +const d = process.env.PERRY_GAP10437_UNSET_D ? require('./gap10437_side_d.cjs') : 'd-skipped' +// E: switch case not taken +switch (1) { + case 2: + require('./gap10437_side_e.cjs') +} +// F: loop body never runs +for (let i = 0; i < 0; i++) require('./gap10437_side_f.cjs') +// G: function never called (already correctly deferred pre-#10437) +function never() { + return require('./gap10437_side_g.cjs') +} + +console.log('before taken branch') + +// H: the taken branch — must load exactly here, not earlier. +if (true) { + require('./gap10437_side_h.cjs') +} + +console.log('after taken branch, d=' + d) + +// Caching: two conditional requires of the SAME module must run the side +// effect once and return the SAME exports object both times. +let capA = null +let capB = null +if (true) { + capA = require('./gap10437_counter.cjs') +} +if (true) { + capB = require('./gap10437_counter.cjs') +} +console.log('cache same=' + (capA === capB) + ' n=' + capA.n) + +// Crash-form (pg-native shape): an optional native binding behind an unset +// env check, whose target itself unconditionally (but inside a +// non-swallowing try/catch) requires a module that does not exist on disk. +// Pre-fix this crashed the whole program with "Cannot find module" even +// though the guarding env var was never set. +let impl = 'js' +if (process.env.PERRY_GAP10437_USE_NATIVE) { + impl = require('./gap10437_native_rethrow.cjs') +} +console.log('impl=' + impl) + +// A genuinely missing module behind a try/catch that SWALLOWS the error, +// itself nested inside a condition that never runs. +let fallback = 'default' +if (process.env.PERRY_GAP10437_UNSET_FALLBACK) { + try { + fallback = require('./gap10437_does_not_exist.cjs') + } catch (e) { + fallback = 'caught' + } +} +console.log('fallback=' + fallback) + +console.log('end') + +module.exports = { never: never } diff --git a/test-files/_helpers/gap10437_counter.cjs b/test-files/_helpers/gap10437_counter.cjs new file mode 100644 index 0000000000..35643ed461 --- /dev/null +++ b/test-files/_helpers/gap10437_counter.cjs @@ -0,0 +1,2 @@ +console.log('counter evaluated') +module.exports = { n: 1 } diff --git a/test-files/_helpers/gap10437_native_rethrow.cjs b/test-files/_helpers/gap10437_native_rethrow.cjs new file mode 100644 index 0000000000..76e34c552c --- /dev/null +++ b/test-files/_helpers/gap10437_native_rethrow.cjs @@ -0,0 +1,11 @@ +'use strict' +// pg 8.22.0 lib/native/client.js:3-10 shape: an optional native addon, +// required unconditionally once this file's own init runs, wrapped in a +// try/catch that RE-THROWS rather than swallowing. +var Native +try { + Native = require('./gap10437_missing_optional_dep.cjs') +} catch (e) { + throw e +} +module.exports = Native diff --git a/test-files/_helpers/gap10437_side_a.cjs b/test-files/_helpers/gap10437_side_a.cjs new file mode 100644 index 0000000000..5efe46f1b2 --- /dev/null +++ b/test-files/_helpers/gap10437_side_a.cjs @@ -0,0 +1,2 @@ +console.log('side_a evaluated') +module.exports = 'a' diff --git a/test-files/_helpers/gap10437_side_b.cjs b/test-files/_helpers/gap10437_side_b.cjs new file mode 100644 index 0000000000..3d552351d4 --- /dev/null +++ b/test-files/_helpers/gap10437_side_b.cjs @@ -0,0 +1,2 @@ +console.log('side_b evaluated') +module.exports = 'b' diff --git a/test-files/_helpers/gap10437_side_c.cjs b/test-files/_helpers/gap10437_side_c.cjs new file mode 100644 index 0000000000..e72b064d6c --- /dev/null +++ b/test-files/_helpers/gap10437_side_c.cjs @@ -0,0 +1,2 @@ +console.log('side_c evaluated') +module.exports = 'c' diff --git a/test-files/_helpers/gap10437_side_d.cjs b/test-files/_helpers/gap10437_side_d.cjs new file mode 100644 index 0000000000..aa22fe700e --- /dev/null +++ b/test-files/_helpers/gap10437_side_d.cjs @@ -0,0 +1,2 @@ +console.log('side_d evaluated') +module.exports = 'd' diff --git a/test-files/_helpers/gap10437_side_e.cjs b/test-files/_helpers/gap10437_side_e.cjs new file mode 100644 index 0000000000..4a43a95ec8 --- /dev/null +++ b/test-files/_helpers/gap10437_side_e.cjs @@ -0,0 +1,2 @@ +console.log('side_e evaluated') +module.exports = 'e' diff --git a/test-files/_helpers/gap10437_side_f.cjs b/test-files/_helpers/gap10437_side_f.cjs new file mode 100644 index 0000000000..c886b3edd4 --- /dev/null +++ b/test-files/_helpers/gap10437_side_f.cjs @@ -0,0 +1,2 @@ +console.log('side_f evaluated') +module.exports = 'f' diff --git a/test-files/_helpers/gap10437_side_g.cjs b/test-files/_helpers/gap10437_side_g.cjs new file mode 100644 index 0000000000..2c5c7003eb --- /dev/null +++ b/test-files/_helpers/gap10437_side_g.cjs @@ -0,0 +1,2 @@ +console.log('side_g evaluated') +module.exports = 'g' diff --git a/test-files/_helpers/gap10437_side_h.cjs b/test-files/_helpers/gap10437_side_h.cjs new file mode 100644 index 0000000000..3a0b41452a --- /dev/null +++ b/test-files/_helpers/gap10437_side_h.cjs @@ -0,0 +1,2 @@ +console.log('side_h evaluated') +module.exports = 'h' diff --git a/test-files/test_gap_cjs_conditional_require_deferred.ts b/test-files/test_gap_cjs_conditional_require_deferred.ts new file mode 100644 index 0000000000..01d2549078 --- /dev/null +++ b/test-files/test_gap_cjs_conditional_require_deferred.ts @@ -0,0 +1,20 @@ +// #10437: CommonJS `require()` outside a function is hoisted and run +// unconditionally at module init, whatever the surrounding control flow. +// Perry loaded every `require('')` in a CJS file before the +// file's first statement ran, so a branch that never runs (`if (false)`, a +// false env check, `&&`, `?:`, `switch`, a loop that never iterates) still +// loaded its module, and a module reached via a taken branch loaded before +// the statements preceding it. +// +// The crash form is `pg` 8.22.0: `lib/index.js` guards its optional native +// binding behind `if (forceNative) { require('./native') }`, and `./native` +// requires the optional, often-uninstalled `pg-native`. Every program using +// `pg` crashed at init with `Cannot find module 'pg-native'` even though +// `forceNative` was false. `./_helpers/gap10437_cjs_lazy_require.cjs` +// reproduces the full variant matrix (A-H from the issue, plus require +// caching and a swallowed try/catch around a genuinely missing module) in +// one file so the expected interleaving with its own `console.log` calls is +// unambiguous. +import mod from "./_helpers/gap10437_cjs_lazy_require.cjs"; + +console.log("typeof never=" + typeof mod.never); From a4d9d3c653cc22a5dc18d3a46006e663e19b9404 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 22:45:40 +0000 Subject: [PATCH 10/19] changelog: #10674 --- .../10674-cjs-conditional-require-deferred.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 changelog.d/10674-cjs-conditional-require-deferred.md diff --git a/changelog.d/10674-cjs-conditional-require-deferred.md b/changelog.d/10674-cjs-conditional-require-deferred.md new file mode 100644 index 0000000000..7b1c487441 --- /dev/null +++ b/changelog.d/10674-cjs-conditional-require-deferred.md @@ -0,0 +1,36 @@ +### Fixed + +- **CommonJS `require()` outside a function is no longer hoisted past the + control flow that guards it.** Perry's CJS→ESM wrap turned every + literal `require('S')` in a wrapped file into a static `import` at the + top of the module and eager-initialized the target — even when the call + sat inside `if (false)`, a false env check, `&&`/`??`, a ternary arm, a + `switch` case, or a loop that never iterates. A module reached only + through such a branch loaded (and could throw) at program start, + regardless of whether the branch ever ran; a module reached through a + taken branch loaded before the statements preceding it. This was the + sole remaining blocker compiling `pg` from source: `lib/index.js` guards + its optional native binding behind `if (forceNative) { require('./native') }`, + and `./native` requires the often-uninstalled `pg-native` — every + program using `pg` crashed at init with `Cannot find module 'pg-native'` + even though `forceNative` was false. + `cjs_wrap::extract_requires::function_local_specs` now classifies a + `require()` call site as deferred (Node's actual "loads only when + control flow reaches it" semantics) whenever it sits inside a + control-flow block (`if`/`for`/`while`/`switch`/`catch`/`try`/`else`/ + `do`/`finally`) or a braceless/operator equivalent (`cond && + require(...)`, `cond ? require(...) : x`, `for (...) require(...)` with + no block) — not only inside a function body as before. An ordinary + object literal or class body still does not count, so the common + `module.exports = { fs: require('fs'), path: require('path') }` barrel + shape stays eager. A `process.platform === ''` guard (the + node-pty Windows/Unix terminal split) is exempted from the broader + reclassification and keeps its existing eager treatment — the platform + is a compile-time-known build target, not a runtime unknown, and + `wrap_commonjs_for_target`'s dead-branch pruning already resolves it. + +Verified end-to-end: `pg` now compiles, links, and runs from real source +under `perry.compilePackages`, reaching a real TCP connect attempt with no +`pg-native` crash. + +Fixes #10437. From ada0628f37c833c0192c4b580eced706331b2952 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 21:34:24 +0000 Subject: [PATCH 11/19] wip(runtime): support class expressions in dyn_eval interpreter (#10661) --- crates/perry-runtime/src/dyn_eval/expr.rs | 10 +- crates/perry-runtime/src/dyn_eval/interp.rs | 197 ++++++++++++++++++++ crates/perry-runtime/src/dyn_eval/mod.rs | 7 +- 3 files changed, 210 insertions(+), 4 deletions(-) diff --git a/crates/perry-runtime/src/dyn_eval/expr.rs b/crates/perry-runtime/src/dyn_eval/expr.rs index 0a582d9f3b..c2b77405b5 100644 --- a/crates/perry-runtime/src/dyn_eval/expr.rs +++ b/crates/perry-runtime/src/dyn_eval/expr.rs @@ -7,8 +7,12 @@ //! sequence operators, `typeof`/`instanceof`/`in`/`delete`, assignments //! (plain, compound, logical, destructuring), member + computed access, //! optional chaining, calls (host functions, host methods, interpreted -//! closures — with `this` bound like the runtime binds it), and `new` on -//! host constructors / builtin error types / RegExp. +//! closures — with `this` bound like the runtime binds it), `new` on +//! host constructors / builtin error types / RegExp, and #10661 class +//! expressions restricted to: a constructor plus regular (non-getter/setter, +//! non-generator/async) instance/static methods with identifier, string, or +//! numeric keys — see `interp::eval_class_expr` for exactly what is and +//! isn't covered. //! //! Everything else throws the #6559 diagnostic naming the construct. @@ -94,7 +98,7 @@ pub(crate) fn eval_expr(ctx: &Ctx, expr: &ast::Expr, env_idx: usize) -> f64 { OptChain(o) => eval_opt_chain(ctx, o, env_idx), Await(_) => throw_unsupported("await (async interpreted code)"), Yield(_) => throw_unsupported("yield (generator interpreted code)"), - Class(_) => throw_unsupported("class expression"), + Class(c) => super::interp::eval_class_expr(ctx, c, env_idx), TaggedTpl(_) => throw_unsupported("tagged template literal"), SuperProp(_) => throw_unsupported("super property access"), MetaProp(_) => throw_unsupported("new.target / import.meta"), diff --git a/crates/perry-runtime/src/dyn_eval/interp.rs b/crates/perry-runtime/src/dyn_eval/interp.rs index b3fc1819d2..6aae4d0c96 100644 --- a/crates/perry-runtime/src/dyn_eval/interp.rs +++ b/crates/perry-runtime/src/dyn_eval/interp.rs @@ -1089,3 +1089,200 @@ fn exec_try_catch(ctx: &Ctx, t: &ast::TryStmt, env_idx: usize) -> Flow { fn protected_block(ctx: &Ctx, t: &ast::TryStmt, env_idx: usize) -> Flow { exec_block_scope(ctx, &t.block, env_idx) } + +// ── class expressions (#10661) ────────────────────────────────────────────── + +/// `class [Name] { constructor(...) { ... } method(...) { ... } ... }` as a +/// standalone expression — the shape `generate-function` emits (mysql2's row +/// parsers: `return class TextRow { constructor(fields) {...} next(...) {...} }`). +/// +/// **Supported subset, deliberately narrow** (matches what the schema/codegen +/// corpus behind #6559 actually emits, not general ES2022 class syntax): +/// * an optional `constructor`; missing one synthesizes an empty no-op +/// constructor (there is no `extends`, so there is nothing to forward to +/// a super constructor); +/// * regular (non-getter/setter, non-generator/async) methods, instance or +/// `static`, keyed by identifier / string / numeric literal; +/// * a named class expression sees its own name inside its body, exactly +/// like a named function expression. +/// +/// **Explicitly unsupported** (throws the #6559 diagnostic naming the +/// construct, same as every other out-of-subset form in this interpreter): +/// `extends` (no superclass chain — no `super()`/`super.foo` machinery +/// exists here), decorators, getters/setters, generator/async methods, +/// class fields (public or private), private methods, static blocks, +/// auto-accessors, TS index signatures, TS parameter properties, and +/// computed member keys. +/// +/// **Why this is sugar, not a new mechanism.** The interpreter already +/// supports the ES5 pattern this desugars to — `function Foo(){}` plus +/// `Foo.prototype.bar = function(){}` plus `new Foo()` — because ordinary +/// property writes on an interpreted closure already land in its dynamic +/// expando table (ajv's `validate.errors = ...` already exercises that path), +/// and `new` on ANY closure (host or interpreted) already goes through +/// `js_new_function_construct`'s generic path, which specifically looks for a +/// `"prototype"` dynamic prop to link the new instance's `[[Prototype]]` +/// (`crates/perry-runtime/src/object/class_registry/construct.rs`). So this +/// function does nothing runtime-side that wasn't already reachable from +/// interpreted code — it just builds a constructor closure, a plain prototype +/// object, and wires them together the same way hand-written ES5 would. +/// Nothing new is added to `js_new_function_construct`, method dispatch, or +/// `instanceof` — an instance built this way is an ordinary object whose +/// `[[Prototype]]` happens to be the class's prototype object, found by the +/// same prototype-chain walk any plain object uses. +pub(crate) fn eval_class_expr(ctx: &Ctx, class_expr: &ast::ClassExpr, env_idx: usize) -> f64 { + let class = class_expr.class.as_ref(); + if class.super_class.is_some() { + throw_unsupported("class expression with `extends`"); + } + if !class.decorators.is_empty() { + throw_unsupported("class decorator"); + } + + let base = roots_len(); + + // Named class expressions see their own name inside constructor AND + // method bodies — same pattern `make_function_value` uses for named + // function expressions: chain a one-binding scope, alloc the closure + // over it, then backfill the binding once the closure value exists. + let name = class_expr.ident.as_ref().map(|i| i.sym.to_string()); + let body_env_idx = if name.is_some() { + let name_env = env::env_new(root_get(env_idx)); + root_push(name_env) + } else { + env_idx + }; + + let ctor_member = class.body.iter().find_map(|m| match m { + ast::ClassMember::Constructor(c) => Some(c), + _ => None, + }); + let ctor_fn_id = match ctor_member { + Some(c) => { + let mut params = Vec::with_capacity(c.params.len()); + for p in &c.params { + match p { + ast::ParamOrTsParamProp::Param(p) => params.push(p.pat.clone()), + ast::ParamOrTsParamProp::TsParamProp(_) => throw_unsupported( + "TypeScript parameter property in class constructor", + ), + } + } + let body = + InterpBody::Block(c.body.as_ref().map(|b| b.stmts.clone()).unwrap_or_default()); + fn_id_for_node(c as *const ast::Constructor as usize, || { + build_interp_fn(params, body, ctx.strict) + }) + } + None => { + // No constructor written: synthesize an empty one. Keyed on the + // `Class` node itself (there is no dedicated AST node for a + // synthesized constructor) — only used as a cache key, stable + // for the same reason every other node-address key here is: + // `FN_REGISTRY` keeps the owning `InterpFn` (and therefore this + // address) alive for the program's lifetime. + fn_id_for_node(class as *const ast::Class as usize, || { + build_interp_fn(Vec::new(), InterpBody::Block(Vec::new()), ctx.strict) + }) + } + }; + + let ctor_closure = alloc_interp_closure( + ctor_fn_id, + root_get(body_env_idx), + None, + root_get(ctx.global_idx), + root_get(ctx.intrinsics_idx), + ctx.strings_allowed, + ctx.wasm_allowed, + ); + let ctor_idx = root_push(ctor_closure); + + if let Some(name) = &name { + env::define(root_get(body_env_idx), name, root_get(ctor_idx)); + } + + // Plain object, `Object.prototype`-rooted — same as any object literal. + let prototype = bridge::attach_intrinsic_prototype( + bridge::object_new(), + root_get(ctx.intrinsics_idx), + "Object", + ); + let proto_idx = root_push(prototype); + bridge::set_member(root_get(proto_idx), "constructor", root_get(ctor_idx)); + + for member in &class.body { + match member { + ast::ClassMember::Constructor(_) => {} + ast::ClassMember::Method(m) => { + if m.kind != ast::MethodKind::Method { + throw_unsupported("getter/setter in class body"); + } + if m.function.is_generator || m.function.is_async { + throw_unsupported("generator/async method in class body"); + } + let value = make_function_value( + ctx, + m.function.params.iter().map(|p| p.pat.clone()).collect(), + InterpBody::Block( + m.function + .body + .as_ref() + .map(|b| b.stmts.clone()) + .unwrap_or_default(), + ), + false, + None, + m.function.as_ref() as *const ast::Function as usize, + body_env_idx, + ); + let target_idx = if m.is_static { ctor_idx } else { proto_idx }; + set_class_member(target_idx, &m.key, value); + } + ast::ClassMember::PrivateMethod(_) => throw_unsupported("private method (#field)"), + ast::ClassMember::ClassProp(_) => throw_unsupported("class field"), + ast::ClassMember::PrivateProp(_) => throw_unsupported("private class field (#field)"), + ast::ClassMember::TsIndexSignature(_) => { + throw_unsupported("TypeScript index signature in class body") + } + ast::ClassMember::Empty(_) => {} + ast::ClassMember::StaticBlock(_) => throw_unsupported("static initialization block"), + ast::ClassMember::AutoAccessor(_) => throw_unsupported("auto-accessor class member"), + } + } + + // Wire the two together last: `Ctor.prototype = proto` is the dynamic + // expando write `js_new_function_construct` specifically looks for + // (`closure_get_dynamic_prop(fp, "prototype")`) to link a `new`-built + // instance's `[[Prototype]]` to `proto` instead of the closure's default + // (empty, per-function) prototype object. + bridge::set_member(root_get(ctor_idx), "prototype", root_get(proto_idx)); + + let result = root_get(ctor_idx); + roots_truncate(base); + result +} + +/// Set a class member (method) by its `PropName` onto the target (prototype +/// or constructor, for instance vs. `static`). Rejects computed and bigint +/// keys — see `eval_class_expr`'s documented subset. +fn set_class_member(target_idx: usize, key: &ast::PropName, value: f64) { + let value_idx = root_push(value); + match key { + ast::PropName::Ident(i) => { + bridge::set_member(root_get(target_idx), &i.sym, root_get(value_idx)) + } + ast::PropName::Str(s) => bridge::set_member( + root_get(target_idx), + &String::from_utf8_lossy(s.value.as_bytes()), + root_get(value_idx), + ), + ast::PropName::Num(n) => { + let k = bridge::make_number(n.value); + bridge::set_index(root_get(target_idx), k, root_get(value_idx), false); + } + ast::PropName::Computed(_) => throw_unsupported("computed method name in class body"), + ast::PropName::BigInt(_) => throw_unsupported("bigint method name in class body"), + } + roots_truncate(value_idx); +} diff --git a/crates/perry-runtime/src/dyn_eval/mod.rs b/crates/perry-runtime/src/dyn_eval/mod.rs index 6edcca6d22..7515dae07f 100644 --- a/crates/perry-runtime/src/dyn_eval/mod.rs +++ b/crates/perry-runtime/src/dyn_eval/mod.rs @@ -15,7 +15,12 @@ //! covers the pragmatic subset those code generators emit (see `interp.rs` / //! `expr.rs`); anything outside the subset throws a diagnostic TypeError //! naming the unsupported construct, so real-world gaps surface as clear -//! errors instead of silent miscomputation. +//! errors instead of silent miscomputation. #10661: `generate-function` +//! (mysql2's row parsers, and others beyond mysql2) emits a **class +//! expression** as the returned value — `interp::eval_class_expr` supports a +//! deliberately narrow subset of that (constructor + plain methods, no +//! `extends`/decorators/getters/setters/fields/private members/computed +//! keys); see its doc comment for the exact boundary. //! //! Bridging is the crux and it is bidirectional: //! * interpreted code calls REAL runtime values (schema refs, format From 24b35bf25af4701a0190a9bf1fab24497e10a28c Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 21:52:40 +0000 Subject: [PATCH 12/19] test(runtime): unit tests for dyn_eval class expression support (#10661) --- crates/perry-runtime/src/dyn_eval/tests.rs | 178 ++++++++++++++++++++- 1 file changed, 176 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/dyn_eval/tests.rs b/crates/perry-runtime/src/dyn_eval/tests.rs index 7696d0dd12..b92932f160 100644 --- a/crates/perry-runtime/src/dyn_eval/tests.rs +++ b/crates/perry-runtime/src/dyn_eval/tests.rs @@ -397,11 +397,16 @@ fn parse_error_throws_syntax_error() { #[test] fn unsupported_construct_diagnostic_names_the_construct() { + // #10661 narrowed what counts as "unsupported" here: a plain class + // expression is now interpreted (see the `class_expression_*` tests + // above). `extends` stays out of the supported subset (no superclass + // chain / `super()` machinery exists in this interpreter), so it is + // still the representative "diagnostic names the construct" case. let result = catch_throw(|| { - let f = dyn_fn(&["return class {}"]); + let f = dyn_fn(&["return class extends Array {}"]); call(f, &[]) }); - let exc = result.expect_err("class expression must be rejected"); + let exc = result.expect_err("class expression with extends must be rejected"); let msg = error_message(exc); assert!( msg.contains("unsupported construct") && msg.contains("class"), @@ -1076,3 +1081,172 @@ fn promise_static_result_retains_intrinsic_prototype() { buys nothing and costs a process-wide fast-path invalidation" ); } + +// ── class expressions (#10661) ────────────────────────────────────────────── +// +// mysql2's row parsers (`lib/parsers/text_parser.js` / +// `binary_parser.js`, via `generate-function`) build EXACTLY this shape at +// runtime — captured verbatim from a live `mysql2` `SELECT` against a real +// server (`Function.apply(null, keys.concat(src)).apply(null, vals)`, +// `generate-function/index.js:172`): +// +// (function anonymous() { +// return ((function () { +// return class TextRow { +// constructor(fields) {} +// next(packet, fields, options) { +// this.packet = packet; +// const result = {}; +// result["val"] = packet.readLengthCodedString(fields[0].encoding); +// return result; +// } +// }; +// })()) +// }) +// +// The tests below exercise that shape (minus the host `packet` receiver, +// which is out of unit-test scope the same way +// `interpreted_code_constructs_host_class_parameter` above notes) plus the +// rest of the documented subset, and confirm the documented boundary +// (`extends`, getters/setters, private members, computed keys, class fields, +// static blocks) still throws the #6559 diagnostic. + +#[test] +fn class_expression_mysql2_row_parser_shape() { + let f = dyn_fn(&[r#" + return (function () { + return class TextRow { + constructor(fields) { + this.fields = fields; + } + next(extra) { + return this.fields + extra; + } + }; + })(); + "#]); + let ctor_idx = root_push(call(f, &[])); + let inst = super::bridge::construct(root_get(ctor_idx), &[num(3.0)]); + let inst_idx = root_push(inst); + let result = super::bridge::call_method(root_get(inst_idx), "next", &[num(4.0)]); + roots_truncate(ctor_idx); + assert_eq!(as_num(result), 7.0); +} + +#[test] +fn class_expression_default_constructor_and_instance_state() { + // No explicit constructor: synthesized empty one, matching a class with + // no `constructor(...)` member. + let f = dyn_fn(&[r#" + return class Empty { + set(v) { this.v = v; return this; } + get() { return this.v; } + }; + "#]); + let ctor_idx = root_push(call(f, &[])); + let inst = super::bridge::construct(root_get(ctor_idx), &[]); + let inst_idx = root_push(inst); + super::bridge::call_method(root_get(inst_idx), "set", &[num(9.0)]); + let result = super::bridge::call_method(root_get(inst_idx), "get", &[]); + roots_truncate(ctor_idx); + assert_eq!(as_num(result), 9.0); +} + +#[test] +fn class_expression_static_method_and_string_numeric_keys() { + let f = dyn_fn(&[r#" + return class Keyed { + static make() { return new Keyed(); } + "str-key"() { return "s"; } + 0() { return "n"; } + }; + "#]); + let ctor_idx = root_push(call(f, &[])); + let made = super::bridge::call_method(root_get(ctor_idx), "make", &[]); + let made_idx = root_push(made); + assert_eq!( + as_str(super::bridge::call_method(root_get(made_idx), "str-key", &[])), + "s" + ); + assert_eq!(as_str(super::bridge::call_method(root_get(made_idx), "0", &[])), "n"); + roots_truncate(ctor_idx); +} + +#[test] +fn class_expression_two_instances_do_not_share_state() { + let f = dyn_fn(&[r#" + return class Counter { + constructor() { this.n = 0; } + inc() { this.n = this.n + 1; return this.n; } + }; + "#]); + let ctor_idx = root_push(call(f, &[])); + let a = super::bridge::construct(root_get(ctor_idx), &[]); + let a_idx = root_push(a); + let b = super::bridge::construct(root_get(ctor_idx), &[]); + let b_idx = root_push(b); + super::bridge::call_method(root_get(a_idx), "inc", &[]); + super::bridge::call_method(root_get(a_idx), "inc", &[]); + let a_result = super::bridge::call_method(root_get(a_idx), "inc", &[]); + let b_result = super::bridge::call_method(root_get(b_idx), "inc", &[]); + roots_truncate(ctor_idx); + assert_eq!(as_num(a_result), 3.0); + assert_eq!(as_num(b_result), 1.0); +} + +#[test] +fn class_expression_named_self_reference() { + // A named class expression sees its own name inside its body, same as a + // named function expression. + let f = dyn_fn(&[r#" + return (class Self { + static describe() { return typeof Self; } + }).describe(); + "#]); + let r = call(f, &[]); + assert_eq!(as_str(r), "function"); +} + +#[test] +fn class_expression_with_extends_is_unsupported() { + let f = dyn_fn(&["return class Sub extends Array {};"]); + let err = catch_throw(|| call(f, &[])).expect_err("extends must throw"); + assert!( + error_message(err).contains("class expression with `extends`"), + "unexpected message: {}", + error_message(err) + ); +} + +#[test] +fn class_expression_getter_is_unsupported() { + let f = dyn_fn(&["return class G { get x() { return 1; } };"]); + let err = catch_throw(|| call(f, &[])).expect_err("getter must throw"); + assert!(error_message(err).contains("getter/setter in class body")); +} + +#[test] +fn class_expression_field_is_unsupported() { + let f = dyn_fn(&["return class F { x = 1; };"]); + let err = catch_throw(|| call(f, &[])).expect_err("class field must throw"); + assert!(error_message(err).contains("class field")); +} + +#[test] +fn class_expression_computed_key_is_unsupported() { + let f = dyn_fn(&[r#" + const k = "m"; + return class C { [k]() { return 1; } }; + "#]); + let err = catch_throw(|| call(f, &[])).expect_err("computed key must throw"); + assert!(error_message(err).contains("computed method name in class body")); +} + +#[test] +fn class_declaration_statement_remains_unsupported() { + // Only the class EXPRESSION form is in scope for #10661; a class + // declaration statement is untouched. + let f = dyn_fn(&["class D {} return D;"]); + let err = catch_throw(|| call(f, &[])).expect_err("class declaration must throw"); + assert!(error_message(err).contains("class declaration")); +} From e5d6f8fbdd7a82fb309f39ef3e770280da66adf8 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 22:23:34 +0000 Subject: [PATCH 13/19] test(gap): class-expression dyn_eval gap test for #10661 (mysql2 row-parser shape) --- .../test_gap_10661_dyn_eval_class_expr.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 test-files/test_gap_10661_dyn_eval_class_expr.ts diff --git a/test-files/test_gap_10661_dyn_eval_class_expr.ts b/test-files/test_gap_10661_dyn_eval_class_expr.ts new file mode 100644 index 0000000000..6a46d76af0 --- /dev/null +++ b/test-files/test_gap_10661_dyn_eval_class_expr.ts @@ -0,0 +1,108 @@ +// #10661: Perry's `new Function` runtime interpreter (#6559) did not support +// class expressions, so `mysql2` compiled from source but crashed at runtime +// with "unsupported construct: class expression" — `mysql2`'s row parsers +// are built at runtime by `generate-function` +// (`Function.apply(null, keys.concat(src)).apply(null, vals)`, +// generate-function/index.js:172) and the generated source is a class +// expression. +// +// This mirrors the EXACT shape captured from a live `mysql2` `SELECT` +// against a real server (`lib/parsers/text_parser.js`'s `compile()`): +// +// (function anonymous(wrap, LocalDate) { +// return ((function () { +// return class TextRow { +// constructor(fields) {} +// next(packet, fields, options) { ... } +// }; +// })()) +// }) +// +// plus the rest of the #10661 supported subset (constructor + regular +// instance/static methods, string/numeric keys, named self-reference) — +// everything the class expression form does NOT need (`extends`, +// getters/setters, private members, computed keys, class fields) is +// out of scope and stays untouched by this test. +// +// `genfun()` below is a minimal stand-in for `generate-function`'s own +// `genfun()`: `toFunction` assembles `"return (" + + ")"` and +// runs it through `Function.apply(null, keys.concat(src)).apply(null, vals)` +// — verbatim generate-function/index.js:154-172. Every `gen(...)` chain below +// therefore supplies the BODY of that one implicit `return (...)`, so a chain +// only writes its own `return` when it is inside a nested function scope +// (block 1 and 4's inner IIFE) — never at the outer level, which is exactly +// how mysql2's real `text_parser.js`/`binary_parser.js` codegen is shaped. + +function genfun() { + const lines: string[] = []; + const gen: any = function (line: string) { + lines.push(line); + return gen; + }; + gen.toFunction = function (scope: any) { + const src = "return (" + lines.join("\n") + ")"; + const keys = Object.keys(scope || {}); + const vals = keys.map((key) => scope[key]); + return Function.apply(null, keys.concat(src)).apply(null, vals); + }; + return gen; +} + +// 1. mysql2's row-parser shape verbatim: a nested IIFE returning a class +// expression with a constructor and one instance method. +{ + const gen = genfun(); + gen("(function () {")("return class TextRow {")("constructor(fields) {")( + "this.fields = fields;" + )("}")("next(extra) {")("return this.fields + extra;")("}")("};")("})()"); + const TextRow = gen.toFunction({}); + const row = new TextRow(3); + console.log("mysql2-row-parser", typeof TextRow, row.next(4)); +} + +// 2. A named class expression with constructor + multiple instance methods, +// built through the same `Function.apply` machinery, static method +// referencing the class by its own name, and string/numeric method keys. +{ + const gen = genfun(); + gen("class Counter {")("constructor(start) {")("this.n = start;")("}")( + "inc() {" + )("this.n = this.n + 1;")("return this.n;")("}")("static make(start) {")( + "return new Counter(start);" + )("}")('"label"() {')('return "counter";')("}")("0() {")( + 'return "zero-key";' + )("}")("}"); + const Counter = gen.toFunction({}); + const a = new Counter(10); + const b = Counter.make(100); + console.log("counter-a", a.inc(), a.inc(), a.inc()); + console.log("counter-b", b.inc(), b.inc()); + console.log("counter-a-again", a.inc()); + console.log("counter-label", a["label"]()); + console.log("counter-zero-key", a[0]()); +} + +// 3. No explicit constructor — the default (empty) constructor. +{ + const gen = genfun(); + gen("class Empty {")("set(v) { this.v = v; return this; }")( + "get() { return this.v; }" + )("}"); + const Empty = gen.toFunction({}); + const e = new Empty(); + console.log("empty-ctor", e.set(42).get()); +} + +// 4. A row-parser-shaped class over multiple synthetic fields, matching the +// per-field member assignment `text_parser.js` actually generates. +{ + const gen = genfun(); + gen("(function () {")("return class Row {")("constructor(fields) {")("}")( + "next(values) {" + )("var result = {};")('result["id"] = values[0];')( + 'result["name"] = values[1];' + )("return result;")("}")("};")("})()"); + const Row = gen.toFunction({}); + const row = new Row([1, 2]); + console.log("row-parser-fields", JSON.stringify(row.next([7, "ann"]))); +} From a9386dd66158b248c6da4d750493f98471dc90db Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 22:47:04 +0000 Subject: [PATCH 14/19] style: cargo fmt for #10661 class-expression changes --- crates/perry-runtime/src/dyn_eval/interp.rs | 6 +++--- crates/perry-runtime/src/dyn_eval/tests.rs | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/dyn_eval/interp.rs b/crates/perry-runtime/src/dyn_eval/interp.rs index 6aae4d0c96..e4f4788485 100644 --- a/crates/perry-runtime/src/dyn_eval/interp.rs +++ b/crates/perry-runtime/src/dyn_eval/interp.rs @@ -1163,9 +1163,9 @@ pub(crate) fn eval_class_expr(ctx: &Ctx, class_expr: &ast::ClassExpr, env_idx: u for p in &c.params { match p { ast::ParamOrTsParamProp::Param(p) => params.push(p.pat.clone()), - ast::ParamOrTsParamProp::TsParamProp(_) => throw_unsupported( - "TypeScript parameter property in class constructor", - ), + ast::ParamOrTsParamProp::TsParamProp(_) => { + throw_unsupported("TypeScript parameter property in class constructor") + } } } let body = diff --git a/crates/perry-runtime/src/dyn_eval/tests.rs b/crates/perry-runtime/src/dyn_eval/tests.rs index b92932f160..0fe12e05da 100644 --- a/crates/perry-runtime/src/dyn_eval/tests.rs +++ b/crates/perry-runtime/src/dyn_eval/tests.rs @@ -1165,10 +1165,17 @@ fn class_expression_static_method_and_string_numeric_keys() { let made = super::bridge::call_method(root_get(ctor_idx), "make", &[]); let made_idx = root_push(made); assert_eq!( - as_str(super::bridge::call_method(root_get(made_idx), "str-key", &[])), + as_str(super::bridge::call_method( + root_get(made_idx), + "str-key", + &[] + )), "s" ); - assert_eq!(as_str(super::bridge::call_method(root_get(made_idx), "0", &[])), "n"); + assert_eq!( + as_str(super::bridge::call_method(root_get(made_idx), "0", &[])), + "n" + ); roots_truncate(ctor_idx); } From f8ec1cb784ed30790b3800bbf6fd1c6e26f5b528 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 22:48:07 +0000 Subject: [PATCH 15/19] docs(changelog): #10675 dyn_eval class expression support --- changelog.d/10675-dyn-eval-class-expr.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 changelog.d/10675-dyn-eval-class-expr.md diff --git a/changelog.d/10675-dyn-eval-class-expr.md b/changelog.d/10675-dyn-eval-class-expr.md new file mode 100644 index 0000000000..d759df2289 --- /dev/null +++ b/changelog.d/10675-dyn-eval-class-expr.md @@ -0,0 +1,11 @@ +Fixed `new Function`-string interpreter (#6559) to support **class expressions** in a +deliberately narrow subset: a constructor plus regular instance/`static` methods +(identifier/string/numeric keys), no `extends`/decorators/getters-setters/fields/private +members/computed keys/static blocks. This was the sole runtime blocker for `mysql2`, whose +`generate-function`-built row parsers (`text_parser.js`/`binary_parser.js`) return a class +expression from a `Function.apply(...).apply(...)` call; `generate-function` is used well beyond +mysql2, so this likely unblocks other packages too. Desugars onto machinery the interpreter +already had (closure expando writes + the generic `new ` path that already reads a +`"prototype"` dynamic prop), so no new runtime mechanism was added. mysql2 now runs an +end-to-end `CREATE`/`INSERT`/`SELECT`/`DROP` round trip against a real server; the hand-written +native mysql2 binding now looks deletable as a follow-up. See #10661. From 228b943c058439d8ad1080ce4081a343e36ac4bf Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sat, 19 Sep 2026 07:34:55 +0000 Subject: [PATCH 16/19] fix(codegen): an inherited property read no longer folds to undefined on a scalar-replaced object (#10689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_escapes_in_expr`'s `Expr::PropertyGet` arm treated every read on a scalar-replacement candidate as a plain field read, without checking that the class chain declares the key. Scalar replacement allocates a slot per declared field only, so `expr/property_get.rs`'s scalar arm found no slot and folded the read to the constant `undefined`. The effect was silent and order-dependent: const o = { a: 1 }; typeof o.toString // undefined, where node gives "function" o.toString() // correct — a fused call never consults the // elided object It reached `Object.prototype` members read as values (`toString`, `constructor`, `hasOwnProperty`), a class's own prototype method read as a value, and user-added `Object.prototype` properties. Anything that made the receiver escape — passing it to a function, storing it in an array — repaired it, which is what made the bug look like it depended on unrelated earlier statements. This is the READ half of the rule the write arms already apply: #9024 for `PropertySet`/`PutValueSet` and #9460 for `PropertyUpdate`, plus the sibling literal analysis in `escape_objects.rs`. Only the read arm was missing it. Reads of declared fields still take the no-heap scalar path, which is spec-correct because an own property shadows the chain and OrdinaryGet never reaches the prototype. Reads of undeclared keys now take the ordinary heap path. A fused method call is unaffected: its callee is handled in the `Expr::Call` arm and is no longer routed through `PropertyGet`, so `simple_scalar_method_summary` receivers stay scalar-replaced. --- changelog.d/10689-inherited-read-escape.md | 33 +++ .../src/collectors/escape_check.rs | 73 +++-- .../object_prototype_value_read_10689.rs | 280 ++++++++++++++++++ 3 files changed, 367 insertions(+), 19 deletions(-) create mode 100644 changelog.d/10689-inherited-read-escape.md create mode 100644 crates/perry/tests/object_prototype_value_read_10689.rs diff --git a/changelog.d/10689-inherited-read-escape.md b/changelog.d/10689-inherited-read-escape.md new file mode 100644 index 0000000000..fd7580b402 --- /dev/null +++ b/changelog.d/10689-inherited-read-escape.md @@ -0,0 +1,33 @@ +### Fixed + +- **`typeof o.toString` on a non-escaping object literal answered `undefined` (#10689).** + Reading an *inherited* member as a VALUE — `o.constructor`, `o.toString`, + `o.hasOwnProperty`, a user-added `Object.prototype` property, or a class's own + prototype METHOD read as a value — answered `undefined` whenever the receiver + was a scalar-replacement candidate, while *calling* the same member + (`o.toString()`, `"" + o`, `` `${o}` ``) was correct. No error: the program + took the other branch and continued. + + The mechanism is escape analysis, not the lazy `globalThis` realm the report + guessed at. `collectors/escape_check.rs`'s `PropertyGet` arm classified every + read on a candidate local as a "plain field read — safe", without checking + that the class chain actually declares the key. The local stayed + scalar-replaced (no heap object exists at all) and `expr/property_get.rs`'s + scalar arm folded the slot-less read to the constant `undefined`. The three + WRITE arms of the same analysis already carried exactly this rule — #9024 for + `PropertySet`/`PutValueSet`, #9460 for `PropertyUpdate` — and the sibling + object-literal analysis in `collectors/escape_objects.rs` has always had it; + only the read arm was missing it. + + That also explains the reported order-dependence. `JSON.stringify(o)` earlier + in the function "repaired" the read because passing `o` to a call makes it + escape — not because it forced the realm. `JSON.stringify` of an *unrelated* + object forces the realm just the same and did **not** repair it; that case is + pinned as a test. + + `Expr::Call`'s arm no longer routes a fused method-call callee + (`o.m()`) back through the `PropertyGet` arm, so the receivers that + `simple_scalar_method_summary` deliberately keeps scalar-replaced still are. + Measured instruction-neutral on the r0–r9 ladder (max |Δ| 0.02%, noise). + + Regression test: `crates/perry/tests/object_prototype_value_read_10689.rs`. diff --git a/crates/perry-codegen/src/collectors/escape_check.rs b/crates/perry-codegen/src/collectors/escape_check.rs index ef20f86861..2470f65f19 100644 --- a/crates/perry-codegen/src/collectors/escape_check.rs +++ b/crates/perry-codegen/src/collectors/escape_check.rs @@ -222,7 +222,36 @@ pub fn check_escapes_in_expr( escaped.insert(*id); return; } - // Plain field read — safe, don't recurse into object. + // #10689: a read of a key the class chain does not + // DECLARE as a field is an INHERITED read — an + // `Object.prototype` member (`toString`, `constructor`, + // `hasOwnProperty`), a prototype method read as a value, + // or a user-added `Object.prototype` property. Scalar + // replacement allocates a slot per declared field only, so + // `expr/property_get.rs`'s scalar arm finds none and folds + // the read to the constant `undefined` — silently, and + // only while the receiver happens not to escape, which is + // why `JSON.stringify(o)` earlier in the function + // "repaired" it. Escape the receiver so the read takes the + // ordinary heap path, which resolves the prototype chain. + // + // This is the READ half of the rule the three WRITE arms + // below already apply (#9024 `PropertySet`/`PutValueSet`, + // #9460 `PropertyUpdate`), and the per-property form of + // #6343's whole-class unmodeled-base escape. + // + // A fused method CALL (`o.m()`) is NOT this: its callee is + // handled in the `Expr::Call` arm, which does not route the + // callee through here, so `simple_scalar_method_summary` + // receivers stay scalar-replaced. + if !crate::collectors::class_accessors::class_chain_has_field( + classes, class_name, property, + ) { + escaped.insert(*id); + return; + } + // Plain declared-field read — safe, don't recurse into + // object. return; } } @@ -465,31 +494,37 @@ pub fn check_escapes_in_expr( // and fixed numeric params. That summary lets codegen inline the // body against scalar field slots instead of dispatching with a // heap receiver. - if let Expr::PropertyGet { object, .. } = callee.as_ref() { + // #10689: set when the callee IS the fused method-call form on a + // candidate receiver. That callee is a CALL target, not a value + // read of `property`, so it must not be sent through the + // `PropertyGet` arm — whose inherited-read rule would escape every + // receiver whose method the summary below deliberately keeps + // scalar-replaced. The receiver is `LocalGet(id)` itself, so + // skipping the recursion hides no nested candidate. + let mut callee_is_candidate_method_call = false; + if let Expr::PropertyGet { + object, property, .. + } = callee.as_ref() + { if let Expr::LocalGet(id) = object.as_ref() { - if candidates.contains_key(id) { - let is_summarized = if let Expr::PropertyGet { property, .. } = - callee.as_ref() - { - candidates.get(id).is_some_and(|class_name| { - crate::collectors::simple_scalar_method_summary( - classes, - class_name, - property, - args.len(), - ) - .is_some() - }) - } else { - false - }; + if let Some(class_name) = candidates.get(id) { + let is_summarized = crate::collectors::simple_scalar_method_summary( + classes, + class_name, + property, + args.len(), + ) + .is_some(); if !is_summarized { escaped.insert(*id); } + callee_is_candidate_method_call = true; } } } - check_escapes_in_expr(callee, candidates, classes, escaped); + if !callee_is_candidate_method_call { + check_escapes_in_expr(callee, candidates, classes, escaped); + } for a in args { check_escapes_in_expr(a, candidates, classes, escaped); } diff --git a/crates/perry/tests/object_prototype_value_read_10689.rs b/crates/perry/tests/object_prototype_value_read_10689.rs new file mode 100644 index 0000000000..338944a805 --- /dev/null +++ b/crates/perry/tests/object_prototype_value_read_10689.rs @@ -0,0 +1,280 @@ +//! Regression: reading an INHERITED member of a non-escaping object as a +//! VALUE must resolve through the prototype chain, not fold to `undefined`. +//! +//! Issue #10689. `const o = { a: 1 }; typeof o.toString` answered `undefined` +//! while `o.toString()` answered correctly, and the divergence was +//! order-dependent: adding `JSON.stringify(o)` earlier in the function +//! "repaired" it. +//! +//! The mechanism is escape analysis, not the lazy `globalThis` realm the +//! report guessed at. `collectors/escape_check.rs`'s `PropertyGet` arm treated +//! EVERY read on a scalar-replacement candidate as a "plain field read — safe", +//! including reads of keys the class chain does not declare. The local then +//! stayed scalar-replaced (no heap object at all) and +//! `expr/property_get.rs`'s scalar arm folded the slot-less read to the +//! constant `undefined`. `JSON.stringify(o)` only appeared to fix it because +//! passing `o` to a call makes it escape; `JSON.stringify` of an UNRELATED +//! object — which forces the realm just the same — does not, and that case is +//! pinned below. +//! +//! The three WRITE arms of the same analysis already carried this rule +//! (#9024 `PropertySet`/`PutValueSet`, #9460 `PropertyUpdate`); only the read +//! arm was missing it. +//! +//! Fixtures are `.js`, not `.ts`, so `Object.prototype.zz = 7` and +//! `o.nope` are valid source without `as any` casts — a cast would route the +//! read through the dynamic path and miss the statically-shaped lowering the +//! bug lived in. + +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +/// Write `entry` into `dir`, compile it with `--no-cache` +/// `PERRY_NO_AUTO_OPTIMIZE=1` (links the prebuilt runtime archive), run it, and +/// return stdout. Mirrors the helper in `builtin_namespace_unknown_member.rs`. +fn compile_and_run_js(dir: &Path, entry: &str, source: &str) -> String { + let entry_path = dir.join(entry); + std::fs::write(&entry_path, source).expect("write fixture"); + let output = dir.join(format!("{entry}.bin")); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry_path) + .arg("--no-cache") + .arg("-o") + .arg(&output) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// The core case. `o` is read but never passed anywhere, so nothing makes it +/// escape and nothing forces the realm — the exact program shape #10689 +/// reported. Every line was `undefined` / `false` before the fix. +#[test] +fn inherited_object_prototype_members_read_as_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +const o = { a: 1 }; +console.log("typeof-constructor:", typeof o.constructor); +console.log("typeof-toString:", typeof o.toString); +console.log("typeof-hasOwnProperty:", typeof o.hasOwnProperty); +console.log("typeof-valueOf:", typeof o.valueOf); +console.log("typeof-isPrototypeOf:", typeof o.isPrototypeOf); +console.log("typeof-propertyIsEnumerable:", typeof o.propertyIsEnumerable); +console.log("ctor-is-Object:", o.constructor === Object); +console.log("toString-is-proto-toString:", o.toString === Object.prototype.toString); +"#, + ); + for expected in [ + "typeof-constructor: function", + "typeof-toString: function", + "typeof-hasOwnProperty: function", + "typeof-valueOf: function", + "typeof-isPrototypeOf: function", + "typeof-propertyIsEnumerable: function", + "ctor-is-Object: true", + "toString-is-proto-toString: true", + ] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} + +/// The ordering half of #10689, and the case that names the real mechanism. +/// +/// `JSON.stringify` is what the report found "repaired" the read — but only +/// when it was handed `o` ITSELF, which makes `o` escape. Stringifying an +/// UNRELATED object forces exactly the same lazy realm and must not change the +/// answer, and the read before it must agree with the read after it. Before the +/// fix all four reads here were `undefined`; a fix that merely forced the realm +/// from the read path would leave this test passing for the wrong reason, so it +/// asserts the invariant (before == after == "function"), not the repair. +#[test] +fn inherited_value_read_does_not_depend_on_evaluation_order() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +const o = { a: 1 }; +console.log("before-toString:", typeof o.toString); +console.log("before-constructor:", typeof o.constructor); +// Forces `populate_global_this_builtins` without `o` escaping. +JSON.stringify({ unrelated: 2 }); +console.log("after-toString:", typeof o.toString); +console.log("after-constructor:", typeof o.constructor); +"#, + ); + for expected in [ + "before-toString: function", + "before-constructor: function", + "after-toString: function", + "after-constructor: function", + ] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} + +/// The other direction of the same pair: calls through the inherited chain were +/// always correct and must STAY correct, so a future change cannot "fix" reads +/// by routing them through something that breaks the call path. +#[test] +fn inherited_object_prototype_members_are_still_callable() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +const o = { a: 1 }; +console.log("call-toString:", o.toString()); +console.log("call-hasOwnProperty-present:", o.hasOwnProperty("a")); +console.log("call-hasOwnProperty-absent:", o.hasOwnProperty("b")); +console.log("concat:", "" + o); +console.log("template:", `${o}`); +console.log("in-operator:", "constructor" in o); +console.log("proto-identity:", Object.getPrototypeOf(o) === Object.prototype); +// Read-then-call through a local, the form that needs a real function value. +const f = o.toString; +console.log("read-then-call:", f.call(o)); +"#, + ); + for expected in [ + "call-toString: [object Object]", + "call-hasOwnProperty-present: true", + "call-hasOwnProperty-absent: false", + "concat: [object Object]", + "template: [object Object]", + "in-operator: true", + "proto-identity: true", + "read-then-call: [object Object]", + ] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} + +/// The same hole on a declared class: a PROTOTYPE METHOD read as a value off a +/// non-escaping `new` answered `undefined` while the fused call answered +/// correctly. Same arm, same fold — `m` is not a declared FIELD, so scalar +/// replacement had no slot for it. +#[test] +fn prototype_method_read_as_value_on_non_escaping_instance() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +// `m` is body-summarizable, which is what keeps `c` scalar-replaced across +// the fused call below. A method whose body the summary rejects escapes its +// receiver for that reason alone and would not exercise the fold. +class C { + m() { return 1; } +} +const c = new C(); +console.log("typeof-method:", typeof c.m); +console.log("call-method:", c.m()); +"#, + ); + for expected in ["typeof-method: function", "call-method: 1"] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} + +/// A user-added `Object.prototype` member is inherited by a plain object. The +/// folded read could not see it at all, which is the form the report warned +/// about: a library feature-detects and silently takes the other branch. +#[test] +fn user_added_object_prototype_member_is_inherited() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +Object.prototype.perryInherited = 7; +const o = { a: 1 }; +console.log("inherited-value:", o.perryInherited); +console.log("own-value:", o.a); +"#, + ); + for expected in ["inherited-value: 7", "own-value: 1"] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} + +/// The opposite failure the fix must not cause: a key that is on neither the +/// object nor its prototype chain still reads `undefined`, and own fields still +/// come from their scalar slots. Without this, "make every miss escape" would +/// pass the tests above while answering some non-`undefined` value here. +#[test] +fn genuinely_absent_key_is_still_undefined_and_own_fields_are_unchanged() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run_js( + dir.path(), + "main.js", + r#" +const o = { a: 1, b: "x" }; +console.log("absent-typeof:", typeof o.definitelyNotThere); +console.log("absent-is-undefined:", o.definitelyNotThere === undefined); +console.log("own-a:", o.a); +console.log("own-b:", o.b); +const n = { count: 0 }; +n.count = n.count + 41; +n.count++; +console.log("own-updated:", n.count); +"#, + ); + for expected in [ + "absent-typeof: undefined", + "absent-is-undefined: true", + "own-a: 1", + "own-b: x", + "own-updated: 42", + ] { + assert!( + stdout.contains(expected), + "missing {expected:?}\nstdout:\n{stdout}" + ); + } +} From 6ac4ad574d374f0f1fc174230776cad051e2a8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 04:46:19 +0000 Subject: [PATCH 17/19] refactor(stdlib): remove jsonwebtoken native binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #10683. The hand-written native jsonwebtoken binding (crates/perry-ext-jsonwebtoken, plus a second duplicate implementation in crates/perry-stdlib/src/jsonwebtoken.rs exporting the same js_jwt_* symbols per #10678) has a live security defect: verify() returns null instead of throwing on every forgery case (tampered payload, wrong secret, alg:none, garbage token, tampered signature, expired token), so `try { jwt.verify(...) } catch { reject() }` never rejects a forgery. sign(..., { expiresIn: "1h" }) also silently drops the expiry (string coerces to NaN, and the runtime only writes `exp` when > 0.0). Removes both copies plus the dedicated codegen lowering path (lower_call/native/jsonwebtoken.rs's lower_jsonwebtoken_sign/_verify and its native_runtime_branch.rs dispatch), the decode-only NativeModSig row in native_table/utils_crypto.rs, the js_jwt_* FFI declarations in runtime_decls/stdlib_ffi/third_party.rs, the well_known_bindings.toml entry, the NATIVE_MODULES/manifest rows, the bundled-jsonwebtoken stdlib feature (re-wiring dep:rsa/dep:spki directly onto perry-stdlib's `crypto` feature, since webcrypto/key_object.rs and keys.rs need them independently of jsonwebtoken), and the Android stub exports. The real `jsonwebtoken` crates.io dependency stays — it is unrelated Rust tooling used by perry's own Apple code-signing (commands/run/resign.rs, commands/setup/common_apple.rs). Regenerated docs/api/perry.d.ts, docs/src/api/reference.md (--print-api-manifest) and docs/src/native-libraries/governance.md (binding_governance.py --table). Updated workspace-architecture.json (workspace_members 83->82, externalize 33->32) and scripts/string_payload_access_baseline.txt (perry-stdlib inline-offset sites 40->39, from the deleted stdlib file). --- Cargo.lock | 12 - Cargo.toml | 2 - crates/perry-api-manifest/src/entries.rs | 1 - .../perry-api-manifest/src/entries/part_1.rs | 65 -- .../src/lower_call/native/jsonwebtoken.rs | 290 ------ .../src/lower_call/native/mod.rs | 4 - .../native/native_runtime_branch.rs | 7 - .../lower_call/native_table/utils_crypto.rs | 18 - .../runtime_decls/stdlib_ffi/third_party.rs | 21 - crates/perry-ext-jsonwebtoken/Cargo.toml | 22 - crates/perry-ext-jsonwebtoken/src/lib.rs | 399 -------- crates/perry-stdlib/Cargo.toml | 4 +- crates/perry-stdlib/src/jsonwebtoken.rs | 904 ------------------ crates/perry-stdlib/src/lib.rs | 5 - crates/perry-ui-android/src/stdlib_stubs.rs | 20 - crates/perry/src/commands/stdlib_features.rs | 1 - crates/perry/well_known_bindings.toml | 12 - docs/api/perry.d.ts | 11 +- docs/src/api/reference.md | 11 +- docs/src/native-libraries/governance.md | 1 - scripts/string_payload_access_baseline.txt | 2 +- scripts/unrooted_local_shape_baseline.json | 1 - workspace-architecture.json | 9 +- 23 files changed, 6 insertions(+), 1816 deletions(-) delete mode 100644 crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs delete mode 100644 crates/perry-ext-jsonwebtoken/Cargo.toml delete mode 100644 crates/perry-ext-jsonwebtoken/src/lib.rs delete mode 100644 crates/perry-stdlib/src/jsonwebtoken.rs diff --git a/Cargo.lock b/Cargo.lock index 7693bb21b9..8d196773a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6026,17 +6026,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "perry-ext-jsonwebtoken" -version = "0.5.1597" -dependencies = [ - "base64 0.22.1", - "jsonwebtoken", - "perry-ffi", - "serde", - "serde_json", -] - [[package]] name = "perry-ext-lru-cache" version = "0.5.1597" @@ -6420,7 +6409,6 @@ dependencies = [ "hyper", "hyper-util", "image", - "jsonwebtoken", "lazy_static", "lettre", "libc", diff --git a/Cargo.toml b/Cargo.toml index baed25af0c..081a017221 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ members = [ "crates/perry-ext-uuid", "crates/perry-ext-bcrypt", "crates/perry-ext-argon2", - "crates/perry-ext-jsonwebtoken", "crates/perry-ext-validator", "crates/perry-validation", "crates/perry-perex", @@ -482,7 +481,6 @@ perry-ext-nanoid = { path = "crates/perry-ext-nanoid" } perry-ext-uuid = { path = "crates/perry-ext-uuid" } perry-ext-bcrypt = { path = "crates/perry-ext-bcrypt" } perry-ext-argon2 = { path = "crates/perry-ext-argon2" } -perry-ext-jsonwebtoken = { path = "crates/perry-ext-jsonwebtoken" } perry-ext-validator = { path = "crates/perry-ext-validator" } perry-validation = { path = "crates/perry-validation" } perry-perex = { path = "crates/perry-perex" } diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 0e1415b629..de7f6a8bec 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -47,7 +47,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "crypto", // (Node builtin) hashing, HMAC, cipher, sign/verify, WebCrypto "dotenv", // .env file loader "dotenv/config", // dotenv's auto-load-on-import subpath - "jsonwebtoken", // JWT sign/verify "nanoid", // compact URL-safe ID generation "validator", // string validators/sanitizers "ethers", // Ethereum library (utils/wallet/ABI) diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index aa51556739..5af3539565 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -1166,71 +1166,6 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ }], TypeSpec::Number, ), - method_sig( - "jsonwebtoken", - "sign", - false, - None, - &[ - ParamSpec::Named { - name: "payload", - ty: TypeSpec::Any, - optional: false, - }, - ParamSpec::Named { - name: "secret", - ty: TypeSpec::String, - optional: false, - }, - ParamSpec::Named { - name: "options", - ty: TypeSpec::Any, - optional: true, - }, - // #915: FFI's 4th arg is `kid_ptr: *const StringHeader` — the - // dispatch table padding zeroes it when the user doesn't pass - // it. Surfacing the slot in the manifest keeps the - // #512 arity-drift assertion happy without forcing every - // caller to write a 4th positional arg. - ParamSpec::Named { - name: "kid", - ty: TypeSpec::String, - optional: true, - }, - ], - TypeSpec::String, - ), - method_sig( - "jsonwebtoken", - "verify", - false, - None, - &[ - ParamSpec::Named { - name: "token", - ty: TypeSpec::String, - optional: false, - }, - ParamSpec::Named { - name: "secret", - ty: TypeSpec::String, - optional: false, - }, - ], - TypeSpec::Any, - ), - method_sig( - "jsonwebtoken", - "decode", - false, - None, - &[ParamSpec::Named { - name: "token", - ty: TypeSpec::String, - optional: false, - }], - TypeSpec::Any, - ), method_sig( "nodemailer", "createTransport", diff --git a/crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs b/crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs deleted file mode 100644 index dbdec4be7c..0000000000 --- a/crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! `lower_jsonwebtoken_sign` / `lower_jsonwebtoken_verify` and the -//! payload-pointer helper. Split out of `lower_call/native.rs` -//! (~272 LOC) so the parent module stays under the 2000-line cap. -//! -//! Both entry points are option-aware (algorithm: HS256 / ES256 / -//! RS256) and route to typed runtime helpers when possible, falling -//! back to the `_dyn` / `_dyn_opts` paths when the algorithm / -//! options object isn't an inline literal (#1074). - -use anyhow::{bail, Result}; -use perry_hir::Expr; - -use crate::expr::{lower_expr, FnCtx}; -use crate::nanbox::double_literal; -use crate::type_analysis::is_string_expr; -use crate::types::{DOUBLE, I32, I64}; - -use super::*; - -fn lower_jsonwebtoken_payload_ptr(ctx: &mut FnCtx<'_>, payload: &Expr) -> Result { - if is_string_expr(ctx, payload) { - return get_raw_string_ptr(ctx, payload); - } - - let boxed_payload = lower_expr(ctx, payload)?; - Ok(ctx.block().call( - I64, - "js_json_stringify", - &[(DOUBLE, &boxed_payload), (I32, "0")], - )) -} - -pub(super) fn lower_jsonwebtoken_sign(ctx: &mut FnCtx<'_>, args: &[Expr]) -> Result { - if args.len() < 2 { - bail!( - "jsonwebtoken.sign(payload, secret, options?) expects at least 2 args, got {}", - args.len() - ); - } - - let payload_ptr = lower_jsonwebtoken_payload_ptr(ctx, &args[0])?; - let secret_ptr = get_raw_string_ptr(ctx, &args[1])?; - let mut runtime = "js_jwt_sign"; - // #1074: when the user writes `{ algorithm: ALG }` (i.e. `algorithm` - // is a non-literal expression), the inline-literal fast path can't - // pick a typed runtime helper. We track that as a fallback to - // `js_jwt_sign_dyn`, which takes the alg string as a runtime argument - // and dispatches there. Pre-#1074 this fell through to the HS256 - // path silently — a real cryptographic downgrade. - let mut alg_ptr_dyn: Option = None; - let mut expires_in = double_literal(0.0); - let mut kid_ptr = "0".to_string(); - - if let Some(options) = args.get(2) { - if let Some(props) = extract_options_fields(ctx, options) { - for (key, val) in &props { - match key.as_str() { - "algorithm" => { - if let Expr::String(algorithm) = val { - runtime = match algorithm.as_str() { - "ES256" => "js_jwt_sign_es256", - "RS256" => "js_jwt_sign_rs256", - _ => "js_jwt_sign", - }; - } else { - // Non-literal alg (#1074): lower to a string - // pointer and let `js_jwt_sign_dyn` pick the - // right backend at runtime. - alg_ptr_dyn = Some(get_raw_string_ptr(ctx, val)?); - runtime = "js_jwt_sign_dyn"; - } - } - "expiresIn" => { - expires_in = lower_expr(ctx, val)?; - } - "keyid" | "kid" => { - kid_ptr = get_raw_string_ptr(ctx, val)?; - } - _ => { - let _ = lower_expr(ctx, val)?; - } - } - } - } else { - // #1074 case C: the options expression is not an inline - // object literal (e.g. `const opts = { algorithm: "ES256" }; - // jwt.sign(p, k, opts)`). Lower options as a NaN-boxed - // JSValue and route to `js_jwt_sign_dyn_opts`, which - // extracts algorithm/expiresIn/keyid at runtime. - let opts_val = lower_expr(ctx, options)?; - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - ctx.pending_declares.push(( - "js_jwt_sign_dyn_opts".to_string(), - I64, - vec![I64, I64, DOUBLE], - )); - let raw = ctx.block().call( - I64, - "js_jwt_sign_dyn_opts", - &[(I64, &payload_ptr), (I64, &secret_ptr), (DOUBLE, &opts_val)], - ); - return Ok(ctx.block().bitcast_i64_to_double(&raw)); - } - } - - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - - // Build the call. The five-arg dyn path takes the alg string first; - // the four-arg typed-helper path doesn't (the algorithm is implied - // by the symbol name). - let raw = if let Some(alg_ptr) = alg_ptr_dyn { - ctx.pending_declares.push(( - "js_jwt_sign_dyn".to_string(), - I64, - vec![I64, I64, I64, DOUBLE, I64], - )); - ctx.block().call( - I64, - "js_jwt_sign_dyn", - &[ - (I64, &alg_ptr), - (I64, &payload_ptr), - (I64, &secret_ptr), - (DOUBLE, &expires_in), - (I64, &kid_ptr), - ], - ) - } else { - ctx.pending_declares - .push((runtime.to_string(), I64, vec![I64, I64, DOUBLE, I64])); - ctx.block().call( - I64, - runtime, - &[ - (I64, &payload_ptr), - (I64, &secret_ptr), - (DOUBLE, &expires_in), - (I64, &kid_ptr), - ], - ) - }; - Ok(ctx.block().bitcast_i64_to_double(&raw)) -} - -/// Dispatch `jsonwebtoken.verify(token, secret_or_pem, options?)` to -/// the right runtime (HS256 / ES256 / RS256) based on the -/// `algorithms: ['…']` (or singular `algorithm: '…'`) option. -/// Mirrors `lower_jsonwebtoken_sign`. -/// -/// perry#927 follow-up: the generic NativeModSig table picked -/// `js_jwt_verify` (HS256-only) for every algorithm, so ES256 / RS256 -/// tokens silently failed verification (returning `null` to user -/// code, breaking the shop-admin auth middleware after a successful -/// signup). Verify needs the same option-aware routing that `sign` -/// already has. -/// -/// Return shape matches the old `NR_OBJ_FROM_JSON_STR`: the runtime -/// hands back a JSON-text `*mut StringHeader` (or null), which we -/// pipe through `js_json_parse_or_null` so user code sees a real -/// object on success and `null` on failure (no throw). -pub(super) fn lower_jsonwebtoken_verify(ctx: &mut FnCtx<'_>, args: &[Expr]) -> Result { - if args.len() < 2 { - bail!( - "jsonwebtoken.verify(token, secret, options?) expects at least 2 args, got {}", - args.len() - ); - } - - let token_ptr = get_raw_string_ptr(ctx, &args[0])?; - let secret_ptr = get_raw_string_ptr(ctx, &args[1])?; - let mut runtime = "js_jwt_verify"; - // #1074: when `algorithm` (or the first entry of `algorithms`) is a - // non-literal expression, lower it as a string and route through - // `js_jwt_verify_dyn` instead of silently picking HS256. - let mut alg_ptr_dyn: Option = None; - - if let Some(options) = args.get(2) { - if let Some(props) = extract_options_fields(ctx, options) { - for (key, val) in &props { - match key.as_str() { - // `algorithm: 'ES256'` (singular) — accepted for - // symmetry with `sign`'s option name. - "algorithm" => { - if let Expr::String(algorithm) = val { - runtime = match algorithm.as_str() { - "ES256" => "js_jwt_verify_es256", - "RS256" => "js_jwt_verify_rs256", - _ => "js_jwt_verify", - }; - } else { - alg_ptr_dyn = Some(get_raw_string_ptr(ctx, val)?); - runtime = "js_jwt_verify_dyn"; - } - } - // `algorithms: ['ES256']` (plural array) — the - // canonical Node `jsonwebtoken.verify` shape. - // First entry decides routing; the underlying Rust - // jsonwebtoken crate's verify is single-algorithm, - // so multi-algorithm fallback isn't honored. - "algorithms" => { - if let Expr::Array(elems) = val { - match elems.first() { - Some(Expr::String(algorithm)) => { - runtime = match algorithm.as_str() { - "ES256" => "js_jwt_verify_es256", - "RS256" => "js_jwt_verify_rs256", - _ => "js_jwt_verify", - }; - } - // #1074: first element is a non-literal - // (e.g. `algorithms: [ALG]` where ALG is - // a const-bound name). Lower it as a - // string and route through the dyn path. - Some(other) => { - alg_ptr_dyn = Some(get_raw_string_ptr(ctx, other)?); - runtime = "js_jwt_verify_dyn"; - } - None => {} - } - } else { - // `algorithms` is a non-array expression - // (e.g. a const-bound array reference). We - // could try harder, but the runtime opts - // path below already handles this when the - // whole options object is non-extractable. - // Lower the side effect and let the - // following HS256 fallback fire — same as - // pre-#1074 (rare in practice). - let _ = lower_expr(ctx, val)?; - } - } - _ => { - let _ = lower_expr(ctx, val)?; - } - } - } - } else { - // #1074 case C: options is not an inline object literal — - // defer extraction to `js_jwt_verify_dyn_opts`, which reads - // `algorithm` / `algorithms[0]` at runtime. - let opts_val = lower_expr(ctx, options)?; - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - ctx.pending_declares.push(( - "js_jwt_verify_dyn_opts".to_string(), - I64, - vec![I64, I64, DOUBLE], - )); - ctx.pending_declares - .push(("js_json_parse_or_null".to_string(), I64, vec![I64])); - let blk = ctx.block(); - let raw = blk.call( - I64, - "js_jwt_verify_dyn_opts", - &[(I64, &token_ptr), (I64, &secret_ptr), (DOUBLE, &opts_val)], - ); - let parsed_bits = blk.call(I64, "js_json_parse_or_null", &[(I64, &raw)]); - return Ok(blk.bitcast_i64_to_double(&parsed_bits)); - } - } - - for extra in args.iter().skip(3) { - let _ = lower_expr(ctx, extra)?; - } - - let raw = if let Some(alg_ptr) = alg_ptr_dyn { - ctx.pending_declares - .push(("js_jwt_verify_dyn".to_string(), I64, vec![I64, I64, I64])); - ctx.block().call( - I64, - "js_jwt_verify_dyn", - &[(I64, &alg_ptr), (I64, &token_ptr), (I64, &secret_ptr)], - ) - } else { - ctx.pending_declares - .push((runtime.to_string(), I64, vec![I64, I64])); - ctx.block() - .call(I64, runtime, &[(I64, &token_ptr), (I64, &secret_ptr)]) - }; - ctx.pending_declares - .push(("js_json_parse_or_null".to_string(), I64, vec![I64])); - let blk = ctx.block(); - let parsed_bits = blk.call(I64, "js_json_parse_or_null", &[(I64, &raw)]); - Ok(blk.bitcast_i64_to_double(&parsed_bits)) -} diff --git a/crates/perry-codegen/src/lower_call/native/mod.rs b/crates/perry-codegen/src/lower_call/native/mod.rs index 8c255a1bd9..6f5e681b11 100644 --- a/crates/perry-codegen/src/lower_call/native/mod.rs +++ b/crates/perry-codegen/src/lower_call/native/mod.rs @@ -17,8 +17,6 @@ //! Split into siblings: //! - `box_style.rs` — `apply_box_style` + `emit_dim_setter` (perry/tui //! `Box(...)` inline-style destructure helpers). -//! - `jsonwebtoken.rs` — `lower_jsonwebtoken_sign` / `_verify` (#1074 -//! algorithm-aware routing). //! The giant `lower_native_method_call` dispatcher itself stays here. use anyhow::{bail, Result}; @@ -49,11 +47,9 @@ pub(super) use super::{ }; mod box_style; -mod jsonwebtoken; mod perf_hooks; use box_style::apply_box_style; -use jsonwebtoken::{lower_jsonwebtoken_sign, lower_jsonwebtoken_verify}; fn util_types_arg_is_async_function_static(ctx: &FnCtx<'_>, expr: &Expr) -> Option { match expr { diff --git a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs index c1e5a9a059..5fa5d09881 100644 --- a/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs @@ -312,13 +312,6 @@ } } - if module == "jsonwebtoken" && method == "sign" && object.is_none() { - return lower_jsonwebtoken_sign(ctx, args); - } - if module == "jsonwebtoken" && method == "verify" && object.is_none() { - return lower_jsonwebtoken_verify(ctx, args); - } - // node:perf_hooks → native/perf_hooks.rs (performance.* + PerformanceObserver). if let Some(v) = perf_hooks::lower_perf_hooks_method(ctx, module, method, object, args)? { return Ok(v); diff --git a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs index 880a733ee1..f94c677f70 100644 --- a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs +++ b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs @@ -75,24 +75,6 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ args: &[NA_STR], ret: NR_F64, }, - // ========== jsonwebtoken ========== - // `sign` and `verify` are intentionally handled in - // lower_call/native.rs — both need option-dependent runtime - // selection (HS256 / ES256 / RS256) that the generic table can't - // express. `decode` stays here because it has no algorithm options. - NativeModSig { - module: "jsonwebtoken", - has_receiver: false, - method: "decode", - class_filter: None, - runtime: "js_jwt_decode", - // js_jwt_decode(token_ptr) -> *mut StringHeader (JSON of payload). - // NR_OBJ_FROM_JSON_STR pipes the returned JSON through - // js_json_parse_or_null so user code sees an object (mirrors - // `verify`'s post-#927 contract). Issue #927. - args: &[NA_STR], - ret: NR_OBJ_FROM_JSON_STR, - }, // ========== nodemailer ========== NativeModSig { module: "nodemailer", diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index 471f8c5726..ff029c6f90 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -63,27 +63,6 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { module.declare_function("js_perry_native_f32", DOUBLE, &[DOUBLE]); module.declare_function("js_perry_native_f64", DOUBLE, &[DOUBLE]); - // ========== jsonwebtoken / JWT ========== - module.declare_function("js_jwt_decode", I64, &[I64]); - module.declare_function("js_jwt_sign", I64, &[I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_sign_es256", I64, &[I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_sign_rs256", I64, &[I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_verify", I64, &[I64, I64]); - module.declare_function("js_jwt_verify_es256", I64, &[I64, I64]); - module.declare_function("js_jwt_verify_rs256", I64, &[I64, I64]); - // #1074: runtime-algorithm dispatchers. The codegen `lower_jsonwebtoken_*` - // fast paths still hard-route literal `algorithm: "ES256"` to the typed - // helpers above; non-literal shapes (const-bound ident, spread, ternary) - // are routed here with the alg name lowered as a string at runtime. - module.declare_function("js_jwt_sign_dyn", I64, &[I64, I64, I64, DOUBLE, I64]); - module.declare_function("js_jwt_verify_dyn", I64, &[I64, I64, I64]); - // #1074 case C: options is a whole non-extractable expression - // (`const opts = { algorithm: "ES256" }; jwt.sign(p, k, opts)`). We - // pass `opts` as a NaN-boxed JSValue and the runtime helper extracts - // `algorithm` / `expiresIn` / `keyid` via `js_object_get_field_by_name`. - module.declare_function("js_jwt_sign_dyn_opts", I64, &[I64, I64, DOUBLE]); - module.declare_function("js_jwt_verify_dyn_opts", I64, &[I64, I64, DOUBLE]); - // ========== axios / node-fetch ========== module.declare_function("js_axios_create", DOUBLE, &[I64]); module.declare_function("js_axios_delete", I64, &[I64]); diff --git a/crates/perry-ext-jsonwebtoken/Cargo.toml b/crates/perry-ext-jsonwebtoken/Cargo.toml deleted file mode 100644 index 3450cc9270..0000000000 --- a/crates/perry-ext-jsonwebtoken/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "perry-ext-jsonwebtoken" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for the npm `jsonwebtoken` package — uses only `perry-ffi`. Sync, string-only port (Phase 5 step 7)." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true -jsonwebtoken.workspace = true -serde = { workspace = true } -serde_json = { workspace = true } -base64.workspace = true - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-jsonwebtoken/src/lib.rs b/crates/perry-ext-jsonwebtoken/src/lib.rs deleted file mode 100644 index 42faadda03..0000000000 --- a/crates/perry-ext-jsonwebtoken/src/lib.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! Native bindings for the npm `jsonwebtoken` package. -//! -//! Sync wrapper — no async/await, no Promise. Uses only the -//! perry-ffi v0.5 string surface. Functionally identical to -//! `crates/perry-stdlib/src/jsonwebtoken.rs`. Seventh wrapper port -//! under #466 Phase 5. - -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use perry_ffi::{alloc_string, nanbox_string_bits, read_string, JsString, StringHeader}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Generic claims structure that can hold any JSON. Mirrors the -/// shape `perry-stdlib::jsonwebtoken` uses so encoded / decoded -/// tokens are byte-compatible. -#[derive(Debug, Serialize, Deserialize)] -struct Claims { - #[serde(flatten)] - data: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - exp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iat: Option, - #[serde(skip_serializing_if = "Option::is_none")] - nbf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - sub: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iss: Option, - #[serde(skip_serializing_if = "Option::is_none")] - aud: Option, -} - -unsafe fn read_str(ptr: *const StringHeader) -> Option { - let handle = JsString::from_raw(ptr as *mut StringHeader); - read_string(handle).map(String::from) -} - -/// Shared signing logic — parse payload, apply expiry, encode with -/// the given algorithm/key. `kid_ptr` is optional (null = no `kid` -/// header field). Returns a NaN-boxed string i64, or 0 on error. -unsafe fn sign_common( - payload_ptr: *const StringHeader, - expires_in_secs: f64, - algorithm: Algorithm, - key: &EncodingKey, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(payload_json) = read_str(payload_ptr) else { - return 0; - }; - - let mut claims: Claims = serde_json::from_str(&payload_json).unwrap_or_else(|_| Claims { - data: HashMap::new(), - exp: None, - iat: None, - nbf: None, - sub: None, - iss: None, - aud: None, - }); - - if expires_in_secs > 0.0 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - claims.exp = Some(now + expires_in_secs as u64); - if claims.iat.is_none() { - claims.iat = Some(now); - } - } - - let mut header = Header::new(algorithm); - if !kid_ptr.is_null() { - if let Some(kid) = read_str(kid_ptr) { - if !kid.is_empty() { - header.kid = Some(kid); - } - } - } - - match encode(&header, &claims, key) { - Ok(token) => { - let s = alloc_string(&token); - nanbox_string_bits(s.as_raw()) as i64 - } - Err(_) => 0, - } -} - -/// `jwt.sign(payload, secret)` — HS256. -/// -/// # Safety -/// -/// All pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign( - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(secret) = read_str(secret_ptr) else { - return 0; - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::HS256, - &EncodingKey::from_secret(secret.as_bytes()), - kid_ptr, - ) -} - -/// `jwt.sign(payload, ecPrivateKeyPem, { algorithm: 'ES256' })` — -/// PKCS#8 PEM-encoded EC P-256 private key. Used by APNs. -/// -/// # Safety -/// -/// All pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_es256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(pem) = read_str(pem_ptr) else { - return 0; - }; - let Ok(key) = EncodingKey::from_ec_pem(pem.as_bytes()) else { - return 0; - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::ES256, - &key, - kid_ptr, - ) -} - -/// `jwt.sign(payload, rsaPrivateKeyPem, { algorithm: 'RS256' })` — -/// PKCS#8 PEM-encoded RSA private key. Used by FCM. -/// -/// # Safety -/// -/// All pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_rs256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let Some(pem) = read_str(pem_ptr) else { - return 0; - }; - let Ok(key) = EncodingKey::from_rsa_pem(pem.as_bytes()) else { - return 0; - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::RS256, - &key, - kid_ptr, - ) -} - -/// `jwt.verify(token, secret)` — HS256. Returns the claims as a -/// JSON string. -/// -/// # Safety -/// -/// `token_ptr` and `secret_ptr` must be null or Perry-runtime -/// `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify( - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, -) -> *mut StringHeader { - let Some(token) = read_str(token_ptr) else { - return std::ptr::null_mut(); - }; - let Some(secret) = read_str(secret_ptr) else { - return std::ptr::null_mut(); - }; - - let key = DecodingKey::from_secret(secret.as_bytes()); - let mut validation = Validation::new(Algorithm::HS256); - // Match Node's `jsonwebtoken`: validate the `exp` claim whenever it is - // present (so expired tokens are rejected), but do not *require* exp — a - // token that legitimately omits expiry still verifies. `required_spec_claims` - // stays empty for the latter; `validate_exp = true` enforces the former. - // - // This previously read `validate_exp = false`, which accepted expired - // tokens indefinitely (GHSA-5324-c68v-8w62 / CVE-2026-53777) — the same - // bug already fixed in crates/perry-stdlib/src/jsonwebtoken.rs. - validation.required_spec_claims = std::collections::HashSet::new(); - validation.validate_exp = true; - - match decode::(&token, &key, &validation) { - Ok(token_data) => { - let json = serde_json::to_string(&token_data.claims).unwrap_or_else(|_| "{}".into()); - alloc_string(&json).as_raw() - } - Err(_) => std::ptr::null_mut(), - } -} - -/// `jwt.decode(token)` — split-and-base64-decode the payload, no -/// signature verification. -/// -/// # Safety -/// -/// `token_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_decode(token_ptr: *const StringHeader) -> *mut StringHeader { - let Some(token) = read_str(token_ptr) else { - return std::ptr::null_mut(); - }; - - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return std::ptr::null_mut(); - } - - use base64::Engine; - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let Ok(payload_bytes) = engine.decode(parts[1]) else { - return std::ptr::null_mut(); - }; - let Ok(payload_json) = String::from_utf8(payload_bytes) else { - return std::ptr::null_mut(); - }; - if serde_json::from_str::(&payload_json).is_err() { - return std::ptr::null_mut(); - } - alloc_string(&payload_json).as_raw() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn s(handle: i64) -> String { - const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - let raw = (handle as u64 & POINTER_MASK) as *mut StringHeader; - read_string(unsafe { JsString::from_raw(raw) }) - .map(String::from) - .unwrap_or_default() - } - - fn ps(p: *mut StringHeader) -> Option { - if p.is_null() { - return None; - } - read_string(unsafe { JsString::from_raw(p) }).map(String::from) - } - - #[test] - fn sign_then_verify_round_trip() { - let payload = alloc_string(r#"{"sub":"1234","name":"Alice"}"#); - let secret = alloc_string("supersecret"); - let token_bits = unsafe { - js_jwt_sign( - payload.as_raw() as *const _, - secret.as_raw() as *const _, - 3600.0, - std::ptr::null(), - ) - }; - assert_ne!(token_bits, 0, "sign returned zero"); - let token = s(token_bits); - assert!( - token.starts_with("eyJ"), - "JWT should start with eyJ: {}", - token - ); - - let token_handle = alloc_string(&token); - let claims_ptr = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string("supersecret").as_raw() as *const _, - ) - }; - let claims = ps(claims_ptr).expect("verify returned non-null"); - assert!(claims.contains("\"name\":\"Alice\""), "got: {}", claims); - assert!(claims.contains("\"sub\":\"1234\""), "got: {}", claims); - } - - #[test] - fn verify_with_wrong_secret_returns_null() { - let payload = alloc_string(r#"{"sub":"x"}"#); - let token_bits = unsafe { - js_jwt_sign( - payload.as_raw() as *const _, - alloc_string("right").as_raw() as *const _, - 0.0, - std::ptr::null(), - ) - }; - let token = s(token_bits); - let token_handle = alloc_string(&token); - let result = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string("wrong").as_raw() as *const _, - ) - }; - assert!(result.is_null(), "wrong secret should fail verify"); - } - - #[test] - fn decode_skips_signature_check() { - // Decode unverified — even with a wrong secret, decode - // returns the payload. Used by clients that just need to - // peek at the claims (e.g. `exp`) before deciding whether - // to refresh. - let payload = alloc_string(r#"{"role":"admin"}"#); - let token_bits = unsafe { - js_jwt_sign( - payload.as_raw() as *const _, - alloc_string("k").as_raw() as *const _, - 0.0, - std::ptr::null(), - ) - }; - let token = s(token_bits); - let result_ptr = unsafe { js_jwt_decode(alloc_string(&token).as_raw() as *const _) }; - let claims = ps(result_ptr).expect("decode non-null"); - assert!(claims.contains("\"role\":\"admin\""), "got: {}", claims); - } - - #[test] - fn verify_rejects_expired_token() { - // Regression for #5066 / GHSA-5324-c68v-8w62: expired token must be rejected. - let secret = "supersecret"; - let expired_claims = Claims { - data: std::collections::HashMap::new(), - exp: Some(1), - iat: None, - nbf: None, - sub: Some("1234".into()), - iss: None, - aud: None, - }; - let token = encode( - &Header::new(Algorithm::HS256), - &expired_claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .expect("encode expired token"); - let token_handle = alloc_string(&token); - let result = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string(secret).as_raw() as *const _, - ) - }; - assert!( - result.is_null(), - "expired token must be rejected, got claims: {:?}", - ps(result) - ); - } - - #[test] - fn verify_accepts_token_without_exp() { - // Node parity: token omitting exp must still verify (required_spec_claims empty). - let secret = "supersecret"; - let claims = Claims { - data: std::collections::HashMap::new(), - exp: None, - iat: None, - nbf: None, - sub: Some("1234".into()), - iss: None, - aud: None, - }; - let token = encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .expect("encode no-exp token"); - let token_handle = alloc_string(&token); - let result = unsafe { - js_jwt_verify( - token_handle.as_raw() as *const _, - alloc_string(secret).as_raw() as *const _, - ) - }; - assert!(!result.is_null(), "token without exp must still verify"); - } -} diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 2881d64681..3dd3510220 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -229,10 +229,9 @@ bundled-mongodb = ["dep:mongodb", "dep:bson", "dep:futures-util", "async-runtime # bindings so the well-known flip (#466 Phase 4 step 2) can route them # to perry-ext-bcrypt / perry-ext-argon2 without taking the rest of # the crypto surface offline. -crypto = ["dep:sha2", "dep:sha1", "dep:sha3", "dep:shake", "dep:sha3_010", "dep:sha3-utils", "dep:rsa-sha1", "dep:md-5", "dep:hex", "dep:hmac", "dep:aes", "dep:aes_09", "dep:cbc", "dep:ecb", "dep:ctr", "dep:scrypt", "dep:pbkdf2", "dep:base64", "dep:x25519-dalek", "dep:x448", "dep:ed25519-dalek", "dep:ed448-goldilocks", "dep:aes-gcm", "dep:chacha20poly1305", "dep:ghash", "dep:aes-kw", "dep:hkdf", "dep:p256", "dep:p384", "dep:p521", "dep:x509-cert", "dep:ml-kem", "async-runtime", "ids", "bundled-bcrypt", "bundled-argon2", "bundled-jsonwebtoken", "bundled-ethers"] +crypto = ["dep:sha2", "dep:sha1", "dep:sha3", "dep:shake", "dep:sha3_010", "dep:sha3-utils", "dep:rsa-sha1", "dep:md-5", "dep:hex", "dep:hmac", "dep:aes", "dep:aes_09", "dep:cbc", "dep:ecb", "dep:ctr", "dep:scrypt", "dep:pbkdf2", "dep:base64", "dep:x25519-dalek", "dep:x448", "dep:ed25519-dalek", "dep:ed448-goldilocks", "dep:aes-gcm", "dep:chacha20poly1305", "dep:ghash", "dep:aes-kw", "dep:hkdf", "dep:p256", "dep:p384", "dep:p521", "dep:rsa", "dep:spki", "dep:x509-cert", "dep:ml-kem", "async-runtime", "ids", "bundled-bcrypt", "bundled-argon2", "bundled-ethers"] bundled-bcrypt = ["dep:bcrypt", "async-runtime"] bundled-argon2 = ["dep:argon2", "async-runtime"] -bundled-jsonwebtoken = ["dep:jsonwebtoken", "dep:p256", "dep:rsa", "dep:spki"] # ethers blockchain utilities — pure Rust, no extra deps. Default-on # through `crypto` umbrella; the well-known flip strips this and # routes to perry-ext-ethers when `import 'ethers'` is detected. @@ -388,7 +387,6 @@ md-5 = { version = "0.11", optional = true } hex = { workspace = true, optional = true } hmac = { version = "0.13", optional = true } bcrypt = { version = "0.19", optional = true } -jsonwebtoken = { workspace = true, optional = true } p256 = { version = "0.13", optional = true, default-features = false, features = ["pkcs8", "pem", "ecdsa", "ecdh"] } p384 = { version = "0.13", optional = true, default-features = false, features = ["pkcs8", "pem", "ecdsa", "ecdh"] } p521 = { version = "0.13", optional = true, default-features = false, features = ["pkcs8", "pem", "ecdsa", "ecdh"] } diff --git a/crates/perry-stdlib/src/jsonwebtoken.rs b/crates/perry-stdlib/src/jsonwebtoken.rs deleted file mode 100644 index 0bfb6dd0bd..0000000000 --- a/crates/perry-stdlib/src/jsonwebtoken.rs +++ /dev/null @@ -1,904 +0,0 @@ -//! JSON Web Token module (jsonwebtoken compatible) -//! -//! Native implementation of the 'jsonwebtoken' npm package. -//! Provides JWT sign, verify, and decode functionality. - -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use perry_runtime::{ - js_object_get_field_by_name, js_string_from_bytes, ObjectHeader, StringHeader, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use crate::common::string_from_header; - -/// Generic claims structure that can hold any JSON -#[derive(Debug, Serialize, Deserialize)] -struct Claims { - #[serde(flatten)] - data: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - exp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iat: Option, - #[serde(skip_serializing_if = "Option::is_none")] - nbf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - sub: Option, - #[serde(skip_serializing_if = "Option::is_none")] - iss: Option, - #[serde(skip_serializing_if = "Option::is_none")] - aud: Option, -} - -const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; -const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - -/// Shared signing logic — parse payload, apply expiry, encode with given algorithm/key. -/// `kid_ptr` is optional (null = no `kid` header field). Returns a NaN-boxed string i64, -/// or 0 on error. -unsafe fn sign_common( - payload_ptr: *const StringHeader, - expires_in_secs: f64, - algorithm: Algorithm, - key: &EncodingKey, - kid_ptr: *const StringHeader, -) -> i64 { - let payload_json = match string_from_header(payload_ptr) { - Some(p) => p, - None => return 0, - }; - - let mut claims: Claims = match serde_json::from_str(&payload_json) { - Ok(c) => c, - Err(_) => Claims { - data: HashMap::new(), - exp: None, - iat: None, - nbf: None, - sub: None, - iss: None, - aud: None, - }, - }; - - if expires_in_secs > 0.0 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - claims.exp = Some(now + expires_in_secs as u64); - if claims.iat.is_none() { - claims.iat = Some(now); - } - } - - let mut header = Header::new(algorithm); - if !kid_ptr.is_null() { - if let Some(kid) = string_from_header(kid_ptr) { - if !kid.is_empty() { - header.kid = Some(kid); - } - } - } - - match encode(&header, &claims, key) { - Ok(token) => { - let ptr = js_string_from_bytes(token.as_ptr(), token.len() as u32); - (STRING_TAG | (ptr as u64 & POINTER_MASK)) as i64 - } - Err(_) => 0, - } -} - -/// Sign a payload to create a JWT (HS256) -/// jwt.sign(payload, secret) -> string -/// jwt.sign(payload, secret, options) -> string -/// -/// `kid_ptr` may be null when no `keyid` is provided in options. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign( - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let secret = match string_from_header(secret_ptr) { - Some(s) => s, - None => return 0, - }; - let key = EncodingKey::from_secret(secret.as_bytes()); - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::HS256, - &key, - kid_ptr, - ) -} - -/// Sign a payload to create a JWT (ES256) -/// `pem_ptr` must contain a PKCS#8 PEM-encoded EC private key (P-256 curve). -/// jwt.sign(payload, ecPrivateKeyPem, { algorithm: 'ES256', keyid: '...' }) -> string -/// -/// Used by APNs (Apple Push Notification service) provider tokens — APNs requires -/// `kid` in the JWT header to identify which `.p8` key was used to sign. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_es256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => return 0, - }; - // jsonwebtoken's `EncodingKey::from_ec_pem` only accepts PKCS#8 - // (`-----BEGIN PRIVATE KEY-----`). openssl's default - // `ecparam -genkey -name prime256v1` emits SEC1 - // (`-----BEGIN EC PRIVATE KEY-----`), which is the form most users - // start with. Convert SEC1 → PKCS#8 transparently so both PEM - // forms work. Same ergonomic story as the verify side's - // `ec_pem_to_public_pem` helper. - let pkcs8_pem = if pem.contains("EC PRIVATE KEY") { - use p256::pkcs8::EncodePrivateKey; - match p256::SecretKey::from_sec1_pem(&pem) - .ok() - .and_then(|k| k.to_pkcs8_pem(Default::default()).ok()) - { - Some(p) => p.to_string(), - None => { - eprintln!("[jwt-sign-es256] could not convert SEC1 EC PEM to PKCS#8"); - return 0; - } - } - } else { - pem - }; - let key = match EncodingKey::from_ec_pem(pkcs8_pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - eprintln!("[jwt-sign-es256] invalid EC PEM key: {}", e); - return 0; - } - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::ES256, - &key, - kid_ptr, - ) -} - -/// Sign a payload to create a JWT (RS256) -/// `pem_ptr` must contain a PKCS#8 PEM-encoded RSA private key. -/// jwt.sign(payload, rsaPrivateKeyPem, { algorithm: 'RS256', keyid: '...' }) -> string -/// -/// Used by FCM (Firebase Cloud Messaging) OAuth assertions. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_rs256( - payload_ptr: *const StringHeader, - pem_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => return 0, - }; - let key = match EncodingKey::from_rsa_pem(pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - eprintln!("[jwt-sign-rs256] invalid RSA PEM key: {}", e); - return 0; - } - }; - sign_common( - payload_ptr, - expires_in_secs, - Algorithm::RS256, - &key, - kid_ptr, - ) -} - -/// Dynamic-algorithm `jwt.sign` dispatcher (#1074). -/// -/// The codegen fast path in `lower_jsonwebtoken_sign` routes inline-literal -/// `{ algorithm: "ES256" }` to `js_jwt_sign_es256` / `…_rs256` at compile -/// time. When `algorithm` is anything else — a const-bound identifier -/// (`const ALG = "ES256"; jwt.sign(p, k, { algorithm: ALG })`), a property -/// spread, a ternary, etc. — the fast path falls through and previously -/// silently signed with HS256 keyed by the user's PEM (cryptographic -/// downgrade: the token is HMAC-signed with the PEM bytes, on-wire -/// `header.alg` reads `"HS256"`, so any verifier that accepts either -/// HMAC OR EC/RSA quietly accepted the downgrade). -/// -/// This entry point reads the algorithm name from `alg_ptr` at runtime and -/// dispatches to the same `sign_common` paths the typed helpers use. The -/// inline-literal fast path remains for the common case; everything else -/// goes through here. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_dyn( - alg_ptr: *const StringHeader, - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - expires_in_secs: f64, - kid_ptr: *const StringHeader, -) -> i64 { - let alg_name = string_from_header(alg_ptr).unwrap_or_else(|| "HS256".to_string()); - match alg_name.as_str() { - "ES256" => js_jwt_sign_es256(payload_ptr, secret_ptr, expires_in_secs, kid_ptr), - "RS256" => js_jwt_sign_rs256(payload_ptr, secret_ptr, expires_in_secs, kid_ptr), - "HS256" | "" => js_jwt_sign(payload_ptr, secret_ptr, expires_in_secs, kid_ptr), - other => { - // Unknown alg — treat as HS256 fallback (matches the legacy - // non-literal behavior) but log under PERRY_DEBUG so callers - // can diagnose. The header.alg will still say HS256, so the - // user's verifier rejects it properly — this is a safer - // failure mode than the pre-#1074 silent downgrade. - if std::env::var_os("PERRY_DEBUG").is_some() { - eprintln!( - "[jwt-sign-dyn] unknown algorithm `{}`; falling back to HS256", - other - ); - } - js_jwt_sign(payload_ptr, secret_ptr, expires_in_secs, kid_ptr) - } - } -} - -/// Coerce a NaN-boxed JSValue (`f64`) into a raw `*const ObjectHeader` -/// pointer. Mirrors the upper-bits sniff used by the native HTTP bindings. -/// Returns null when the value isn't pointer-shaped. -unsafe fn jsvalue_to_object_ptr(obj_f64: f64) -> *const ObjectHeader { - let obj_bits = obj_f64.to_bits(); - let upper = obj_bits >> 48; - if upper >= 0x7FF8 { - (obj_bits & 0x0000_FFFF_FFFF_FFFF) as *const ObjectHeader - } else if upper == 0 && obj_bits >= 0x10000 { - obj_bits as *const ObjectHeader - } else { - std::ptr::null() - } -} - -/// Read a named property off a NaN-boxed object value, returning its -/// string-typed result as a `*const StringHeader` (or null when missing/ -/// not-a-string). The field name is materialized as a transient -/// `*const StringHeader` because that's `js_object_get_field_by_name`'s -/// signature. -unsafe fn opts_get_string_field(obj_f64: f64, field: &str) -> *const StringHeader { - let obj_ptr = jsvalue_to_object_ptr(obj_f64); - if obj_ptr.is_null() { - return std::ptr::null(); - } - let key = js_string_from_bytes(field.as_ptr(), field.len() as u32); - let val = js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return std::ptr::null(); - } - if val.is_string() { - return val.as_string_ptr(); - } - std::ptr::null() -} - -/// Read a named property as f64. Returns 0.0 when missing/non-numeric -/// (matches `lower_jsonwebtoken_sign`'s `expires_in = double_literal(0.0)` -/// default). -unsafe fn opts_get_number_field(obj_f64: f64, field: &str) -> f64 { - let obj_ptr = jsvalue_to_object_ptr(obj_f64); - if obj_ptr.is_null() { - return 0.0; - } - let key = js_string_from_bytes(field.as_ptr(), field.len() as u32); - let val = js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return 0.0; - } - if val.is_number() { - return val.as_number(); - } - 0.0 -} - -/// `jwt.sign(payload, secret, options)` where `options` is a non-extractable -/// expression (e.g. `const opts = { algorithm: "ES256", ... }; jwt.sign(p, k, opts)`) -/// — #1074 case C. The codegen lowers `opts` as a NaN-boxed JSValue and we -/// extract `algorithm` / `expiresIn` / `keyid` at runtime, then defer to -/// `js_jwt_sign_dyn`. Reads each option via `js_object_get_field_by_name` -/// (which works for ordinary `Expr::Object` literals → `__AnonShape_*` class -/// instances). -#[no_mangle] -pub unsafe extern "C" fn js_jwt_sign_dyn_opts( - payload_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - options_value: f64, -) -> i64 { - let alg_ptr = opts_get_string_field(options_value, "algorithm"); - // `keyid` is the spec-correct field name; `kid` is accepted as an alias - // (matches the inline-literal codegen path which special-cases both). - let kid_ptr = { - let p = opts_get_string_field(options_value, "keyid"); - if p.is_null() { - opts_get_string_field(options_value, "kid") - } else { - p - } - }; - let expires_in = opts_get_number_field(options_value, "expiresIn"); - js_jwt_sign_dyn(alg_ptr, payload_ptr, secret_ptr, expires_in, kid_ptr) -} - -/// Shared verify path — runs the decode + returns claims as JSON, or -/// a null pointer on any failure. `debug` mirrors the gating in -/// `js_jwt_verify` (perry#924) so all three verify entry points emit -/// the same `[jwt-verify]` log lines under `PERRY_DEBUG=1`. -unsafe fn verify_decode( - token: &str, - key: &DecodingKey, - algorithm: Algorithm, - debug: bool, -) -> *mut StringHeader { - let mut validation = Validation::new(algorithm); - // Match Node's `jsonwebtoken`: validate the `exp` claim whenever it is - // present (so expired tokens are rejected), but do not *require* exp — a - // token that legitimately omits expiry still verifies. `required_spec_claims` - // stays empty for the latter; `validate_exp = true` enforces the former. - // - // This previously read `validate_exp = false`, which disabled expiry - // enforcement for every JWT verification path in the stdlib — expired - // tokens were accepted indefinitely (GHSA-5324-c68v-8w62 / CVE-2026-53777). - validation.required_spec_claims = std::collections::HashSet::new(); - validation.validate_exp = true; - - match decode::(token, key, &validation) { - Ok(token_data) => { - let json = - serde_json::to_string(&token_data.claims).unwrap_or_else(|_| "{}".to_string()); - if debug { - eprintln!( - "[jwt-verify] success, claims={}", - &json[..json.len().min(80)] - ); - } - js_string_from_bytes(json.as_ptr(), json.len() as u32) - } - Err(e) => { - if debug { - eprintln!("[jwt-verify] error: {}", e); - } - std::ptr::null_mut() - } - } -} - -/// Verify and decode an HS256 JWT -/// jwt.verify(token, secret) -> object (payload) -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify( - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, -) -> *mut StringHeader { - // perry#924: all `[jwt-verify]` eprintln!s are gated behind - // `PERRY_DEBUG=1`. Authenticated production services call - // `jwt.verify` per request, so the previous unconditional logging - // (token length + secret length + claims/error) flooded stderr and - // also leaked the secret length, narrowing the cracking surface - // when paired with a known JWT structure. The application layer - // already logs 401s at a useful granularity. - let debug = std::env::var_os("PERRY_DEBUG").is_some(); - - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => { - if debug { - eprintln!("[jwt-verify] token_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let secret = match string_from_header(secret_ptr) { - Some(s) => s, - None => { - if debug { - eprintln!("[jwt-verify] secret_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let key = DecodingKey::from_secret(secret.as_bytes()); - verify_decode(&token, &key, Algorithm::HS256, debug) -} - -/// Coerce an EC PEM (public *or* private, SEC1 or PKCS#8) into a -/// PKCS#8 PUBLIC KEY PEM that `DecodingKey::from_ec_pem` accepts. -/// Mirrors Node's `jsonwebtoken` ergonomics: the user can pass the -/// same PEM to `sign` and `verify` without having to extract the -/// public key separately. perry#927 follow-up — without this, ES256 -/// `verify` rejected the very PEM the matching `sign` accepted, -/// breaking the shop-admin auth path even after the JSON-parse -/// return-shape fix. -fn ec_pem_to_public_pem(pem: &str) -> Option { - use p256::pkcs8::{DecodePrivateKey, EncodePublicKey}; - - if pem.contains("PUBLIC KEY") { - return Some(pem.to_string()); - } - - // Try PKCS#8 private (`-----BEGIN PRIVATE KEY-----`) first, - // then SEC1 (`-----BEGIN EC PRIVATE KEY-----`). - let secret = p256::SecretKey::from_pkcs8_pem(pem) - .or_else(|_| p256::SecretKey::from_sec1_pem(pem)) - .ok()?; - secret - .public_key() - .to_public_key_pem(Default::default()) - .ok() -} - -/// Verify and decode an ES256 JWT. -/// `pem_ptr` may contain either a PUBLIC key PEM (SPKI) or the -/// matching PRIVATE key PEM (PKCS#8 or SEC1) — the latter is -/// auto-converted via `ec_pem_to_public_pem` so callers can reuse -/// their signing key. -/// jwt.verify(token, pem, { algorithms: ['ES256'] }) -> object -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_es256( - token_ptr: *const StringHeader, - pem_ptr: *const StringHeader, -) -> *mut StringHeader { - let debug = std::env::var_os("PERRY_DEBUG").is_some(); - - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => { - if debug { - eprintln!("[jwt-verify-es256] token_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-es256] pem_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let public_pem = match ec_pem_to_public_pem(&pem) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-es256] could not derive EC public key from PEM"); - } - return std::ptr::null_mut(); - } - }; - - let key = match DecodingKey::from_ec_pem(public_pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - if debug { - eprintln!("[jwt-verify-es256] invalid EC PEM key: {}", e); - } - return std::ptr::null_mut(); - } - }; - - verify_decode(&token, &key, Algorithm::ES256, debug) -} - -/// Coerce an RSA PEM (public *or* private, PKCS#1 or PKCS#8) into a -/// PEM that `DecodingKey::from_rsa_pem` accepts. Matches Node's -/// `jsonwebtoken` behavior of accepting either side of the keypair -/// on verify. -fn rsa_pem_to_public_pem(pem: &str) -> Option { - use rsa::pkcs1::EncodeRsaPublicKey; - use rsa::pkcs8::{DecodePrivateKey, EncodePublicKey}; - - if pem.contains("PUBLIC KEY") { - // Either PKCS#1 `RSA PUBLIC KEY` or PKCS#8 `PUBLIC KEY` — - // both consumed directly by `DecodingKey::from_rsa_pem`. - return Some(pem.to_string()); - } - - // Try PKCS#8 (`-----BEGIN PRIVATE KEY-----`) then PKCS#1 - // (`-----BEGIN RSA PRIVATE KEY-----`). - let priv_key = rsa::RsaPrivateKey::from_pkcs8_pem(pem) - .or_else(|_| { - use rsa::pkcs1::DecodeRsaPrivateKey; - rsa::RsaPrivateKey::from_pkcs1_pem(pem) - }) - .ok()?; - let pub_key = priv_key.to_public_key(); - pub_key - .to_public_key_pem(Default::default()) - .ok() - .or_else(|| pub_key.to_pkcs1_pem(Default::default()).ok()) -} - -/// Verify and decode an RS256 JWT. -/// `pem_ptr` may contain either a PUBLIC key PEM (PKCS#1 or PKCS#8) -/// or the matching PRIVATE key PEM (auto-converted via -/// `rsa_pem_to_public_pem`). -/// jwt.verify(token, pem, { algorithms: ['RS256'] }) -> object -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_rs256( - token_ptr: *const StringHeader, - pem_ptr: *const StringHeader, -) -> *mut StringHeader { - let debug = std::env::var_os("PERRY_DEBUG").is_some(); - - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => { - if debug { - eprintln!("[jwt-verify-rs256] token_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let pem = match string_from_header(pem_ptr) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-rs256] pem_ptr is null or invalid"); - } - return std::ptr::null_mut(); - } - }; - - let public_pem = match rsa_pem_to_public_pem(&pem) { - Some(p) => p, - None => { - if debug { - eprintln!("[jwt-verify-rs256] could not derive RSA public key from PEM"); - } - return std::ptr::null_mut(); - } - }; - - let key = match DecodingKey::from_rsa_pem(public_pem.as_bytes()) { - Ok(k) => k, - Err(e) => { - if debug { - eprintln!("[jwt-verify-rs256] invalid RSA PEM key: {}", e); - } - return std::ptr::null_mut(); - } - }; - - verify_decode(&token, &key, Algorithm::RS256, debug) -} - -/// Dynamic-algorithm `jwt.verify` dispatcher (#1074). -/// -/// Mirrors `js_jwt_sign_dyn`. The codegen fast path resolves -/// `algorithms: ["ES256"]` to `js_jwt_verify_es256` at compile time; -/// const-ref or computed shapes fell through to `js_jwt_verify` (HS256) -/// and silently rejected ES/RS tokens. This entry point reads the -/// algorithm name from `alg_ptr` at runtime and dispatches. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_dyn( - alg_ptr: *const StringHeader, - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, -) -> *mut StringHeader { - let alg_name = string_from_header(alg_ptr).unwrap_or_else(|| "HS256".to_string()); - match alg_name.as_str() { - "ES256" => js_jwt_verify_es256(token_ptr, secret_ptr), - "RS256" => js_jwt_verify_rs256(token_ptr, secret_ptr), - "HS256" | "" => js_jwt_verify(token_ptr, secret_ptr), - other => { - if std::env::var_os("PERRY_DEBUG").is_some() { - eprintln!( - "[jwt-verify-dyn] unknown algorithm `{}`; falling back to HS256", - other - ); - } - js_jwt_verify(token_ptr, secret_ptr) - } - } -} - -/// `jwt.verify(token, secret, options)` where `options` is a non-extractable -/// expression (case C, #1074). Extract `algorithm` (singular) or the first -/// entry of `algorithms` (plural array) at runtime and defer to -/// `js_jwt_verify_dyn`. The plural-array first-entry rule mirrors the -/// compile-time fast path in `lower_jsonwebtoken_verify` — the underlying -/// `jsonwebtoken` crate verifies against one algorithm at a time, so we -/// pick the first. -#[no_mangle] -pub unsafe extern "C" fn js_jwt_verify_dyn_opts( - token_ptr: *const StringHeader, - secret_ptr: *const StringHeader, - options_value: f64, -) -> *mut StringHeader { - // Try singular `algorithm: "..."` first. - let mut alg_ptr = opts_get_string_field(options_value, "algorithm"); - // Then plural `algorithms: ["..."]`. Read the field, then index [0] - // through `js_array_get_f64` to mirror the compile-time fast path. - if alg_ptr.is_null() { - let obj_ptr = jsvalue_to_object_ptr(options_value); - if !obj_ptr.is_null() { - let key = js_string_from_bytes("algorithms".as_ptr(), "algorithms".len() as u32); - let arr_val = js_object_get_field_by_name(obj_ptr, key); - // Array is pointer-tagged in NaN-boxing; extract pointer if - // present. We reuse the existing array_get_f64 entry point - // because it's the most-tested path for array.[i] reads. - if !arr_val.is_undefined() && !arr_val.is_null() { - // The array NaN-box is POINTER_TAG-shaped just like an - // object — strip the upper bits to recover the raw - // ArrayHeader*. `js_array_get_f64` does its own tag - // strip too, but we already have an authoritative - // pointer here so just pass it through. - let arr_bits = arr_val.bits(); - let arr_ptr = - (arr_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ArrayHeader; - if !arr_ptr.is_null() { - let first_jsval = perry_runtime::js_array_get(arr_ptr, 0); - if first_jsval.is_string() { - alg_ptr = first_jsval.as_string_ptr(); - } - } - } - } - } - js_jwt_verify_dyn(alg_ptr, token_ptr, secret_ptr) -} - -/// Decode a JWT without verification (just parse the payload) -/// jwt.decode(token) -> object (payload) -#[no_mangle] -pub unsafe extern "C" fn js_jwt_decode(token_ptr: *const StringHeader) -> *mut StringHeader { - let token = match string_from_header(token_ptr) { - Some(t) => t, - None => return std::ptr::null_mut(), - }; - - // Split the token into parts - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return std::ptr::null_mut(); - } - - // Decode the payload (second part) - use base64::Engine; - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - - match engine.decode(parts[1]) { - Ok(payload_bytes) => { - match String::from_utf8(payload_bytes) { - Ok(payload_json) => { - // Validate it's valid JSON and return it - if serde_json::from_str::(&payload_json).is_ok() { - js_string_from_bytes(payload_json.as_ptr(), payload_json.len() as u32) - } else { - std::ptr::null_mut() - } - } - Err(_) => std::ptr::null_mut(), - } - } - Err(_) => std::ptr::null_mut(), - } -} - -#[cfg(all(test, unix))] -mod tests { - //! perry#924 regression tests — `jwt.verify` MUST be silent on the - //! happy path. We exercise the real `js_jwt_verify` FFI in a - //! subprocess (spawning the current test binary with a sentinel - //! env var) because cargo-test's harness installs a Rust-level - //! stderr capture that intercepts `eprintln!` before fd 2, making - //! in-process `dup2`-style capture vacuously pass. Subprocess - //! stderr is unaffected and gives us a real byte stream to count - //! lines against. - //! - //! Before the fix: - //! • valid token: 3 stderr lines (`token_len=…` + `success, claims=…`) - //! • invalid token: 2 stderr lines (`token_len=…` + `error: …`) - //! After the fix (no `PERRY_DEBUG`): - //! • valid token: 0 stderr lines - //! • invalid token: 0 stderr lines - //! With `PERRY_DEBUG=1`: original verbose output is restored. - use super::*; - use perry_runtime::js_string_from_bytes; - use std::process::{Command, Stdio}; - - /// Sentinel env var: when set, the targeted helper test runs the - /// FFI in this process (which is a subprocess of the real test) - /// and exits so the subprocess produces a clean, uncaptured - /// stderr stream for the parent test to inspect. Spawning is done - /// via `--exact …::__perry_924_helper --nocapture --quiet` so - /// only the helper test runs and harness stderr capture is off. - const HELPER_ENV: &str = "PERRY_924_HELPER"; - - /// Hidden helper test — invoked by the real tests via subprocess. - /// When `PERRY_924_HELPER` is set, exec the requested FFI scenario - /// and exit. Otherwise no-op (so a normal `cargo test` run just - /// records this as a trivially-passing test). - #[test] - fn __perry_924_helper() { - let Ok(mode) = std::env::var(HELPER_ENV) else { - return; - }; - unsafe { run_helper(&mode) }; - std::process::exit(0); - } - - unsafe fn run_helper(mode: &str) { - unsafe fn mk(s: &str) -> *mut StringHeader { - js_string_from_bytes(s.as_ptr(), s.len() as u32) - } - - match mode { - "valid" => { - // Mint a real HS256 token, then verify it. Success - // path → must not eprintln (unless PERRY_DEBUG set - // by parent). - let payload = mk(r#"{"sub":"1234","name":"Alice"}"#); - let secret = mk("supersecret"); - let token_bits = js_jwt_sign( - payload as *const _, - secret as *const _, - 0.0, - std::ptr::null(), - ); - assert_ne!(token_bits, 0); - let raw = (token_bits as u64 & POINTER_MASK) as *mut StringHeader; - let len = (*raw).byte_len as usize; - let data_ptr = (raw as *const u8).add(std::mem::size_of::()); - let token_bytes = std::slice::from_raw_parts(data_ptr, len); - let token_str = std::str::from_utf8(token_bytes).unwrap().to_string(); - - let token = mk(&token_str); - let secret2 = mk("supersecret"); - let result = js_jwt_verify(token as *const _, secret2 as *const _); - assert!(!result.is_null(), "verify must succeed on a valid token"); - } - "invalid" => { - // Garbage input → verify must fail silently (no log - // unless PERRY_DEBUG set). - let token = mk("not-a-jwt"); - let secret = mk("supersecret"); - let result = js_jwt_verify(token as *const _, secret as *const _); - assert!(result.is_null(), "verify must fail on garbage"); - } - other => panic!("unknown helper mode: {}", other), - } - } - - fn spawn_helper(mode: &str, debug: bool) -> std::process::Output { - let exe = std::env::current_exe().expect("current_exe"); - let mut cmd = Command::new(exe); - cmd.arg("--exact") - .arg("jsonwebtoken::tests::__perry_924_helper") - .arg("--nocapture") - .arg("--quiet") - .env(HELPER_ENV, mode) - .env_remove("PERRY_DEBUG") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if debug { - cmd.env("PERRY_DEBUG", "1"); - } - cmd.output().expect("spawn helper") - } - - #[test] - fn verify_valid_token_is_silent() { - let out = spawn_helper("valid", false); - assert!(out.status.success(), "helper exited non-zero: {:?}", out); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.is_empty(), - "jwt.verify on a valid token must not log to stderr (perry#924); got: {:?}", - stderr - ); - } - - #[test] - fn verify_invalid_token_is_silent() { - let out = spawn_helper("invalid", false); - assert!(out.status.success(), "helper exited non-zero: {:?}", out); - let stderr = String::from_utf8_lossy(&out.stderr); - // Application code (e.g. authMiddleware) already logs the - // 401 — stdlib must not duplicate. One line maximum if we - // ever decide a single error-class summary is worth it. - let lines = stderr.lines().count(); - assert!( - lines == 0, - "jwt.verify on invalid input must be silent (perry#924), got {} lines: {:?}", - lines, - stderr - ); - assert!( - !stderr.contains("[jwt-verify]"), - "no `[jwt-verify]` line may appear without PERRY_DEBUG; got: {:?}", - stderr - ); - } - - #[test] - fn verify_logs_under_perry_debug() { - let out = spawn_helper("valid", true); - assert!(out.status.success(), "helper exited non-zero: {:?}", out); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("[jwt-verify] success"), - "PERRY_DEBUG=1 must restore verbose logging; got: {:?}", - stderr - ); - } - - // --- GHSA-5324-c68v-8w62 / CVE-2026-53777: exp must be enforced --- - - fn now_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - } - - fn hs256_token(claims: serde_json::Value, secret: &[u8]) -> String { - encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(secret), - ) - .unwrap() - } - - #[test] - fn expired_token_is_rejected() { - let secret = b"supersecret"; - let token = hs256_token( - serde_json::json!({ "sub": "user123", "exp": now_secs() - 3600 }), - secret, - ); - let key = DecodingKey::from_secret(secret); - unsafe { - let r = verify_decode(&token, &key, Algorithm::HS256, false); - assert!(r.is_null(), "expired token must be rejected by jwt.verify"); - } - } - - #[test] - fn unexpired_token_is_accepted() { - let secret = b"supersecret"; - let token = hs256_token( - serde_json::json!({ "sub": "user123", "exp": now_secs() + 3600 }), - secret, - ); - let key = DecodingKey::from_secret(secret); - unsafe { - let r = verify_decode(&token, &key, Algorithm::HS256, false); - assert!(!r.is_null(), "valid, unexpired token must be accepted"); - } - } - - #[test] - fn token_without_exp_is_still_accepted() { - // Node's jsonwebtoken does not *require* exp; a token that omits it - // verifies. We must not regress that while enforcing exp-if-present. - let secret = b"supersecret"; - let token = hs256_token(serde_json::json!({ "sub": "user123" }), secret); - let key = DecodingKey::from_secret(secret); - unsafe { - let r = verify_decode(&token, &key, Algorithm::HS256, false); - assert!(!r.is_null(), "token without exp claim must still verify"); - } - } -} diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 1c9346d525..1a2970af77 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -340,14 +340,9 @@ pub mod argon2; #[cfg(feature = "bundled-argon2")] pub use argon2::*; -// jsonwebtoken split out into `bundled-jsonwebtoken` (v0.5.538) // for the same reason as bcrypt/argon2 — well-known flip // independence. The `crypto` umbrella still pulls it in for // backwards compat. -#[cfg(feature = "bundled-jsonwebtoken")] -pub mod jsonwebtoken; -#[cfg(feature = "bundled-jsonwebtoken")] -pub use jsonwebtoken::*; #[cfg(feature = "crypto")] pub mod crypto_e2e; diff --git a/crates/perry-ui-android/src/stdlib_stubs.rs b/crates/perry-ui-android/src/stdlib_stubs.rs index 6e061442a9..4351918c1f 100644 --- a/crates/perry-ui-android/src/stdlib_stubs.rs +++ b/crates/perry-ui-android/src/stdlib_stubs.rs @@ -917,26 +917,6 @@ pub extern "C" fn js_ioredis_setex() -> i64 { } // js_json_* — real implementations in json.rs #[no_mangle] -pub extern "C" fn js_jwt_decode() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_sign() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_sign_es256() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_sign_rs256() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_jwt_verify() -> i64 { - 0 -} -#[no_mangle] pub extern "C" fn js_lodash_camel_case() -> i64 { 0 } diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index a7f4b0561b..e45b6d39b7 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -101,7 +101,6 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // bcrypt also typically use sha256/jwt/etc., which keeps the // umbrella worthwhile. "bcrypt" => &["bundled-bcrypt"], - "jsonwebtoken" => &["bundled-jsonwebtoken"], "crypto" => &["crypto"], // ethers ships utility functions (formatUnits, parseUnits, // getAddress, keccak256, …). The keccak256 implementation is diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 59e13d4598..70b7e3b59c 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -131,18 +131,6 @@ repo = "https://github.com/ranisalt/node-argon2" ref = "786de7152f95881b0683aea1d2ca60ed0d6d9e2f" ported-at = "0.45.1" date = "2026-07-30" -[bindings.jsonwebtoken] -crate = "perry-ext-jsonwebtoken" -lib = "perry_ext_jsonwebtoken" -tracking = "#466" - -[bindings.jsonwebtoken.upstream] -version = "9.0.3" -sha256 = "d9af2628a7a4dda25acf1e19c7ecc2468e1e9e8d4619fe2cae829e89d96f6b82" -repo = "https://github.com/auth0/node-jsonwebtoken" -ref = "ed59e76ea37a80f54b833668c02a5271984dcba3" -ported-at = "9.0.3" -date = "2026-07-30" [bindings.validator] crate = "perry-ext-validator" lib = "perry_ext_validator" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 87dc9693bb..81c488c26b 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2093 entries across 136 modules +// Coverage: 2090 entries across 135 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -2096,15 +2096,6 @@ declare module "iovalkey" { export function createClient(...args: any[]): any; } -declare module "jsonwebtoken" { - /** stdlib */ - export function decode(token: string): any; - /** stdlib */ - export function sign(payload: any, secret: string, options?: any, kid?: string): string; - /** stdlib */ - export function verify(token: string, secret: string): any; -} - declare module "lodash" { /** stdlib */ export function camelCase(p0: string): string; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index afcbe5c54a..affdd14dcb 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3035 entries across 138 modules. +Total: 3032 entries across 137 modules. ## Modules @@ -64,7 +64,6 @@ Total: 3035 entries across 138 modules. - [`inspector/promises`](#inspectorpromises) - [`ioredis`](#ioredis) - [`iovalkey`](#iovalkey) -- [`jsonwebtoken`](#jsonwebtoken) - [`lodash`](#lodash) - [`lru-cache`](#lru-cache) - [`module`](#module) @@ -2012,14 +2011,6 @@ Total: 3035 entries across 138 modules. - `createClient` — module -## `jsonwebtoken` - -### Methods - -- `decode` — module -- `sign` — module -- `verify` — module - ## `lodash` ### Methods diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index 1591f27649..9813d11331 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -102,7 +102,6 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-fetch` | `node-fetch` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-http` | `http`
`http2`
`https` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-ioredis` | `ioredis`
`iovalkey`
`redis` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-jsonwebtoken` | `jsonwebtoken` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-lru-cache` | `lru-cache` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-moment` | `moment` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-mongodb` | `mongodb` | Source package | Compile the upstream package source | Bundled; migration pending | diff --git a/scripts/string_payload_access_baseline.txt b/scripts/string_payload_access_baseline.txt index 52cc4699a8..bc1ba4ef70 100644 --- a/scripts/string_payload_access_baseline.txt +++ b/scripts/string_payload_access_baseline.txt @@ -13,7 +13,7 @@ inline-offset | perry-ext-pg | 2 inline-offset | perry-ext-zlib | 3 inline-offset | perry-ffi | 3 inline-offset | perry-runtime | 350 -inline-offset | perry-stdlib | 40 +inline-offset | perry-stdlib | 39 inline-offset | perry-updater | 5 reader-helper | perry-ext-ethers | 1 reader-helper | perry-runtime | 13 diff --git a/scripts/unrooted_local_shape_baseline.json b/scripts/unrooted_local_shape_baseline.json index 40f260534c..c1786de33f 100644 --- a/scripts/unrooted_local_shape_baseline.json +++ b/scripts/unrooted_local_shape_baseline.json @@ -19,7 +19,6 @@ "crates/perry-ext-http/src/server/response.rs": 1, "crates/perry-ext-http/src/server/types.rs": 1, "crates/perry-ext-ioredis/src/lib.rs": 1, - "crates/perry-ext-jsonwebtoken/src/lib.rs": 1, "crates/perry-ext-mongodb/src/lib.rs": 2, "crates/perry-ext-mysql2/src/lib.rs": 9, "crates/perry-ext-net/src/classes.rs": 2, diff --git a/workspace-architecture.json b/workspace-architecture.json index 623d2d2711..8f9365ffc4 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 83, + "workspace_members": 82, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -68,7 +68,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 33, + "externalize": 32, "keep": 45, "merge": 1, "remove": 1, @@ -245,11 +245,6 @@ "decision": "externalize", "migration": "compile-source" }, - "perry-ext-jsonwebtoken": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-lru-cache": { "category": "binding", "decision": "externalize", From bfa81d0166e9ca2540a8f60071c54573c0dbf87d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 04:47:41 +0000 Subject: [PATCH 18/19] changelog: add fragment for #10687 (jsonwebtoken native binding removal) --- ...10687-jsonwebtoken-native-binding-removal.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 changelog.d/10687-jsonwebtoken-native-binding-removal.md diff --git a/changelog.d/10687-jsonwebtoken-native-binding-removal.md b/changelog.d/10687-jsonwebtoken-native-binding-removal.md new file mode 100644 index 0000000000..d0c6817091 --- /dev/null +++ b/changelog.d/10687-jsonwebtoken-native-binding-removal.md @@ -0,0 +1,17 @@ +Removed the native `jsonwebtoken` binding (#10683): `verify()` returned +`null` instead of throwing on every forgery case (tampered payload, wrong +secret, `alg:none`, garbage token, tampered signature, expired token), and +`sign(..., { expiresIn: "1h" })` silently dropped the expiry. `import jwt +from "jsonwebtoken"` (no `perry.compilePackages` entry) now compiles the +real npm package from source, matching Node exactly including all six +thrown error names/messages. + +Deleted both duplicate hand-written implementations (`crates/perry-ext-jsonwebtoken` +and `crates/perry-stdlib/src/jsonwebtoken.rs`, which independently exported +the same `js_jwt_*` symbols — #10678) plus the dedicated codegen lowering +path in `crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs` that +bypassed the well-known-binding registry entirely. Re-wired `dep:rsa`/ +`dep:spki` directly onto perry-stdlib's `crypto` feature, since WebCrypto's +`key_object.rs`/`keys.rs` need them unconditionally and were only reachable +through the now-deleted `bundled-jsonwebtoken` feature by historical +accident. From 5e257b4aca977d09a3fa5333301eab87acb30b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 10:47:44 +0200 Subject: [PATCH 19/19] chore: release merge train 220 as v0.5.1598 --- CLAUDE.md | 2 +- Cargo.lock | 160 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 82 insertions(+), 82 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd0386da48..ec3ed18294 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1597 +**Current Version:** 0.5.1598 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 8d196773a2..1f0159a2c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1597" +version = "0.5.1598" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "lru", "perry-ffi", @@ -6037,7 +6037,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "chrono", "perry-ffi", @@ -6045,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "bson", "futures-util", @@ -6057,7 +6057,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "chrono", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "nanoid", "perry-ffi", @@ -6078,7 +6078,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "bytes", "perry-ffi", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "lettre", "perry-ffi", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "notify", "perry-ffi", @@ -6134,7 +6134,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "printpdf", @@ -6142,7 +6142,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "sqlx", @@ -6151,7 +6151,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "perry-runtime", @@ -6160,7 +6160,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "governor", "perry-ffi", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "fast_image_resize", "image", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "lazy_static", "perry-ffi", @@ -6188,7 +6188,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-ffi", @@ -6208,7 +6208,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "perry-runtime", @@ -6217,7 +6217,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "uuid", @@ -6225,7 +6225,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-ffi", "perry-validation", @@ -6234,7 +6234,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "futures-util", "lazy_static", @@ -6247,7 +6247,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "brotli", "flate2", @@ -6257,7 +6257,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6267,7 +6267,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-api-manifest", @@ -6287,11 +6287,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1597" +version = "0.5.1598" [[package]] name = "perry-parser" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "perry-diagnostics", @@ -6304,7 +6304,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perex", "regex", @@ -6312,7 +6312,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "ahash", "base64 0.22.1", @@ -6370,14 +6370,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6465,21 +6465,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "dirs", "perry-ffi", @@ -6489,7 +6489,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "jni", @@ -6504,7 +6504,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "rand 0.10.2", "serde", @@ -6514,7 +6514,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6537,7 +6537,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "block2", @@ -6554,7 +6554,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "block2", @@ -6571,7 +6571,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1597" +version = "0.5.1598" [[package]] name = "perry-ui-test" @@ -6582,11 +6582,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1597" +version = "0.5.1598" [[package]] name = "perry-ui-tvos" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "block2", @@ -6603,7 +6603,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "block2", @@ -6620,7 +6620,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "block2", "libc", @@ -6634,7 +6634,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "libc", @@ -6653,7 +6653,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "base64 0.22.1", "libc", @@ -6666,7 +6666,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "anyhow", "base64 0.22.1", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "idna", "regex", @@ -6691,7 +6691,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1597" +version = "0.5.1598" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 081a017221..aaee929951 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -337,7 +337,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1597" +version = "0.5.1598" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"