Skip to content

fix(codegen): a typed-array store used as an expression evaluated to 0 (#7590) - #7591

Open
proggeramlug wants to merge 2 commits into
mainfrom
fix/7590-discard-expr-value-leak
Open

fix(codegen): a typed-array store used as an expression evaluated to 0 (#7590)#7591
proggeramlug wants to merge 2 commits into
mainfrom
fix/7590-discard-expr-value-leak

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #7590.

The bug

const buf = new Uint8Array(4);
sink((buf[0] = 5), 5);           // was 0, now 5
n = buf[1] = 7;                  // was 0, now 7
sink((buf[2] = 3) + 100, 103);   // was 100, now 103

The stores always landed correctly (buf 5 7 3 both before and after) — only the expression's value was wrong. So it was silent: a wrong number, no crash, no diagnostic. An assignment expression must evaluate to the assigned value (ES2024 §13.15.2).

Cause

ctx.discard_expr_value means "this STATEMENT's value is discarded". It is set once per Stmt::Expr, and lower_expr never cleared it while recursing — the only reset in the tree is lower_call/new_ctor_args.rs, for constructor arguments. So it was still set while lowering the operands of sink(buf[0] = 5);.

Four sites read it as though it meant "this EXPRESSION's value is discarded" and returned double_literal(0.0):

  • expr/index_set.rslower_typed_array_store, proven-view checked store
  • expr/arrays_finds.rsUint8ArraySet / buffer stores

expr/dispatch.rs also reads the flag but only to pick a materialization path, so it is unaffected and left alone.

Fix

FnCtx::discard_this_expr, which dispatch::lower_expr takes (mem::take) at the top of every dispatch. It therefore reaches exactly one expression — the one the statement is made of — and every operand lowered beneath it reads false.

The handlers receive it as a parameter rather than reading the field. That is deliberate: they consult the answer after lowering their operands, by which point the field has been taken again, so reading a field there would have reintroduced the same bug in a subtler form.

How it was found

I was gating arr.push(x)'s length computation on the same flag as a performance change. js_array_length is not a field read — it resolves Proxy arrays through the get trap and probes the registered-Set/Map side tables — and a statement-position push discards its result, so it is 8–13% of push_cls spent producing a number nobody reads (see #7511).

That optimisation produced exactly this bug for sink(a.push(10)), n = a.push(20), a.push(1) + 100 and a.push(1) > 0 ? 7 : 9 — which is what led me to test the pre-existing sites and find they had it already.

The optimisation is not in this PR. It needs this fix first, and then the same non-leaking signal; landing them together would have mixed a correctness fix with a perf change.

Testing

test-files/test_typed_array_store_expression_value.ts consumes a store's value in call-argument, assignment, arithmetic, conditional and nested-store position, and keeps two discarded stores to prove the ordinary path still writes. Output matches node exactly.

That combination is the point: the discarded form kept working the entire time the bug was live, so a "does it still run" smoke test passes. Only a test that consumes the value catches it.

  • 78 test-files programs (typed-array/buffer weighted, plus a spread across the corpus) compiled and run under both arms: the only behavioural difference is this new test, which goes assign:WRONG got=0 want=7assign:ok.
  • perry-codegen failure set identical to origin/main (22 pre-existing).
  • cargo fmt --all -- --check and scripts/check_file_size.sh clean.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed typed-array and buffer store expressions incorrectly returning 0 when their values were used in arguments, assignments, calculations, conditions, or nested expressions.
    • Preserved expected behavior when store results are intentionally discarded.
  • Tests
    • Added regression coverage for typed-array store value propagation across common expression contexts.

Ralph Küpper added 2 commits August 7, 2026 10:48
`ctx.discard_expr_value` means "this STATEMENT's value is discarded". It is set
once per Stmt::Expr and lower_expr never cleared it while recursing, so it was
still set while lowering the OPERANDS of `sink(buf[0] = 5);`. Four sites read
it as though it meant "this EXPRESSION's value is discarded" and returned 0.0:
index_set.rs (typed-array store, proven-view checked store) and arrays_finds.rs
(Uint8ArraySet / buffer stores).

The stores landed correctly — only the expression's value was wrong, so this
was silent. An assignment expression must evaluate to the assigned value
(ES2024 13.15.2).

Adds FnCtx::discard_this_expr, which dispatch::lower_expr TAKES at the top of
every dispatch, so it reaches exactly the statement's own expression and every
operand beneath reads false. The handlers receive it as a parameter rather than
reading the field: they consult it after lowering their operands, by which
point the field has been taken again.

Found while gating arr.push(x)'s length computation on the same flag as a perf
change (#7511); that produced this bug for sink(a.push(10)), n = a.push(20),
a.push(1)+100 and a.push(1)>0?7:9, which exposed the pre-existing one. The
optimisation is not included — it needs this fix first.

Test consumes a store's value in call-argument, assignment, arithmetic,
conditional and nested-store position, plus two discarded stores: the discarded
form kept working throughout, so a smoke test passes while the bug is live.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a1f2c21d-bf9a-4778-a521-561ea36301d5

📥 Commits

Reviewing files that changed from the base of the PR and between 46338e3 and 2a7daa6.

📒 Files selected for processing (12)
  • changelog.d/7591-discard-expr-value-leak.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/arrays_finds.rs
  • crates/perry-codegen/src/expr/dispatch.rs
  • crates/perry-codegen/src/expr/index_set.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/src/stmt/mod.rs
  • test-files/test_typed_array_store_expression_value.ts
🚧 Files skipped from review as they are similar to previous changes (12)
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/arrays_finds.rs
  • crates/perry-codegen/src/expr/dispatch.rs
  • crates/perry-codegen/src/expr/mod.rs
  • changelog.d/7591-discard-expr-value-leak.md
  • crates/perry-codegen/src/expr/index_set.rs
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • test-files/test_typed_array_store_expression_value.ts
  • crates/perry-codegen/src/stmt/mod.rs
  • crates/perry-codegen/src/codegen/function.rs

📝 Walkthrough

Walkthrough

Typed-array and buffer store expressions now distinguish statement-level discard from nested expression usage. Store values remain available when consumed as operands, while discarded stores retain their existing behavior. Regression coverage validates both cases.

Changes

Typed-array store value propagation

Layer / File(s) Summary
Scoped discard state propagation
crates/perry-codegen/src/expr/mod.rs, crates/perry-codegen/src/stmt/mod.rs, crates/perry-codegen/src/expr/dispatch.rs, crates/perry-codegen/src/codegen/*.rs
Adds and initializes FnCtx::discard_this_expr. Expression statements scope the flag, and expression dispatch consumes it before lowering nested operands.
Store result lowering
crates/perry-codegen/src/expr/index_set.rs, crates/perry-codegen/src/expr/arrays_finds.rs, crates/perry-codegen/src/expr/proxy_reflect.rs
Passes explicit discard state to store handlers. Typed-array and buffer stores return 0.0 only when their own result is discarded.
Regression coverage and changelog
test-files/test_typed_array_store_expression_value.ts, changelog.d/7591-discard-expr-value-leak.md
Tests store expressions in consumed and discarded contexts. Documents the fix and verification results.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the typed-array store expression code generation bug that this pull request fixes.
Description check ✅ Passed The description clearly explains the bug, cause, fix, related issue, affected cases, and comprehensive test results.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% 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 fix/7590-discard-expr-value-leak

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.

@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.

🧹 Nitpick comments (1)
test-files/test_typed_array_store_expression_value.ts (1)

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

Extend coverage to the Buffer receiver and the dynamic-index proven-view tier.

This test only uses Uint8Array with literal indices. Two other code paths that this PR changes are not exercised:

  • index_set.rs's proven-view checked-store tier, which fires for a dynamic (non-literal) index on a storage-proven view. A literal index resolves through the earlier lower_typed_array_store tier instead.
  • arrays_finds.rs's BufferIndexSet discard checks, which only apply to a Buffer receiver, not Uint8Array.

Add a Buffer variant and a variable-index variant of the consumed-store cases to confirm both tiers return the assigned value instead of 0.

🧪 Suggested additional test cases
 const buf = new Uint8Array(8);
+const nodeBuf = Buffer.alloc(4);
 const out: string[] = [];

 function check(label: string, got: number, want: number): void {
   out.push(got === want ? label + ":ok" : label + ":WRONG got=" + got + " want=" + want);
 }

 // consumed as a call argument
 check("arg", (buf[0] = 5), 5);
+// consumed on a Buffer receiver
+check("buffer", (nodeBuf[0] = 9), 9);
+// consumed with a dynamic (non-literal) index, forcing the proven-view checked-store tier
+let i = 3;
+check("dynamic_index", (buf[i] = 6), 6);
🤖 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 `@test-files/test_typed_array_store_expression_value.ts` around lines 1 - 35,
Extend the typed-array store test to cover both missing paths: add consumed
assignment-expression cases using a Buffer receiver to exercise BufferIndexSet
discard handling, and use a variable non-literal index on a storage-proven view
to exercise index_set.rs’s checked-store tier. Assert each expression evaluates
to its assigned value while preserving the existing literal-index Uint8Array and
discarded-store coverage.
🤖 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.

Nitpick comments:
In `@test-files/test_typed_array_store_expression_value.ts`:
- Around line 1-35: Extend the typed-array store test to cover both missing
paths: add consumed assignment-expression cases using a Buffer receiver to
exercise BufferIndexSet discard handling, and use a variable non-literal index
on a storage-proven view to exercise index_set.rs’s checked-store tier. Assert
each expression evaluates to its assigned value while preserving the existing
literal-index Uint8Array and discarded-store coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9cb3c329-8dc8-471b-8af8-20c1e6ed4c43

📥 Commits

Reviewing files that changed from the base of the PR and between 46338e3 and 2a7daa6.

📒 Files selected for processing (12)
  • changelog.d/7591-discard-expr-value-leak.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/arrays_finds.rs
  • crates/perry-codegen/src/expr/dispatch.rs
  • crates/perry-codegen/src/expr/index_set.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/src/stmt/mod.rs
  • test-files/test_typed_array_store_expression_value.ts

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — correct and complete. Holding the merge briefly for one reason stated at the end.

The mechanism is sound. mem::take at the top of dispatch::lower_expr
gives exactly-one-expression semantics: the statement's own expression reads
true, every operand lowered beneath it reads false. And the set/use ordering
in lower_stmt is careful in a way worth pointing at —

ctx.discard_this_expr = true;
let result = lower_expr(ctx, e);
ctx.discard_this_expr = false;
ctx.discard_expr_value = prev_discard;
let _ = result?;          // deferred, so both flags reset even on the error path

The explicit = false after the call is not redundant with the take: it is
what keeps the flag from leaking into a later statement if e never reaches
dispatch.

It fixes more sites than the body claims. The prose says "four sites"; the
code converts eight textual reads — three in index_set.rs (759/774/853)
and five in arrays_finds.rs (793/829/900/917/990). Zero
ctx.discard_expr_value reads survive in either file. The four is presumably
four logical constructs, but it undersells the change, and "did it get all of
them" is the first thing a reviewer wants to know. It did.

The third caller is handled correctly. index_set::lower is also reached
from proxy_reflect.rs:1266, which dispatch never feeds. It passes false
the conservative direction (computes the value rather than returning 0) — with
the reasoning at the call site. Good: an unconditional true there would have
been a fresh instance of this bug.

The surviving reader is correctly scoped out. dispatch.rs:25 still reads
discard_expr_value, but it selects a materialization path, not a value, so
its leak into operands cannot produce a wrong number.

Ran all four lint gates on the branch: addr_class_inventory,
class_id_collisions, raw_handle_debt (998/998), check_file_size.sh and
cargo fmt --check — all clean.

One recommendation, and I think it matters

There are now two fields one misreading apart, and misreading them is
precisely what caused this bug at eight sites:

  • discard_expr_value — the enclosing statement's value is discarded; leaks
    into operands
  • discard_this_exprthis expression's value is discarded; taken, so it
    cannot leak

The names do not encode the distinction. Only the doc comments do, and the
person who introduces the ninth site will be reading code, not doc comments.
The mechanical fix is to rename discard_expr_valuediscard_stmt_value: no
behaviour change, and it makes the wrong read impossible to write without
noticing. Worth doing here rather than as a follow-up, because the follow-up is
exactly the thing that does not get done.

(Related, for whoever picks up the deferred arr.push optimisation: the same
non-leaking signal is now available, so it should use discard_this_expr and
never the other one.)

Why I have not merged yet

The one check I have not run locally is cargo test. A public-baseline
regeneration is currently occupying the machine and a build would disturb its
CPU-quiet gate. I will build, run perry-codegen/perry-hir/perry-runtime
and merge as soon as it finishes.

That is deliberate rather than cautious-by-default: I merged #7579 today after
running three of the four lint gates but not the fourth, and it turned lint
red on main and on every open PR until #7585. The rule I took from that is to
run the gates on every PR I merge, not only the ones I write — so I would
rather wait an hour than take the author's test run on faith.

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.

fix(codegen): a typed-array element store used as an expression evaluates to 0 — discard_expr_value leaks into operand position

1 participant