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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion crates/perry-codegen/src/expr/instance_misc1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1290,7 +1290,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
"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) --------
Expand Down
64 changes: 64 additions & 0 deletions crates/perry/tests/issue_4858_global_match_no_match.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}