Skip to content

perf(codegen,runtime): let a declared string pick the concat lowering - #7835

Merged
proggeramlug merged 4 commits into
mainfrom
perf/declared-string-concat
Aug 11, 2026
Merged

perf(codegen,runtime): let a declared string pick the concat lowering#7835
proggeramlug merged 4 commits into
mainfrom
perf/declared-string-concat

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

The finding

gc-handoff/apps/shapes.ts's describe() is "shape:" + this.tag — a string literal plus a field declared string. It lowered to js_dynamic_string_or_number_add: a RuntimeHandleScope, four root_nanbox_f64s and two ToPrimitive calls, spent rediscovering at runtime what the declaration already stated.

is_string_expr has trusted that same declaration for string method dispatch since #655. The concat path did not.

88.8 ns per concatenation → 29.4 ns. (gc-handoff/bench/concat_field.ts minus concat_field_base.ts, 2,000,000 concatenations, quiet M1 mini, best-of-7.)

Why widening the predicate is safe, and why it is scoped so tightly

Perry does not enforce annotations at runtime — the exact gap #7831 is closing on the numeric side, where a declared-number field holding a string made fadd propagate the string's NaN payload and typeof (v * 2) answer "string". A declaration is evidence, not proof.

So this PR does not widen is_definitely_string_expr. It adds a separate is_declared_string_expr with exactly one consumer: the two-operand a + b concat, which emits js_string_concat_box.

And it first fixes that helper. js_string_concat_box decoded operands with str_bytes_from_jsvalue(...).unwrap_or((null, 0)) — an operand that was not a string became the empty string, so "ab" + 42 through this helper rendered as "ab". This is reachable on 1ee158d27 today — tracked as #7837. is_definitely_string_expr's LocalGet arm already trusts a declared type, so a string-declared local holding a non-string selects this helper and the operand disappears:

const t: string = (99 as any);
console.log(t + "x");   // node: "99x"   perry before: "x"

Measured on a clean 1ee158d27 build and on this branch. A string-declared field (o.t + 7) and a (string, number) parameter pair both route elsewhere and were already correct on both builds, which is why the defect survives casual probing — three negative probes of the wrong shapes do not establish absence.

It is also why the concat fast path could never be selected from an annotation in the first place: lower_string_concat.rs's self-append lowering carries a whole dother/cold arm whose comment says a lie has to be routed around this helper. It now forwards any non-string pair to js_dynamic_string_or_number_add.

Scope against #7837 — this PR fixes one of the two defects

#7837 records two silent wrong answers from the same premise (an erased TS annotation treated as a runtime proof — the string mirror of #7831). Do not read this PR as closing both.

# shape node before this PR
2 t + "x", operand dropped 99x x 99x — fixed here
1 s + 7, wrong operator chosen 49 427 427not fixed here

Defect 1 lives in the one-sided l ^ r arm, which lowers through js_string_concat_value / js_value_concat_string. Those take an already-unboxed StringHeader* and cannot detect the lie, so fixing it needs the guarded-diamond treatment #7831 is applying to the numeric side, not this PR's runtime delegation. That arm is deliberately left on the strict predicate here and is untouched; the decision belongs to #7837.

Worth noting for that decision: scoping is_declared_string_expr to the two-operand path is what keeps this PR from widening defect 1. Had the declaration been accepted in the one-sided arm, o.t + 7 — correct at 14 on both builds today — would have become "77".

After that, js_string_concat_box returns the dynamic-path answer for every combination of runtime values — string+string, string+number, and number+number (which adds and returns a number). The declaration therefore selects a lowering and can never select an answer.

Three neighbours deliberately keep the strict predicate, each because it would be able to change an answer:

  • the one-sided l ^ r arm lowers through js_string_concat_value / js_value_concat_string, which take an already-unboxed StringHeader* and cannot tell a lie from a string;
  • the N-way chain fold formats every part as a string, so an all-declared chain of numbers would concatenate where the spec adds;
  • the Map string-key fast paths in expr::math_simple key a lookup on the claim.

