From 2d85317d1216e37f23c326bb8bd8cec01efe4142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 9 Jun 2026 23:13:53 +0200 Subject: [PATCH] fix(codegen): String.match no-match returns null, not a boxed null pointer (#4858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expr::StringMatch codegen NaN-boxed js_string_match's result unconditionally. On no-match the runtime returns null (0), and POINTER_TAG|0 is neither `null` nor a valid heap pointer: `"abc".match(/x/g) === null` evaluated to false and consumers that dereference the result (JSON.stringify's tree walk, .map) segfaulted. This was the root cause of #4841's Stripe failure — extractUrlParams runs path.match(/\{\w+\}/g) on every request path, and any path without {params} crashed the request. Fix: branchless null -> TAG_NULL select on the call result, exactly mirroring the Expr::RegExpExec arm directly above (which documents this same bug class). The js_string_match_value path (lower_string_method.rs) already had the select; the static Expr::StringMatch fast path was the only one missing it. The non-global no-match case hit the identical codegen path and also crashed under JSON.stringify (the issue's "non-global works" note only held for console.log, which guards small pointers). Verified: issue repro + a broad match-semantics sweep (global/ non-global, hit/no-match, capture groups, named groups, .index, regex-in-local) byte-match node --experimental-strip-types. New end-to-end regression test compiles and runs the repro via CARGO_BIN_EXE_perry. --- .../perry-codegen/src/expr/instance_misc1.rs | 13 +++- .../tests/issue_4858_global_match_no_match.rs | 64 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 crates/perry/tests/issue_4858_global_match_no_match.rs diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 9377bd7256..e7e3f051df 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -1290,7 +1290,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_string_match", &[(I64, &s_handle), (I64, &r_handle)], ); - Ok(nanbox_pointer_inline(blk, &result)) + // #4858: js_string_match returns null (0) on no-match. NaN-boxing + // 0 with POINTER_TAG yields a value that is neither `null` nor a + // valid heap pointer — `s.match(/x/g) === null` was false and + // consumers that deref the result (JSON.stringify, .map) crashed. + // Branchless null → TAG_NULL select, same as RegExpExec above. + let is_null = blk.icmp_eq(I64, &result, "0"); + let ptr_boxed = nanbox_pointer_inline(ctx.block(), &result); + let ptr_bits = ctx.block().bitcast_double_to_i64(&ptr_boxed); + let selected = + ctx.block() + .select(I1, &is_null, I64, crate::nanbox::TAG_NULL_I64, &ptr_bits); + Ok(ctx.block().bitcast_i64_to_double(&selected)) } // -------- string.matchAll(pattern) -------- diff --git a/crates/perry/tests/issue_4858_global_match_no_match.rs b/crates/perry/tests/issue_4858_global_match_no_match.rs new file mode 100644 index 0000000000..f8c6b9f5bf --- /dev/null +++ b/crates/perry/tests/issue_4858_global_match_no_match.rs @@ -0,0 +1,64 @@ +//! Regression test for #4858: `String.prototype.match` with a global (`/g`) +//! regex that finds no match must return `null` — not a POINTER_TAG-boxed +//! null pointer that compares unequal to `null` and segfaults consumers +//! (`JSON.stringify`, `.map`). This was the root cause of the Stripe SDK +//! failure in #4841 (`extractUrlParams` runs `path.match(/\{\w+\}/g)` on +//! every request path). + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn global_match_no_match_returns_null() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + + std::fs::write( + &entry, + r#" +const a = "abc".match(/x/g); +console.log(a === null, JSON.stringify(a)); +const b = "abc".match(/x/); +console.log(b === null, JSON.stringify(b)); +const stripe = "/v1/products".match(/\{\w+\}/g); +console.log(stripe === null); +const hit = "a1b2c3".match(/\d/g); +console.log(JSON.stringify(hit)); +"#, + ) + .expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .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).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed (signal/segfault = #4858 regression)\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + let stdout = String::from_utf8_lossy(&run.stdout); + assert_eq!( + stdout, "true null\ntrue null\ntrue\n[\"1\",\"2\",\"3\"]\n", + "global/non-global no-match must be null; matches must survive" + ); +}