Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
46a3c64
diag(regex): the site cache's byte-compare volume, and a diag file th…
Sep 6, 2026
6953d05
perf(regex): construction skips the barrier's parent classification
Sep 6, 2026
b2777c4
perf(regex): identify a literal by its source site, not by its patter…
Sep 6, 2026
7ef0650
perf(regex): hold the site table's programs weakly, and stop the adde…
Sep 6, 2026
93c2e64
docs(codegen): state the site-slot publish obligation now that #9890 …
Sep 6, 2026
9e84f33
perf(intl): answer the view mode's canonicality proof in loads, not a…
Sep 6, 2026
b2011ee
perf(intl): validate the view cursor's input once, not on every entry
Sep 6, 2026
46eae8a
fix(intl): scan the recorded RegExp.prototype site as GC roots
Sep 6, 2026
219427a
fix(hir): scope native assignment instances
Sep 6, 2026
aab391e
fix(runtime): link cluster default prototype
Sep 6, 2026
b5dbdfe
fix(stream): wait for both finished sides
Sep 6, 2026
64ea0a5
fix(sqlite): keep statement iterators exhausted
Sep 6, 2026
1c00758
fix(compile): exclude PERRY_SEGVIEW from the build cache
Sep 6, 2026
b5f0fc6
fix(codegen): clear the segment-view cursor at loop exit
Sep 6, 2026
cdc40c5
fix(tls): preserve dynamic peer certificates
Sep 6, 2026
a2956d7
fix(runtime): mock declared prototype methods
Sep 6, 2026
f221009
fix(test): render reporter directives
Sep 6, 2026
91d8a26
fix(train): split regex.rs and its tests for the file cap; classify t…
Sep 6, 2026
e046bca
fix(train): retarget the RegExp birth census, audit the prototype-ptr…
Sep 6, 2026
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
43 changes: 43 additions & 0 deletions changelog.d/9885-regex-newborn-barrier-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
**A `RegExp` literal's construction no longer pays the write barrier's parent
classification.** Since #9845 the `RegExpHeader` is a nursery allocation, so
its two string field stores — `pattern_ptr` and `flags_ptr` — cannot owe the
remembered set anything; they were still taking the full barrier and
discovering that fact, twice, at a cost of four page-map classifications, two
dirty-page-cache probes and two child classifications per construction, all
ending at `ParentNotOldSkips`.

The fix is the runtime twin of a gate the compiler already emits in front of
every one of its own stores (`emit_parent_may_need_remembering_check`, #7511):
`GC_FLAG_TENURED` clear on the parent's live header **and** a globally idle
incremental mark barrier ⇒ neither the remembered set nor the SATB shading has
anything to record. Both clauses are read live, so a header a collection
promoted between `arena_alloc_gc` and the store, or a
`RegExp.prototype.compile` reassigning a tenured receiver, still takes the
full path.

Why the two clauses and not one: the tenured bit answers the generational
question, and the incremental count is what makes it legal to skip the
insertion shading as well — dropping either is a live child swept, which is
what `gc::tests::inline_generation_gate_contract` already pins for the emitted
gate and now pins for the runtime twin, clause by clause, against the same
codegen predicate. A third test asserts on the header `js_regexp_new` actually
returns, so the skip arm is proven reached rather than merely available.

Measured motivation (segment-loop probe, region B, 60,000 reps, `sample`, main
thread, leaf sum = thread header exactly): the probe constructs one `RegExp`
per grapheme from a literal inside a function body, and the barrier subtree
under `js_regexp_new` is 739 of 14,628 main-thread samples — 32 % of that
function's own subtree.

`PERRY_REGEX_NEWBORN_BARRIER_GATE=0` restores the unconditional pair; with the
gate off nothing else changes, so the OFF arm is the pre-change code path
exactly rather than a handicapped control.

`PERRY_REGEX_DIAG` gains the counters that make the claim checkable rather
than argued: `barrier_taken` / `barrier_gated` (whose sum must equal `new`),
`header_bytes`, `site_verify_bytes` (the site cache's byte-compare volume,
which `pattern_bytes` does not isolate) and `side_table_inserts`. Two
reliability fixes ride along: a diag file the process cannot write now says so
on stderr and falls back there instead of vanishing silently, and the first
snapshot is written at the first tick rather than after a full second, so a
short run can no longer look like a dead instrument.
55 changes: 55 additions & 0 deletions changelog.d/9886-regex-literal-site-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
**A regex literal is now identified by its SOURCE SITE, not by its text**, so
constructing one costs a single word compare instead of a content fingerprint
plus a full byte compare of the pattern.

A regex literal evaluates to a fresh object every time it is reached
(ECMA-262), and TUI code reaches them inside hot functions: `string-width`'s
`emojiRegex()` returns a fresh ~12,807-character `/…/g` on every call, once per
grapheme in claude-code's layout pass. The runtime therefore re-derived "which
pattern is this?" from the text on every construction — `regex::site_cache`
keys on a cheap fingerprint and, because a fingerprint can collide, verifies
every hit with `&*entry.pattern == pattern`. That verify is linear in the
pattern: `PERRY_REGEX_DIAG` measured **2.0 GB of `memcmp` per 400-character
reply**, and a `sample` of the segment loop put `_platform_memcmp` at **39.6 %
of `js_regexp_new`'s own subtree**.

The compiler knew the answer all along; the lowering just had no way to say it.
`Expr::RegExp` now emits an 8-byte private global per literal site and passes
its **address** as a third argument to a new `js_regexp_new_site(pattern,
flags, site_key)`. That address is unique by construction, immortal, and never
moves — which is exactly what a `StringHeader` address is not, and why the
earlier analysis of this problem concluded no sound string identity existed and
left the byte compare in place: string headers are GC-managed, so an address is
freed and reused, and a moving collector relocates them.

A hit verifies with one word plus the site's ≤ 8-byte flags text (two spellings
of one canonical form must not answer for each other) and then reads nothing
about the pattern at all: no fingerprint, no `memcmp`, no validation — validity
is a pure function of `(pattern, flags)` and the site's first construction
established it — and no flag canonicalization, since the seven flag bits are a
property of the site. Once the site's first header has executed, later
constructions are born built.

`site_key = 0` means "no site" and behaves exactly as before, so every dynamic
construction (`new RegExp(s)`, `js_regexp_construct`,
`RegExp.prototype.compile`, the runtime's own callers) keeps the two-argument
entry point and never touches the site table — pinned by a test that asserts
the table is still empty after four dynamic constructions, and non-empty after
one site-keyed one, so the zero is a property of the entry point rather than of
a table that never works.

The named sabotage is a table keyed by anything weaker than the site address:
two literals at two sites, same flags, **same pattern length**, different text.
Under a length- or prefix-keyed table the second site inherits the first's
entry, `.source` reports a pattern the literal never contained and `test`
matches the wrong language. Each site is constructed twice, because a first
construction always misses and would pass under every sabotage.

Kill switch: `PERRY_REGEX_SITE_KEY=0` — the probe misses and nothing is
recorded, so the OFF arm is the content-keyed path exactly rather than a
control still paying the bookkeeping.

The new runtime symbol is declared in `runtime_decls/strings.rs` with a test
asserting its **name and arity**: a missing `declare` is invisible to every
HIR-level test and fails only at the in-process LLVM parse (`use of undefined
value`), and a wrong arity parses and miscompiles.
8 changes: 8 additions & 0 deletions changelog.d/9904-native-instance-assignment-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
### Fixed

- Native instances assigned with `target = new NativeClass(...)` or propagated
with `target = source` are now tracked by the resolved binding rather than by
identifier text across the whole module. A native handle named `O` can no
longer make unrelated bindings named `O` dispatch ordinary methods through
that native class, while module-level handles and unresolved global fallbacks
retain their existing cross-function behavior.
5 changes: 5 additions & 0 deletions changelog.d/9905-cluster-default-prototype.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed

- The `node:cluster` default export now inherits from the canonical
`EventEmitter.prototype`, so reflective prototype checks agree with Node while
preserving the cached singleton used by cluster event methods.
5 changes: 5 additions & 0 deletions changelog.d/9906-stream-finished-duplex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed

- Callback-form `stream.finished()` now waits for both sides of a duplex stream,
so ending an unread `PassThrough` does not report completion before its
readable side emits `end`.
1 change: 1 addition & 0 deletions changelog.d/9909-sqlite-iterate-exhaustion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix `DatabaseSync` statement iterators so they remain exhausted after a `for...of` loop. A later `.next()` on the same iterator now returns `{ done: true, value: null }` instead of restarting from the first row.
1 change: 1 addition & 0 deletions changelog.d/9911-tls-peer-certificate-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix dynamic `TLSSocket.getPeerCertificate()` calls from the native net extension so they return the full negotiated certificate. Certificate inspection now preserves the peer identity across server secure-context rotation.
1 change: 1 addition & 0 deletions changelog.d/9913-test-mock-prototype.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`node:test`'s `mock.method()` now replaces declared class prototype methods for instance dispatch and restores their original behavior, while recording calls and receiver identity like Node.
2 changes: 2 additions & 0 deletions changelog.d/9914-test-reporter-directives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Render `skip` and `todo` directives in the `node:test` spec and TAP reporters,
including Node-compatible markers and optional directive reasons.
21 changes: 20 additions & 1 deletion crates/perry-codegen/src/collectors/segview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,26 @@ fn rewrite_site(list: &mut Vec<Stmt>, i: usize, site: &SegmentForOfSite, fresh:
pick(cur, Expr::Undefined, decline_iter),
),
);
3
// Clear the cursor at loop exit. The cursor local is declared in the
// ENCLOSING statement list, not inside the loop, so without this its slot
// stays a live GC root until the function returns. A cursor that spans a
// minor while the loop runs is promoted, and because it holds the input
// string in a traced slot it drags that string into the old generation
// with it — one per `open`, and `string-width` is entered thousands of
// times per reply. That is a candidate mechanism for I4 settling 45-65 MB
// ABOVE I3 after idle despite winning 20-50 MB of peak.
//
// One unconditional clear covers both paths: on the declined path the
// local holds `0.0`, a number, so clearing it is a no-op. `break` reaches
// this statement; `return` inside the body pops the frame, which is
// equally fine. It does not prevent promotion DURING the loop — nothing
// in the compiler can, since the cursor is genuinely live there — it stops
// the slot from keeping a dead cursor rooted for the rest of the function.
list.insert(
i + 5,
Stmt::Expr(Expr::LocalSet(cur, Box::new(Expr::Undefined))),
);
4
}

fn unwrap_for_mut(s: &mut Stmt) -> &mut Stmt {
Expand Down
43 changes: 43 additions & 0 deletions crates/perry-codegen/src/collectors/segview_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,3 +509,46 @@ fn a_site_with_an_unanswerable_use_stays_on_v1() {
"v1 does not rewrite uses: {out}"
);
}