type X = { … } was worth less than interface X { … }

lower_type_alias_decl files aliases in module.type_aliases; lower_interface_decl files interfaces in module.interfaces. static_type_of only ever consulted the latter. So type Record = { kind: string; amount: number } proved nothing about r.kind — not because an alias is weaker evidence than an interface (structurally they are the same declaration, the same runtime layout, the same absence of any layout guarantee), but because of which cabinet it was filed in. Only a non-generic alias whose right-hand side is a closed object type answers.

This is what lets pipeline.ts's makeTagger (prefix + r.kind, 360,000 calls) reach the concat at all.

A static counter re-interned its own key, once per construction

Codegen emits js_class_register_static_field after every Expr::StaticFieldSet, so Shape.made = Shape.made + 1 inside a constructor runs it once per construction — 144,000 times in shapes.ts. Each call did str::from_utf8(…).to_string() (a heap allocation, immediately dropped, because HashMap::insert keeps the original key), a CLASS_DELETED_KEYS probe, and an entry().or_insert_with().insert().

The signature is now &str — every caller already had a borrowed value — and a store whose key exists updates the slot in place. The in-place arm skips the deleted-keys probe only while nothing has ever been deleted; once anything has, the original sequence runs verbatim, so the pre-existing conflation between a deleted prototype key and a same-named static field (class C { m() {} static m = 1 }, both under one class_id) keeps whatever behaviour it had.

Measurements

Quiet M1 mini, load ~1.6, best-of-7 (best-of-5 for the wider set), against a clean build of 1ee158d27 built with the same -p perry -p perry-runtime-static -p perry-stdlib-static package set and an isolated CARGO_TARGET_DIR. Every program's output verified byte-identical to node --experimental-strip-types (26.5.1) with exit 0 before timing; the harness records an exit code per cell.

bench before after
concat_field (new probe) 0.2742 0.1553 −43%
concat_field_base (its subtrahend) 0.0967 0.0965
ns / concatenation 88.8 29.4 −67%
apps/pipeline.ts 0.5164 0.4847 −6.1%
apps/shapes.ts 0.1894 0.1833 −3.2%

No corpus regression (best-of-5): churn 0.4216→0.4234, churn_alloc 0.3729→0.3752, churn_read 0.0228→0.0224, push_num 0.1507→0.1511, push_cls 0.3658→0.3681, cycles 0.1938→0.1938, deeplist 0.1239→0.1232, tree 1.6419→1.6425, tree_wide 2.1132→2.1097, retain 0.3477→0.3464, retain1 0.1367→0.1359, retain_wide 0.4552→0.4569, fib40 0.3997→0.3948, asyncpipe 0.1345→0.1349, interp 1.4976→1.4971, iso_miss 1.9256→1.9260.

The iso_miss canary prints checksum 437840 misses 0 and exits 0 on the miss counter, including under PERRY_GC_SCHEDULE_RATE=1, PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800, and PERRY_GC_VERIFY_EVACUATION=1. shapes, pipeline and concat_field are also byte-correct under PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_VERIFY_EVACUATION=1.

Tests

