Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1b35d6b
diag(gc): trigger/full/budgeted/charge attribution, per-minor surviva…
Sep 4, 2026
08c1565
perf(runtime): stop minting throwaway strings and per-character descr…
Sep 5, 2026
6c86ba1
refactor(descriptors): split GC root scanning into descriptor_state/g…
Sep 5, 2026
2717b71
fix(ext-http): stub perry_ffi_run_pending for unit-test links
Sep 5, 2026
377bdcf
docs(changelog): add PR 9780 fragment
Sep 5, 2026
c6881dd
fix(gc): convert census.rs to hot TLS and re-pin its window (#9740)
Sep 5, 2026
9948c30
fix(runtime): make boxed string indices virtual
Sep 5, 2026
9e96d80
chore: key string wrapper changelog to PR 9814
Sep 5, 2026
c7a00bb
fix: hand-merge #9814's virtual indices with #9794's utf16-len helper
Sep 6, 2026
d07d147
fix: stored descriptors win over synthesized string-index defaults
Sep 6, 2026
5e319d3
fix(gates): classify #9794's GC diagnostics and drop two unused imports
Sep 6, 2026
1cb8392
fix: drop the unused test-only re-export
Sep 6, 2026
55301d0
fix(gates): re-pin the census window for #9794's diagnostics
Sep 6, 2026
025976d
perf(gc): drop the shape-cache young log — it skipped 0 % and cost 35…
Sep 5, 2026
fbf8336
perf(object): pack the per-shape key index into 4-byte cells
Sep 5, 2026
3d2dd8f
perf(regex): close the backtracking cliff, allocation-free cache prob…
Sep 5, 2026
de0e4de
docs(changelog): key the three regex fragments to PR 9796
Sep 5, 2026
5c8bfb7
fix: merge #9796's Arc cache key with #9801, split regex.rs under the…
Sep 6, 2026
d1a0346
fix(compile): bound and unblock extracted Bun bundles
Sep 4, 2026
2d45a9e
feat(codegen): allow an explicit application LLVM opt level
Sep 4, 2026
fafac07
fix: resolve #9775 against the landed require path, split inprocess.rs
Sep 6, 2026
f92672d
refactor(hir): split the ui-widget lowering test under the size cap
Sep 6, 2026
f2d0631
fix: repair the regex tests.rs union that truncated a statement
Sep 6, 2026
bae4d3d
fix: update the regex cache-key test for #9796's Arc<str> key
Sep 6, 2026
53441f6
fix(gates): drop #9756's removed young-log verdict, classify NEVER_MATCH
Sep 6, 2026
f2daa43
fix(gates): move the regex raw-handle ceiling with the split, drop un…
Sep 6, 2026
1e89e83
fix(gates): convert the split module's raw-handle reads, drop the dea…
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
38 changes: 38 additions & 0 deletions benchmarks/string_receiver_boxing.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// #9810: run with Node or compile with Perry. Arguments: receiver length, calls.
// .cjs keeps the receiver-binding controls in sloppy mode on both engines.
const n = Number(process.argv[3] || "20000");
const receiver = "x".repeat(Number(process.argv[2] || "200"));
function unused(value) { return value + 1; }
function observed(value) { return this.length + value; }
function capture() { return this; }
function strict(value) { "use strict"; return value + 1; }
String.prototype.bench9810 = unused;
const funcs = { unused, observed, strict, capture };
let sum = 0;
let start = Date.now();
for (let i = 0; i < n; i++) sum += receiver.bench9810(i);
console.log("method", receiver.length, Date.now() - start, sum);
sum = 0; start = Date.now();
for (let i = 0; i < n; i++) sum += funcs.unused.call(receiver, i);
console.log("call", receiver.length, Date.now() - start, sum);
sum = 0; start = Date.now();
for (let i = 0; i < n; i++) sum += funcs.observed.call(receiver, i);
console.log("call-this", receiver.length, Date.now() - start, sum);
sum = 0; start = Date.now();
for (let i = 0; i < n; i++) sum += funcs.unused.apply(receiver, [i]);
console.log("apply", receiver.length, Date.now() - start, sum);
sum = 0; start = Date.now();
for (let i = 0; i < n; i++) sum += funcs.strict.call(receiver, i);
console.log("strict", receiver.length, Date.now() - start, sum);
sum = 0; start = Date.now();
for (let i = 0; i < n; i++) sum += Object(receiver).length;
console.log("Object", receiver.length, Date.now() - start, sum);

const first = funcs.capture.call(receiver);
const second = funcs.capture.apply(receiver, []);
if (typeof first !== "object" || first === second || first.length !== receiver.length) {
throw new Error("sloppy calls must create distinct String wrappers");
}
first.extra = 7;
if (second.extra !== undefined) throw new Error("receiver state leaked");
delete String.prototype.bench9810;
13 changes: 10 additions & 3 deletions changelog.d/9755-gc-side-table-young-logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
- **Minor collections no longer walk every runtime side table.** A copying
minor's three root-scan passes — and a budgeted minor's initial root scan
and final remark — visited every entry of the closure dynamic-prop tables,
the string-keyed descriptor tables, the shape family/slot-index maps, the
transition cache and the shape cache on every collection, to discover that
nothing in them pointed at the nursery. On the compiled claude-code TUI
the string-keyed descriptor tables, the shape family/slot-index maps and
the transition cache on every collection, to discover that nothing in them
pointed at the nursery. On the compiled claude-code TUI
that was ~35k shape families, ~120k descriptors and ~13k closure owners
per walk, 41 minors per streamed reply, all reporting `slots=0`: 34–56 ms
of scanner time per minor.
Expand All @@ -26,6 +26,13 @@
prints `[gc-young-log]` rows (logged / visited / kept / table size) per
table and cycle.

The **shape cache** was measured and deliberately left on its plain walk.
Its canonical keys arrays are allocated in the longlived arena, which
`addr_is_minor_relevant` must answer `true` for, so no entry ever leaves a
log there: on the claude-code TUI the log named 100 % of the table in every
one of 107 collections (0 % skipped) and cost **35 % more** than the walk it
replaced. The four tables above skip 75–93 %.

- **The post-minor remembered-set coverage restore is proportional to what
the dirty scan could not cover.** `restore_surviving_dirty_coverage`
(#5029) re-walked every slot of every object on the pre-cycle dirty pages
Expand Down
15 changes: 15 additions & 0 deletions changelog.d/9780-bun-tcp-landing-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
**`Bun.listen` / `Bun.connect` are documented, and `perry-ext-http`'s unit
tests link again.** #9514's TCP socket facades added both methods to the
compile-time API manifest without regenerating the derived docs, so
`docs/src/api/reference.md` and `docs/api/perry.d.ts` had been stale since
2026-09-03 and the API-docs drift check failed on every run. They now list
both entries (3046 → 3048).

Separately, `js_bun_tcp_listen` drives the shared async runtime from its
bind-poll loop via `perry_ffi::run_pending`. `perry-ext-net` stubs that
symbol only under `#[cfg(test)]`, which does not apply when it is linked as
an ordinary dependency into `perry-ext-http`'s test binary, so release-linking
that crate failed with `undefined symbol: perry_ffi_run_pending`.
`perry-ext-http`'s test shim now provides it, alongside the
`perry_ffi_spawn_async` stub that exists for the same transitive reason.
`perry-ext-ws` was checked and does not need it.
29 changes: 29 additions & 0 deletions changelog.d/9794-alloc-primitive-string-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
### Runtime

- perf(string): a one-ASCII-character string is now the canonical per-thread
header instead of a fresh 32-byte allocation. `js_string_char_at` — and
everything that funnels through it (`s[i]`, `charAt`, string spread, the
String-wrapper index installer) — used to mint one string per character read,
which on a text-measuring workload is the single largest source of garbage.
Same residency contract as the existing small-integer string table
(longlived arena, `refcount = 0`, pinned, scanned by the same root scanner —
no new scanner is registered).

- perf(runtime): `String`/`Number`/`Boolean`/`BigInt` wrapper dispatch,
`x.constructor`, `toString` resolution and the `globalThis` builtin lookup
resolve their constant property names through the intern table instead of
minting a heap string per lookup. `js_get_global_this_builtin_value` alone
allocated 133 MB during a 3300-character claude-code reply, all of it the
same handful of literals. Interned keys also make the property-read and
property-write fast paths eligible, which a freshly minted key never was.

- perf(runtime): a `String` wrapper no longer stores one property descriptor
per character. ECMA-262 §10.4.3 gives every in-range index of a String
exotic object `{ writable: false, enumerable: true, configurable: false }` —
a fact of the class and the boxed length, not per-object state — so
`get_property_attrs` answers it from the wrapper's payload. Storing it cost,
per boxed character, a Rust `String`, a hash-map entry only a full
collection could reclaim, an owner-index entry, and one program-wide
`prop_plan_epoch_bump()`. A sloppy method call on a string primitive boxes
its receiver, so the compiled claude-code TUI paid that for every rendered
line.
22 changes: 22 additions & 0 deletions changelog.d/9794-gc-churn-attribution-diag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
### Runtime

- `PERRY_GC_DIAG=1` now says WHY the collector ran, not only what it did:
`[gc-trigger]` prints every predicate input at each collection decision
(armed arena trigger vs `arena_total`, from-space vs the nursery cap,
old-gen reclaimable pressure vs baseline/band, the malloc pair, the
pending/retaining flags); `[gc-full]` names the arm behind every full
mark-sweep with a per-site count; `[gc-budgeted] start/done` reports each
incremental cycle's steps, per-phase step time and root-scan share;
`[gc-charge]` attributes mutator-assist and synchronous-full time to the
calling site (return-address chain resolved to the JS display name);
`[gc-survival]` gives, per copying minor, which root first reached each
surviving byte — shadow stack, native stack map, a named side-table
scanner, or the remembered set split by the old parent's type — with
transitive reach charged to the originating root.
- `PERRY_ALLOC_SITE_SAMPLE=<bytes>` (arena/alloc_sample.rs): byte-proportional
allocation-site sampling for the GC arena, covering the runtime allocators
and the codegen inline bump path (the mirrored inline block limit is capped
at one interval while sampling). `[alloc-site]` reports bytes by object type
and the top sites after each copying minor and at exit. Off by default; one
relaxed atomic load per allocation when off; the OFF state and the magnitude
parse are pinned in `gc/tests/env_knob_parse.rs`.
43 changes: 43 additions & 0 deletions changelog.d/9796-regex-backtracking-cliff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
### Performance

- **A capture group no longer turns a pattern into a ReDoS.**
`repeat_matcher::capture_layout` takes a pattern off the linear `regex`
engine when ECMA-262's RepeatMatcher capture semantics are observable — a
capture group directly under a quantifier, or a capture inside a negative
lookaround. That routing is a correctness requirement (the linear engine
keeps the last value of a capture nested in a quantified group; the spec
clears it on every iteration), but the engine it routes to, `regress`, is a
classical backtracker with no step budget. So adding parentheses was enough
to fall off a linear-time path onto an exponential one:

| pattern | node | perry (before) | perry (after) |
|---|---|---|---|
| `/^(a+)+$/.test("a"×28 + "!")` | 4,798 ms | **16,522 ms** | **0 ms** |
| `/^(?:a+)+$/.test(…)` (same language, no capture) | 4,288 ms | 0 ms | 0 ms |

**6.3 %** of the 4,463 distinct regex literals across seven real bundles
take that route — claude-code 7.1 %, dayjs 25 %, luxon 29 % — including
shapes like `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`.

The two engines accept exactly the same LANGUAGE for a pattern they both
compile; they disagree only about which capture assignment to report. So the
linear program is asked first (`linear_rules_out_match`), and when it proves
there is no match at or after the search offset — which is what every ReDoS
input is, a subject that ALMOST matches and then fails — the backtracker is
never entered. Every `&str`-subject entry point goes through
`lookup_repeat_matcher_for`: `test`, `exec`, `match`, `matchAll`, `search`,
`split` and `replace` with a string replacement. The gate disables itself
where the linear engine has no opinion (a pattern it could not compile holds
the never-match placeholder), which is exactly the lookaround shapes.

**This removes the reachable exponential case; it does not BOUND the worst
case.** A real step budget has to be counted by the backtracker, and
`regress` has none today (`fancy-regex`, by contrast, ships
`backtrack_limit: 1_000_000`). A 101-line patch adding one has been measured
— worst hostile search 51 s → 124 ms at a budget of 1,000,000, zero answers
changed across 13,389 real searches, upstream's own 544 tests unchanged — and
is open upstream as
[ridiculousfish/regress#177](https://github.com/ridiculousfish/regress/pull/177).
Until it lands and perry picks it up, do not read "cliff fixed" as "worst
case bounded".
(`quantified_capture_pattern_does_not_backtrack_on_a_non_matching_subject`)
26 changes: 26 additions & 0 deletions changelog.d/9796-regex-borrowed-cache-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
### Performance

- **Probing the compiled-program caches no longer materialises the key.** The
three thread-local caches were `HashMap<(String, String), _>`, and
`HashMap::get` needs a `&(String, String)` — so **every probe allocated two
Strings and copied the pattern text into them**, on a path that runs once per
RegExp OBJECT, and a JS regex literal evaluates to a fresh object every time
it is reached. A native-churn census of the claude-code binary (2026-09-05)
put `js_regexp_test` → `lookup_repeat_matcher` → `build_and_install_programs`
at **6,044 MB of 8,334 MB of estimated allocation with zero live bytes** —
73 % of all remaining native churn — split across the three probe sites: the
`get_or_compile_regex` probe (2,071 MB) and two `core::fmt::Formatter::pad`
frames (1,989 MB and 1,984 MB), which is what `.to_string()` on an `Arc<str>`
lowers to.

The caches are now keyed by `ProgramKey = (Arc<str>, Arc<str>)`. Every caller
that matters already holds those `Arc`s — `REGEX_SOURCE_TABLE` and
`regex::site_cache` share one allocation of a literal's text with every
header built from it — so a probe is two refcount increments and no
allocation at all. The two remaining `Arc::from` materialisations are on cold
paths: the syntax-error fallback in `js_regexp_new` (a pattern the linear
engine's parser refused, 7.7 % of real literals, once each) and
`RegExp.prototype.compile` (once per call from user code).

Hashing still walks the pattern bytes; the allocation is what the census
measured and what this removes.
31 changes: 31 additions & 0 deletions changelog.d/9796-regex-engine-prototype-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
### Internal

- **`PERRY_REGEX_ENGINE=regress` — a measurable tier-0 engine prototype.**
Routes every pattern through `regress` (the ECMAScript backtracker perry
already links for RepeatMatcher capture semantics) instead of only the ones
whose capture semantics require it, and installs a shared never-match
placeholder as the standard program so no NFA is built. Every exec-family
entry point already consults the repeat matcher first, so this exercises the
whole engine surface — `exec`, `test`, `match`, `matchAll`, `search`,
`split`, `replace` — without a second implementation.

It exists so the engine question is settled on measurements from a real
binary rather than on a corpus harness. Measured over 4,463 distinct regex
literals extracted from seven real bundles (two claude-code builds, ethers,
moment, dayjs, luxon, mongodb) with a tracking allocator and the programs
held live:

| engine | accepted | compile µs (med) | bytes/program (med) | corpus total |
|---|---|---|---|---|
| `regex` crate (tier 1 today) | 92.3 % | 48.5 | 12,492 | 136.7 MB |
| `regress` | **100 %** | **2.2** | **512** | **4.9 MB** |
| `fancy-regex` (tier 2 today) | 97.8 % | 59.2 | 12,623 | 146.6 MB |

node/V8, measured the same session, is ~2,600 bytes per program. A
differential over 4,119 patterns × 13 subjects (53,547 comparisons of match
presence, span and every capture span) found **0 disagreements** between the
linear engine and `regress`.

**Not a supported configuration**: the backtracker has no step budget, so a
pathological pattern can run unbounded. Off by default, one relaxed atomic
load when unset.
1 change: 1 addition & 0 deletions changelog.d/9814-virtual-string-indices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix string-wrapper construction scaling with receiver length in non-strict method calls, `Function.prototype.call`/`apply`, and `Object(string)`. Character indices are now virtual properties, preserving UTF-16 indexing, reflection, and readonly descriptors without allocating a property and descriptor for every character (#9810).
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/codegen/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ pub(crate) fn inline_hot_small_max_call_sites() -> u32 {
/// (every function stays on native statepoints, the pre-#8583 behavior).
const DEFAULT_ROOT_SPILL_RELOCATIONS: usize = 32_000_000;

fn root_spill_relocation_threshold() -> usize {
pub(crate) fn root_spill_relocation_threshold() -> usize {
std::env::var("PERRY_ROOT_SPILL_RELOCATIONS")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
Expand Down
12 changes: 12 additions & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,18 @@ pub fn short_spread_method_capabilities(hir: &HirModule) -> Vec<ShortSpreadMetho
out
}

/// Return the exact LLVM symbol used for a top-level HIR function.
///
/// The module prefix and function component deliberately use different
/// manglers: module names keep the stable public ABI, while function names
/// use the injective member mangler so `$a` and `_a` cannot collide. Compile
/// orchestration that prepares cross-module metadata must call this helper
/// instead of reconstructing the symbol with the module-name sanitizer.
pub fn user_function_symbol(module_name: &str, function_name: &str) -> String {
let module_prefix = sanitize(module_name);
helpers::scoped_fn_name(&module_prefix, function_name)
}

/// Compile a Perry HIR module to an object file via LLVM IR.
///
/// CRITICAL (#686): `hir` MUST be `&HirModule` (shared reference), never
Expand Down
9 changes: 8 additions & 1 deletion crates/perry-codegen/src/expr/dyn_extern_i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
is_eval: _,
} => {
let _ = lower_expr(ctx, filename)?;
if ctx.block().is_terminated() {
return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
}
let options_val = if let Some(options) = options {
lower_expr(ctx, options)?
let value = lower_expr(ctx, options)?;
if ctx.block().is_terminated() {
return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
}
value
} else {
double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
};
Expand Down
Loading
Loading