/// The cursor local is declared in the enclosing statement list, so its slot is
/// a live GC root until the function returns unless the lowering clears it. A
/// cursor promoted during the loop holds the input string in a traced slot and
/// drags it into the old generation; leaving the slot rooted afterwards keeps a
/// DEAD cursor doing that for the rest of the function.
#[test]
fn the_cursor_is_cleared_at_loop_exit() {
let mut m = module_with(region(cc_body()));
assert_eq!(segview_rewrite_module(&mut m), 1);

// Structural, not string-matched: the statement AFTER the `For` must be a
// `LocalSet(<cursor>, Undefined)`, and the cursor is the local the `For`'s
// condition tests against zero.
let for_idx = m
.init
.iter()
.position(|s| matches!(s, Stmt::For { .. }))
.expect("the rewritten loop");
let cursor_id = match &m.init[for_idx] {
Stmt::For {
condition: Some(Expr::Conditional { condition, .. }),
..
} => match condition.as_ref() {
Expr::Compare { left, .. } => match left.as_ref() {
Expr::LocalGet(id) => *id,
other => panic!("expected the cursor guard, got {other:?}"),
},
other => panic!("expected a compare, got {other:?}"),
},
_ => unreachable!(),
};
match m.init.get(for_idx + 1) {
Some(Stmt::Expr(Expr::LocalSet(id, v))) => {
assert_eq!(*id, cursor_id, "the cleared local must be the cursor");
assert!(
matches!(v.as_ref(), Expr::Undefined),
"the cursor slot must be cleared to undefined, got {v:?}"
);
}
other => panic!("no cursor clear after the loop: {other:?}"),
}
}
66 changes: 64 additions & 2 deletions crates/perry-codegen/src/expr/logical_collections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1283,15 +1283,77 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let flags_idx = ctx.strings.intern(flags);
let pattern_global = format!("@{}", ctx.strings.entry(pattern_idx).handle_global);
let flags_global = format!("@{}", ctx.strings.entry(flags_idx).handle_global);
// ★ A literal's SITE IDENTITY, as an immortal address.
//
// A regex literal evaluates to a fresh object every time it is
// reached (ECMA-262), and TUI code reaches them inside hot
// functions — `string-width`'s `emojiRegex()` returns a fresh
// ~12,807-character `/…/g` per call. The runtime therefore
// re-derives "which pattern is this?" per construction from the
// TEXT: a content fingerprint plus, on every hit, a full byte
// compare to verify it (`regex::site_cache::entry_matches`). On
// claude-code that verify is ~2.0 GB of `memcmp` per 400-character
// reply, and it is 39.6 % of `js_regexp_new`'s own profile subtree.
//
// The compiler knows the answer statically: this literal is one
// source site whose pattern and flags can never change. What the
// runtime was missing is the key, because the lowering passed only
// the two string handles. This emits an 8-byte private global per
// literal site and passes its ADDRESS — unique by construction
// (distinct globals have distinct addresses), immortal (it is not
// GC memory, so it can never be freed and reused under a stale
// cache entry, which is why the string handles themselves cannot
// serve), and stable for the process. The runtime's site table
// then verifies a hit by comparing that one word, and never looks
// at the pattern at all.
//
// The slot is zero-initialised so it lands in `__bss` and costs
// nothing until the linker lays it out (#9610's lesson about
// zero-initialised globals applies: `private global i64 0`, not a
// non-zero initialiser). Naming carries the module prefix for the
// same reason `inline_cache_global_name` does — codegen-unit
// splitting can promote a private global for cross-unit use.
//
// The slot must reach the module, so every lowering exit has to
// PUBLISH `typed_parse_rodata` rather than drop it. That was not
// true when this landed: `codegen/method.rs`'s "parent class has
// no callable constructor symbol" bail-out lowered the body and
// then discarded the three artifact collections, so a regex
// literal inside such a constructor would have referenced a
// global that is never defined (#9890, fixed by #9896 — every
// return now goes through `publish_lowered_fn_artifacts`, which
// also restores `llmod.ic_counter` and so closes the duplicate
// site-id half). Kept as a note because the obligation is real
// and unenforced: a future early return that drops the artifacts
// breaks this site, loudly, at the in-process LLVM parse (`use of
// undefined value`) rather than at runtime.
let site_id = ctx.ic_site_counter;
ctx.ic_site_counter += 1;
let slot_name = {
let prefix = ctx.strings.module_prefix();
if prefix.is_empty() {
format!("perry_regexp_site_{site_id}")
} else {
format!("perry_regexp_site_{prefix}__{site_id}")
}
};
ctx.typed_parse_rodata
.push(format!("@{slot_name} = private global i64 0"));
let slot_ref = format!("@{slot_name}");
let blk = ctx.block();
let pattern_box = blk.load(DOUBLE, &pattern_global);
let flags_box = blk.load(DOUBLE, &flags_global);
let pattern_handle = unbox_to_i64(blk, &pattern_box);
let flags_handle = unbox_to_i64(blk, &flags_box);
let site_key = blk.ptrtoint(&slot_ref, I64);
let result = blk.call(
I64,
"js_regexp_new",
&[(I64, &pattern_handle), (I64, &flags_handle)],
"js_regexp_new_site",
&[
(I64, &pattern_handle),
(I64, &flags_handle),
(I64, &site_key),
],
);
Ok(nanbox_pointer_inline(blk, &result))
}
Expand Down
53 changes: 53 additions & 0 deletions crates/perry-codegen/src/runtime_decls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,56 @@ pub fn declare_phase_a_strings(module: &mut LlModule) {
// function once they grow.
declare_phase_b_strings(module);
}

#[cfg(test)]
mod tests {
use super::*;

/// A lowering that introduces a new runtime call needs one test that
/// reaches the DECLARATION, not just the HIR.
///
/// #9859 emitted five `js_segments_view_*` calls whose symbols were never
/// declared in the LLVM module: twelve HIR-level unit tests passed and the
/// first real compile died at the in-process LLVM parse with `use of
/// undefined value`. The arity half matters just as much and fails more
/// quietly — a wrong arity PARSES and miscompiles, handing the runtime a
/// garbage argument.
///
/// `Expr::RegExp` lowers to `js_regexp_new_site(pattern, flags, site_key)`
/// (`expr/logical_collections.rs`), so the declaration must be exactly
/// three `i64` parameters returning `i64`.
#[test]
fn the_literal_site_regexp_entry_is_declared_with_its_exact_arity() {
let mut module = crate::module::LlModule::new("arm64-apple-macosx");
declare_phase_b_strings(&mut module);

let line = module
.declaration_lines()
.find(|(name, _)| *name == "js_regexp_new_site")
.map(|(_, line)| line.to_string())
.expect(
"`Expr::RegExp` emits a call to `js_regexp_new_site`; without a `declare` the \
module fails the in-process LLVM parse with `use of undefined value`, which no \
HIR-level test can see",
);
assert!(
line.starts_with("declare i64 @js_regexp_new_site(i64, i64, i64)"),
"the site-keyed entry takes (pattern handle, flags handle, site key) and returns a \
RegExpHeader handle — a wrong arity parses and miscompiles instead of failing. Got: \
{line}"
);

// The two-argument form stays, because every non-literal construction
// (`new RegExp(str)`, `js_regexp_construct`, the runtime's own
// callers) uses it and must never reach the site table.
let plain = module
.declaration_lines()
.find(|(name, _)| *name == "js_regexp_new")
.map(|(_, line)| line.to_string())
.expect("the dynamic form must remain declared");
assert!(
plain.starts_with("declare i64 @js_regexp_new(i64, i64)"),
"got: {plain}"
);
}
}
7 changes: 7 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
&[DOUBLE, DOUBLE, DOUBLE, I32, DOUBLE],
);
module.declare_function("js_regexp_new", I64, &[I64, I64]);
// The literal-site form (`Expr::RegExp` lowering). A missing `declare`
// here is invisible to every HIR-level test and fails only at the
// in-process LLVM parse with `use of undefined value` — which is exactly
// how #9859's five segment-view externs were caught, after twelve passing
// unit tests. `runtime_decls::tests` asserts the name AND the arity: a
// wrong arity parses and miscompiles.
module.declare_function("js_regexp_new_site", I64, &[I64, I64, I64]);
// Full ECMAScript RegExp constructor: NaN-boxed pattern + flags in, handles
// RegExp/undefined/object patterns and ToString-coerced flags.
module.declare_function("js_regexp_construct", I64, &[DOUBLE, DOUBLE]);
Expand Down
Loading
Loading