All in-crate unit tests, so they run in the per-PR cargo-test job rather than the nightly-only integration suites (#5960).

  • perry-runtime: concat_box_delegates_a_non_string_operand_to_the_dynamic_add pins all four operand combinations (including number+number returning a number) plus the SSO result encoding; repeated_store_updates_in_place_and_stays_readable and store_after_delete_clears_the_deleted_mark pin both arms of the static-field store.
  • perry-codegen: alias_declared_string_field_takes_the_static_concat, field_absent_from_the_alias_keeps_the_dynamic_add, and alias_declared_number_field_is_deliberately_not_routed — the last asserts the numeric side is unchanged, so widening it later has to be a deliberate edit to this test.

Local runs on this branch's build: perry-runtime --lib 2108 passed / 0 failed (RUST_TEST_THREADS=1), perry-codegen --lib 858 passed / 0 failed after the test correction. Gap suite in progress locally at time of writing; through 290/535 on the pre-narrowing build the only failures were the 8 already recorded in test-parity/gap_snapshot.json (2 parity_fail, 6 node_fail) — zero new.

Probes added

gc-handoff/bench/concat_field.ts / concat_field_base.ts (the ns/concat pair) live in the out-of-repo handoff tree, not in this PR.

https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2

Summary by CodeRabbit

  • Bug Fixes

    • Improved string concatenation when values declared as strings contain numbers, undefined, or other non-string values.
    • Corrected type handling for properties defined through object-type aliases.
    • Fixed static property updates so existing values are replaced reliably and deleted properties can be restored correctly.
    • Preserved proper numeric addition and string conversion behavior across mixed-value operations.
  • Tests

    • Added regression coverage for mixed-value concatenation, property updates, enumeration, deletion, and restoration scenarios.

Ralph Küpper added 2 commits August 11, 2026 11:28
`"shape:" + this.tag` — a string literal plus a field declared `string` —
lowered to `js_dynamic_string_or_number_add`: a RuntimeHandleScope, four
root_nanbox_f64s and two ToPrimitive calls spent rediscovering what the
declaration already stated. 88.8 ns per concatenation; 29.4 ns after.

Four changes:

1. `js_string_concat_box` forwards a non-string operand to
   `js_dynamic_string_or_number_add` instead of treating it as the empty
   string (`"ab" + 42` used to render as `"ab"`). This is a standalone
   silent-wrong-answer fix, and it is what makes (2) unable to change any
   program's output.

2. A new `is_declared_string_expr`, kept SEPARATE from
   `is_definitely_string_expr` because an annotation is evidence, not proof
   (#7831). Its only consumer is the two-operand concat, which emits
   `js_string_concat_box` — so after (1) the declaration selects a lowering
   and never an answer. The one-sided arm, the N-way chain fold and the Map
   string-key paths all keep the strict predicate; each could otherwise
   change a result.

3. `static_type_of` resolves `type X = { ... }` property types, as it
   already did for `interface X { ... }`. An object-type alias is
   structurally the same declaration; only the filing cabinet differed
   (`module.type_aliases` vs `module.interfaces`).

4. `class_dynamic_prop_root_store` takes `&str` and updates an existing key
   in place. Codegen emits `js_class_register_static_field` after every
   `Expr::StaticFieldSet`, so `Shape.made = Shape.made + 1` in a constructor
   allocated and dropped a `String` once per construction.

Quiet M1 mini, best-of-7, outputs byte-identical to node 26.5.1 with exit 0:
concat probe 0.2742 -> 0.1553, pipeline 0.5164 -> 0.4847, shapes 0.1894 ->
0.1833. No corpus regression; iso_miss canary clean under
PERRY_GC_SCHEDULE_RATE=1, PERRY_GC_PROTECT_FROMSPACE and
PERRY_GC_VERIFY_EVACUATION.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds declaration-based string-concatenation lowering, dynamic coercion for non-string operands, object-type-alias property resolution, and in-place static-field updates with deleted-key handling.

Changes

String Concatenation and Type Analysis

Layer / File(s) Summary
Declared-string lowering and alias resolution
crates/perry-codegen/src/type_analysis/predicates.rs, crates/perry-codegen/src/type_analysis/strings.rs, crates/perry-codegen/src/type_analysis.rs, crates/perry-codegen/src/expr/binary.rs, crates/perry-codegen/src/type_analysis/strings/tests.rs, changelog.d/...
Object-type aliases now resolve declared property types. Pairwise concatenation recognizes declared strings. Tests verify string, numeric, and undeclared fields.

Runtime Concatenation

Layer / File(s) Summary
Runtime concatenation coercion
crates/perry-runtime/src/string/concat.rs, crates/perry-runtime/src/string/tests.rs
js_string_concat_box delegates non-string operands to dynamic string-or-number addition. Tests cover mixed values, numeric addition, undefined, and all-string SSO results.

Static Field Storage

Layer / File(s) Summary
Static-field storage and deletion handling
crates/perry-runtime/src/object/class_registry/state.rs, crates/perry-runtime/src/object/class_registry/gc_roots.rs, crates/perry-runtime/src/object/class_registry/prototype_methods.rs, crates/perry-runtime/src/object/field_set_by_name.rs, crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs, crates/perry-runtime/src/object/object_ops/define_property.rs, crates/perry-runtime/src/symbol/properties.rs
Static-field storage borrows names, updates existing entries in place, preserves write barriers, and handles deleted keys. Callers pass borrowed names, and tests cover updates, enumeration, insertion, and restoration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main codegen and runtime optimization for declared-string concatenation.
Description check ✅ Passed The description is detailed and covers the motivation, scoped changes, benchmarks, tests, related issues, and validation despite using different headings.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/declared-string-concat

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Codegen only selected js_string_concat_box when both operands satisfied the
strict is_definitely_string_expr, so reaching it with a non-string required a
lying `string`-declared local. A declared field and a (string, number)
parameter pair both route elsewhere and answer correctly on 1ee158d. The fix
is still required — widening the operand test to accept a declaration is what
would make the wrong answer reachable — but describing it as a live bug
overclaimed.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
Reverts the previous commit's framing. The empty-string result is reachable on
1ee158d through is_definitely_string_expr's LocalGet arm, which already
trusts a declared type:

    const t: string = (99 as any);
    console.log(t + "x");   // node "99x", perry "x"

Verified on a clean 1ee158d build and on this branch. A declared FIELD and a
(string, number) parameter pair both route elsewhere and were already correct,
which is why three negative probes read as absence.

#7837 records two defects of the same premise. This PR fixes the dropped
operand (defect 2) and NOT the wrong-operator selection (defect 1, `s + 7`
printing 427 instead of 49), which lives in the one-sided arm and needs
#7831-style guarded lowering rather than a runtime delegation.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
@proggeramlug
proggeramlug marked this pull request as ready for review August 11, 2026 10:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
crates/perry-runtime/src/object/class_registry/state.rs (1)

77-102: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Scope the deleted-key fallback to class_id.

Line 77 disables the update fast path for every class after any class key is deleted. CLASS_DELETED_KEYS is keyed by class_id, and the required deleted-key interaction is only within the same class. Keep the global no-deletions fast path, but when it is false, check and clean deleted keys for the current class_id before selecting the slow path. This preserves deleted prototype/static-name handling without permanently degrading unrelated static-field updates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/class_registry/state.rs` around lines 77 -
102, The deleted-key check in the class update logic must be scoped to the
current class instead of treating any entry in CLASS_DELETED_KEYS as disabling
the fast path globally. Preserve the global empty-map fast path, but when
entries exist, inspect the deleted-key set for class_id and remove name only
from that set before deciding whether to update CLASS_DYNAMIC_PROPS or use the
slow path. Ensure unrelated classes retain the fast update behavior.
changelog.d/7835-declared-string-concat-and-static-field-store.md (1)

1-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Condense this fragment into one release-note entry.

Keep the shipped behavior and user impact. Remove benchmark harness details, internal lowering names, and the separate unresolved-defect narrative. These details can become stale or conflict when release notes combine fragments.

Based on learnings, changelog fragments must “describe the final shipped behavior as one coherent release-note entry.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7835-declared-string-concat-and-static-field-store.md` around
lines 1 - 66, Condense the changelog entry into one coherent release note
describing the shipped behavior and user impact: correct non-string handling for
string concatenation, compile-time use of declared string types where safe,
object-type alias property resolution, and more efficient static-field updates.
Remove benchmark data, probe details, internal lowering/runtime symbol names,
test implementation details, and the separate unresolved-defect discussion.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/type_analysis/strings/tests.rs`:
- Around line 1-3: Update the module documentation to refer to the
declaration-based predicate is_declared_string_expr instead of
is_definitely_string_expr, while leaving the static_type_of reference unchanged.

In `@crates/perry-runtime/src/string/concat.rs`:
- Around line 137-146: Update js_string_concat_box to root both l_value and
r_value in a RuntimeHandleScope before string_storage_alloc, then reload the
rooted handles and call str_bytes_from_jsvalue again after allocation before
reading lengths or copying bytes. Ensure no byte views obtained before
allocation are dereferenced afterward, and add a forced-GC test covering
concatenation of heap-backed strings.

---

Nitpick comments:
In `@changelog.d/7835-declared-string-concat-and-static-field-store.md`:
- Around line 1-66: Condense the changelog entry into one coherent release note
describing the shipped behavior and user impact: correct non-string handling for
string concatenation, compile-time use of declared string types where safe,
object-type alias property resolution, and more efficient static-field updates.
Remove benchmark data, probe details, internal lowering/runtime symbol names,
test implementation details, and the separate unresolved-defect discussion.

In `@crates/perry-runtime/src/object/class_registry/state.rs`:
- Around line 77-102: The deleted-key check in the class update logic must be
scoped to the current class instead of treating any entry in CLASS_DELETED_KEYS
as disabling the fast path globally. Preserve the global empty-map fast path,
but when entries exist, inspect the deleted-key set for class_id and remove name
only from that set before deciding whether to update CLASS_DYNAMIC_PROPS or use
the slow path. Ensure unrelated classes retain the fast update behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 54e61f80-b57c-4fea-8f41-235ce6a7cf07

📥 Commits

Reviewing files that changed from the base of the PR and between 82f0e96 and b3380b2.

📒 Files selected for processing (15)
  • changelog.d/7835-declared-string-concat-and-static-field-store.md
  • crates/perry-codegen/src/expr/binary.rs
  • crates/perry-codegen/src/type_analysis.rs
  • crates/perry-codegen/src/type_analysis/predicates.rs
  • crates/perry-codegen/src/type_analysis/strings.rs
  • crates/perry-codegen/src/type_analysis/strings/tests.rs
  • crates/perry-runtime/src/object/class_registry/gc_roots.rs
  • crates/perry-runtime/src/object/class_registry/prototype_methods.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs
  • crates/perry-runtime/src/object/object_ops/define_property.rs
  • crates/perry-runtime/src/string/concat.rs
  • crates/perry-runtime/src/string/tests.rs
  • crates/perry-runtime/src/symbol/properties.rs

Comment on lines +1 to +3
//! cargo-test-visible coverage for the declaration-based string proof in
//! `is_definitely_string_expr` and the `type X = { … }` arm of
//! `static_type_of`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the predicate name in the module documentation.

The declaration-based predicate is is_declared_string_expr, not is_definitely_string_expr. The current text conflicts with the implementation and can direct future changes to the unsafe predicate.

Proposed fix
-//! `is_definitely_string_expr` and the `type X = { … }` arm of
+//! `is_declared_string_expr` and the `type X = { … }` arm of
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
//! cargo-test-visible coverage for the declaration-based string proof in
//! `is_definitely_string_expr` and the `type X = { … }` arm of
//! `static_type_of`.
//! cargo-test-visible coverage for the declaration-based string proof in
//! `is_declared_string_expr` and the `type X = { … }` arm of
//! `static_type_of`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/type_analysis/strings/tests.rs` around lines 1 - 3,
Update the module documentation to refer to the declaration-based predicate
is_declared_string_expr instead of is_definitely_string_expr, while leaving the
static_type_of reference unchanged.

Comment on lines 137 to +146
pub extern "C" fn js_string_concat_box(l_value: f64, r_value: f64) -> f64 {
let mut scratch_l = [0u8; crate::value::SHORT_STRING_MAX_LEN];
let mut scratch_r = [0u8; crate::value::SHORT_STRING_MAX_LEN];
let l = str_bytes_from_jsvalue(l_value, &mut scratch_l).unwrap_or((std::ptr::null(), 0));
let r = str_bytes_from_jsvalue(r_value, &mut scratch_r).unwrap_or((std::ptr::null(), 0));
let (Some(l), Some(r)) = (
str_bytes_from_jsvalue(l_value, &mut scratch_l),
str_bytes_from_jsvalue(r_value, &mut scratch_r),
) else {
// `str_bytes_from_jsvalue` returns `None` for exactly the non-string
// values, so this is the annotation-lie arm and nothing else.
return unsafe { crate::value::js_dynamic_string_or_number_add(l_value, r_value) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Root both operands across string_storage_alloc.

str_bytes_from_jsvalue returns byte views backed by movable heap strings. string_storage_alloc at Line 176 can collect. The reads from l.0 and r.0 at Lines 183-223 can then dereference moved storage.

Root l_value and r_value in a RuntimeHandleScope before allocation. After allocation, reload both handles and decode the byte views again before computing lengths or copying bytes. Add a forced-GC test with heap strings.

Based on learnings, a byte view from str_bytes_from_jsvalue is invalid across allocation or GC for non-SSO strings.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/string/concat.rs` around lines 137 - 146, Update
js_string_concat_box to root both l_value and r_value in a RuntimeHandleScope
before string_storage_alloc, then reload the rooted handles and call
str_bytes_from_jsvalue again after allocation before reading lengths or copying
bytes. Ensure no byte views obtained before allocation are dereferenced
afterward, and add a forced-GC test covering concatenation of heap-backed
strings.

Source: Learnings

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Heads-up from #7842 (closing #7837): this PR's js_string_concat_box hunk is also in mine, verbatim.

I did not intend to duplicate it — #7837's brief told me it was yours and not to redo it — but the acceptance criterion for #7842 is that gc-handoff/m0810/lyinglocal.ts prints 49 and 99x, and the second row is exactly the operand-dropping defect this hunk fixes. There is no codegen-side fix for it that isn't a guard on a case that is already operator-correct (t + "x" really is concat, the helper just discarded t), so #7842 cannot stand on its own without it. Rather than stack on a draft branch that may be reworked, I took the hunk with attribution in both the doc comment and the changelog fragment.

Whichever of us lands second should drop their copy — it is a ~20-line let-else, so the conflict is mechanical either way.

One correction to this PR's framing, now that #7837 has been fully enumerated. The doc comment on is_declared_string_expr says is_definitely_string_expr "answers only for expressions whose string-ness is structural (a literal, String(x), .toString(), JSON.stringify, …)". Two of those are not structural:

#7842 adds string_value_is_runtime_guaranteed to draw that line explicitly (closed whitelist — an unclassified arm is treated as a claim and gets guarded), so if you keep is_declared_string_expr it may be worth defining it against that rather than against is_definitely_string_expr.

No conflict otherwise: your alias_declared_number_field_is_deliberately_not_routed test stays green under #7842, because "t:" is a literal and therefore a proof — that site never reaches my guard. And the concat fast path you are widening keeps its fused single-allocation lowering wherever any operand is proven, which is the "shape:" + this.tag and prefix + r.kind case this PR is about.

proggeramlug pushed a commit that referenced this pull request Aug 11, 2026
…ot pick the `+` operator

`is_definitely_string_expr` answered `true` on the strength of an erased
TypeScript annotation, and `+` chose string concatenation from it. Perry does
not enforce declared types at runtime, so `const s: string = (42 as any)` puts
a number in the slot and thirteen shapes came out silently wrong, exit 0:
`s + 7` printed "427" instead of 49, and `t + "x"` printed "x" — the operand
was decoded as the empty string and vanished.

The policy, matching #7831 on the numeric side: a static type may select a
lowering, never an answer. Applied where each site can afford it.

* `js_string_concat_box` becomes total: a non-string operand is delegated to
  `js_dynamic_string_or_number_add` instead of `unwrap_or((null, 0))`-ing to
  the empty string. (Same hunk as #7835; whichever lands second drops its copy.)
* The one-sided `l ^ r` arm cannot be repaired that way — codegen unboxes to a
  `StringHeader*` before the call, so the tag is gone. A declared-only operand
  is now passed NaN-boxed to `js_string_add_value` / `js_value_add_string`,
  which test the tag and then run either the identical fused concat or the
  spec's `+`.
* The N-way chain fold requires a proven string in its head pair, since it
  formats every part as a string and only reproduces the source tree when the
  first node really concatenates.

`string_value_is_runtime_guaranteed` separates the two kinds of evidence the
predicate had been mixing. Its whitelist is closed: an unclassified arm answers
"claim" and gets guarded, which costs a compare rather than an answer.

Compiling all 19 corpus programs with the base and fixed compilers against the
same runtime archives yields LLVM IR differing by exactly two lines — the
`declare`s for the new helpers. No call site moved.

Refs #7837.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
proggeramlug pushed a commit that referenced this pull request Aug 11, 2026
…ot pick the `+` operator

`is_definitely_string_expr` answered `true` on the strength of an erased
TypeScript annotation, and `+` chose string concatenation from it. Perry does
not enforce declared types at runtime, so `const s: string = (42 as any)` puts
a number in the slot and thirteen shapes came out silently wrong, exit 0:
`s + 7` printed "427" instead of 49, and `t + "x"` printed "x" — the operand
was decoded as the empty string and vanished.

The policy, matching #7831 on the numeric side: a static type may select a
lowering, never an answer. Applied where each site can afford it.

* `js_string_concat_box` becomes total: a non-string operand is delegated to
  `js_dynamic_string_or_number_add` instead of `unwrap_or((null, 0))`-ing to
  the empty string. (Same hunk as #7835; whichever lands second drops its copy.)
* The one-sided `l ^ r` arm cannot be repaired that way — codegen unboxes to a
  `StringHeader*` before the call, so the tag is gone. A declared-only operand
  is now passed NaN-boxed to `js_string_add_value` / `js_value_add_string`,
  which test the tag and then run either the identical fused concat or the
  spec's `+`.
* The N-way chain fold requires a proven string in its head pair, since it
  formats every part as a string and only reproduces the source tree when the
  first node really concatenates.

`string_value_is_runtime_guaranteed` separates the two kinds of evidence the
predicate had been mixing. Its whitelist is closed: an unclassified arm answers
"claim" and gets guarded, which costs a compare rather than an answer.

Compiling all 19 corpus programs with the base and fixed compilers against the
same runtime archives yields LLVM IR differing by exactly two lines — the
`declare`s for the new helpers. No call site moved.

Refs #7837.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
proggeramlug pushed a commit that referenced this pull request Aug 11, 2026
…ot pick the `+` operator

`is_definitely_string_expr` answered `true` on the strength of an erased
TypeScript annotation, and `+` chose string concatenation from it. Perry does
not enforce declared types at runtime, so `const s: string = (42 as any)` puts
a number in the slot and thirteen shapes came out silently wrong, exit 0:
`s + 7` printed "427" instead of 49, and `t + "x"` printed "x" — the operand
was decoded as the empty string and vanished.

The policy, matching #7831 on the numeric side: a static type may select a
lowering, never an answer. Applied where each site can afford it.

* `js_string_concat_box` becomes total: a non-string operand is delegated to
  `js_dynamic_string_or_number_add` instead of `unwrap_or((null, 0))`-ing to
  the empty string. (Same hunk as #7835; whichever lands second drops its copy.)
* The one-sided `l ^ r` arm cannot be repaired that way — codegen unboxes to a
  `StringHeader*` before the call, so the tag is gone. A declared-only operand
  is now passed NaN-boxed to `js_string_add_value` / `js_value_add_string`,
  which test the tag and then run either the identical fused concat or the
  spec's `+`.
* The N-way chain fold requires a proven string in its head pair, since it
  formats every part as a string and only reproduces the source tree when the
  first node really concatenates.

`string_value_is_runtime_guaranteed` separates the two kinds of evidence the
predicate had been mixing. Its whitelist is closed: an unclassified arm answers
"claim" and gets guarded, which costs a compare rather than an answer.

Compiling all 19 corpus programs with the base and fixed compilers against the
same runtime archives yields LLVM IR differing by exactly two lines — the
`declare`s for the new helpers. No call site moved.

Refs #7837.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
proggeramlug pushed a commit that referenced this pull request Aug 11, 2026
…ot pick the `+` operator

`is_definitely_string_expr` answered `true` on the strength of an erased
TypeScript annotation, and `+` chose string concatenation from it. Perry does
not enforce declared types at runtime, so `const s: string = (42 as any)` puts
a number in the slot and thirteen shapes came out silently wrong, exit 0:
`s + 7` printed "427" instead of 49, and `t + "x"` printed "x" — the operand
was decoded as the empty string and vanished.

The policy, matching #7831 on the numeric side: a static type may select a
lowering, never an answer. Applied where each site can afford it.

* `js_string_concat_box` becomes total: a non-string operand is delegated to
  `js_dynamic_string_or_number_add` instead of `unwrap_or((null, 0))`-ing to
  the empty string. (Same hunk as #7835; whichever lands second drops its copy.)
* The one-sided `l ^ r` arm cannot be repaired that way — codegen unboxes to a
  `StringHeader*` before the call, so the tag is gone. A declared-only operand
  is now passed NaN-boxed to `js_string_add_value` / `js_value_add_string`,
  which test the tag and then run either the identical fused concat or the
  spec's `+`.
* The N-way chain fold requires a proven string in its head pair, since it
  formats every part as a string and only reproduces the source tree when the
  first node really concatenates.

`string_value_is_runtime_guaranteed` separates the two kinds of evidence the
predicate had been mixing. Its whitelist is closed: an unclassified arm answers
"claim" and gets guarded, which costs a compare rather than an answer.

Compiling all 19 corpus programs with the base and fixed compilers against the
same runtime archives yields LLVM IR differing by exactly two lines — the
`declare`s for the new helpers. No call site moved.

Refs #7837.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
proggeramlug added a commit that referenced this pull request Aug 11, 2026
…ot pick the `+` operator (#7842)

`is_definitely_string_expr` answered `true` on the strength of an erased
TypeScript annotation, and `+` chose string concatenation from it. Perry does
not enforce declared types at runtime, so `const s: string = (42 as any)` puts
a number in the slot and thirteen shapes came out silently wrong, exit 0:
`s + 7` printed "427" instead of 49, and `t + "x"` printed "x" — the operand
was decoded as the empty string and vanished.

The policy, matching #7831 on the numeric side: a static type may select a
lowering, never an answer. Applied where each site can afford it.

* `js_string_concat_box` becomes total: a non-string operand is delegated to
  `js_dynamic_string_or_number_add` instead of `unwrap_or((null, 0))`-ing to
  the empty string. (Same hunk as #7835; whichever lands second drops its copy.)
* The one-sided `l ^ r` arm cannot be repaired that way — codegen unboxes to a
  `StringHeader*` before the call, so the tag is gone. A declared-only operand
  is now passed NaN-boxed to `js_string_add_value` / `js_value_add_string`,
  which test the tag and then run either the identical fused concat or the
  spec's `+`.
* The N-way chain fold requires a proven string in its head pair, since it
  formats every part as a string and only reproduces the source tree when the
  first node really concatenates.

`string_value_is_runtime_guaranteed` separates the two kinds of evidence the
predicate had been mixing. Its whitelist is closed: an unclassified arm answers
"claim" and gets guarded, which costs a compare rather than an answer.

Compiling all 19 corpus programs with the base and fixed compilers against the
same runtime archives yields LLVM IR differing by exactly two lines — the
`declare`s for the new helpers. No call site moved.

Refs #7837.

Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant