Skip to content

fix(transform): keep statement-position inlines from returning out of the caller - #10530

Closed
proggeramlug wants to merge 2 commits into
mainfrom
fix/10416-inliner-stmt-return
Closed

proggeramlug wants to merge 2 commits into
mainfrom
fix/10416-inliner-stmt-return

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

A small helper called as a statement (f(x);, result discarded) whose inlined body ended in an if containing return had that return spliced into the caller by the HIR inliner. The caller then returned the helper's value: decimal.js intPow returned truncate's true, so new Decimal(x).pow(n) was true. At module top level the stray ret double failed LLVM IR validation against main's i32 result.

Root cause

inline_calls_in_stmts's Stmt::Expr arm (crates/perry-transform/src/inline/call_inliner.rs:380-447 on 7661bc0) checked for nested returns only in inlined_stmts.iter().take(len - 1), so it never looked inside the last statement, and then rewrote the last statement only when it was a bare Stmt::Return. A trailing if containing returns passed both checks, and its Returns were spliced into the caller unchanged. The let r = f() arm (:547-592) already handles this shape with a do { … } while (false) wrapper plus convert_returns_in_stmts.

Auditing the same file for the same blind spot (a return handled as if it sat at the tail) found two siblings, both confirmed on 7661bc0:

  1. The call was deleted. When that arm declined to inline (a nested return before the last statement), the fallback called inline_calls_in_expr on the call and then replaced the call statement with whatever setup it hoisted out of the arguments (new_stmts = Some(hoisted)). f(g(i));, where g is a const z = …; return z + 1 helper and f has an early return, printed nothing: the call to f was gone.
  2. Dead code ran. try_inline_simple_call's void-method path (:1910-1926) skipped Stmt::Return(None) and kept going, so m() { return; this.x = 1; } used as a value (console.log(o.m())) ran this.x = 1.

The let r = f() arm, the return f()/throw f() arm and the expression-position arms (try_inline_simple_call patterns 1/2) are not affected.

Fix

  • New inline/discarded_result.rs: discard_inlined_returns removes every return from an inlined body whose result is discarded, structurally rather than with a loop wrapper. return e becomes e; (evaluation kept, value dropped), return; becomes nothing, and the statements after an if that returns move into the branch that can still fall through to them (if (c) { a; return v; } rest becomes if (c) { a; v; } else { rest }). It declines, which keeps the call, instead of duplicating or dropping statements: both branches can fall through to a non-empty continuation, statements follow a return (or an if whose branches all exit), or a return sits under a loop, switch, try or label. It adds no loop or label, so a break/continue in the callee keeps its target, and it does not rely on has_simple_control_flow for any of that.
  • The Stmt::Expr arm uses it. When it declines, the arm uses the ordinary hoist path, which keeps the call statement after the hoisted argument setup (sibling 1).
  • The void-method path stops at return; (sibling 2).

Why not the do { } while (false) wrapper, and why not refuse: refusing loses inlining for exactly the guard helpers this affects, and a real call is 3.4 to 3.8x the instructions in a hot loop (table below). The wrapper puts a loop in the caller's hot path, with a GC back-edge poll, exact-receiver facts cleared after it, and a loop-shape change for any loop the call sits in. The structural rewrite measured equal to or slightly better than the wrapper written out by hand (1%). A side effect: bodies with an early return before more statements (if (x < 0) return; acc[0] += x;) were previously never inlined at a statement call site. They now are, which matches the let r = f() arm.

Tests

  • test-files/test_gap_10416_inliner_stmt_return.ts: call statements in for, while, do-while and for (;;) loops (non-constant bounds, so they stay loops), in a function with more than 10 statements, in class methods with and without a loop, in arrows (module-level and nested), at module top level, and in a method-call callee on an exact receiver. Callee shapes: if (c) { return v; } only/last, const y = …; if (y) return v;, if/else returning in both branches, early return then more work, else-branch return, a nested conditional return (declined, call kept, argument helper still inlined), return;, var declared before the return, a return with a side effect, nested helpers, and switch/try/finally/generator/async callers. Controls: value uses, return w after the if, throw after the if, and a call site where the if is false at runtime.
    • 7661bc0: Compile Fail (the top-level calls produce error: value doesn't match function result type 'i32'). With the top-level block removed, 13 of 15 checked lines differ from Node (for example inFor:1,2 becomes true, nested:… loses the deleted call's output, and dead:0 becomes dead:1). The two that match are the controls line and the m() value line.
    • This branch: Parity Pass (PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --filter test_gap_10416).
  • crates/perry-transform/src/inline/discarded_result.rs: 7 rewrite unit tests, plus 3 through the inliner: the Inliner copies a callee's return into the caller when the call is a statement and the callee ends in if (...) { return ... } #10416 repro via inline_functions (function body and module init), the kept call after hoisted argument setup, and the void method with statements after return;. The 3 inliner tests fail with the old call_inliner.rs (verified by swapping the file back); all 10 pass on this branch.

Validation (perrybuilder, Linux x64)

check result
cargo test --release -p perry-transform --tests 147 passed, 0 failed
cargo test --release -p perry-codegen --tests 2069 passed, 0 failed
cargo test --release -p perry --tests perry bin unit tests: 1128 of 1129 passed. The one failure, geisterhand::tests::warm_archives_are_rebuilt_as_one_runtime_graph, is a NotFound spawning a PATH-resolved tool and passes when run alone; this crate's tests mutate the process-global PATH, and the test is unrelated to this change. Integration suites: running all 380 serially was not practical on the shared host, so I ran the 23 that exercise inlining, IR shape or GC rooting (issue_inline_seq_arg_binding_hoist, method_shape_inline_guard, minsize_inline_policy, issue_5437_comma_seq_ctor_fields, issue_9131_prototype_method_replacement, issue_8897_field_push_writeback, issue_class_instance_wide_set, issue_5195_property_method_chain, issue_6040_generic_class_dispatch, issue_8693_imported_this_specialization, guarded_numeric_arith, issue_8774_argument_shape_clones, loop_property_array_hoist, issue_9342_u8_inline_read, packed_loop_offset_read_accumulator, strided_tagged_fill, module_global_typed_array_read, issue_9253_affine_range_index, issue_9259_length_bound_offset_reads, gc_array_iter_rooted, gc_write_barrier_stress, issue_10300_cross_module_native_base, standalone_regression): 63 passed, 0 failed, 2 ignored.
cargo fmt --all -- --check clean
scripts/run_lint_gates.sh (full, incl. compile tier) 82 of 83 passed, 2 CI-only skipped (changeset fragment API check, cargo-test shard validation). The one red is Public benchmark evidence freshness (ci_public_baseline_check.py: "public artifact benchmark inputs changed"), which is pre-existing: it fails the same way on a 7661bc0 checkout. The compile tier (-D warnings check and clippy, product and host-compatible, API docs regen and drift) is green.
gap suite (scripts/run_gap_tests.sh) vs 7661bc0 baseline run GAP_EXIT=0, gap snapshot OK. 814 of 820 pass. The 6 non-passing tests (2159_defineproperty_class_prototype, 2514_settracesigint, json_lazy_defineproperty_index, perfhooks_3088_3008_3010_3011, prop_plan_cache_invalidation, v8_2_3680plus) are the same 6 that fail in the 7661bc0 baseline run. No new failures. The new test passes.

Performance (perf stat -e instructions, 3 runs each, 50M iterations; Node 26.5.1 wall for context)

hot loop 7661bc0 this branch Node
A: truncate(arr, 10) (trailing if-return, if never true) 6.3107e9 / 6.3110e9 / 6.3107e9 6.3103e9 / 6.3107e9 / 6.3105e9 ~160 ms
B: if (v < 0) return; acc[0] += v; (was never inlined) 40.48e9 / 40.88e9 / 40.48e9 10.56e9 / 10.56e9 / 10.56e9 ~57 ms
C: no nested return (unchanged shape) 13.119e9 / 13.117e9 / 13.116e9 13.117e9 / 13.117e9 / 13.116e9 ~55 ms
D: if/else both return wrong result (D:0: the caller returned on the first iteration) 11.51e9 / 11.52e9 / 11.52e9 (vs 38.7e9 for the same helper made non-inlinable, i.e. "refuse") ~120 ms
benchmarks/suite/09_method_calls.ts 95.60e6 / 95.61e6 / 95.63e6 95.74e6 / 95.54e6 / 95.66e6 15 ms
decimal.js 10.6.0 bench (100k times/plus/div/sqrt) 68.05e9 / 68.10e9 / 68.17e9 68.04e9 / 68.05e9 / 68.05e9 ~350 ms

Hand-written loop bodies on this branch put the structural form against the do { … } while (false) wrapper: 10.864e9 vs 10.865e9 for if/else, and 9.963e9 vs 10.064e9 for a guard plus work.

Binary size: decimal.js main_w 31,554,768 → 31,554,792 bytes (+24).

Package check (decimal.js 10.6.0, audit main_w.ts)

pow now matches Node (["1.267650600228229401496703205376e+30","2.718145926825224864","0.125"]; it threw (boolean).toSignificantDigits is not a function before). The remaining differences are identical on 7661bc0 and are separate blockers: chain, toFixed and big money give NaN, toExponential/toPrecision give NaN, new D2(1) instanceof Decimal for a Decimal.clone() constructor is false, and toHexadecimal/toBinary give 0x0/0b0.

Not verified

  • macOS, Windows and the auto-optimize (full-tier) gap shards: the gap suite ran in fast mode (PERRY_SKIP_BUILD=1, no auto-optimize) on Linux x64 only.
  • The do { } while (false) alternative was compared only as hand-written TypeScript loop bodies, not as a compiler variant.
  • The remaining ~357 perry integration suites (the sweep and full CI tiers run them).
  • Cross-module inline candidates are not exercised by the gap test. They go through the same inline_calls_in_stmts arm.
  • The remaining decimal.js differences listed above were not investigated.

Fixes #10416

Summary by CodeRabbit

  • Bug Fixes

    • Fixed incorrect control flow when calling functions whose return values are ignored.
    • Preserved statements following conditional or early returns, including calls within loops, branches, error-handling blocks, and asynchronous or generator functions.
    • Prevented unreachable statements after return; from affecting generated code.
    • Improved handling of helper functions with early returns when their results are discarded.
  • Tests

    • Added comprehensive regression coverage for nested control flow, fall-through behavior, and discarded-result calls.

… the caller

A call used as a statement (`f(x);`) whose inlined callee ended in an
`if` containing `return` had that return spliced into the caller: the
statement arm of inline_calls_in_stmts only looked for nested returns in
take(len - 1) and only rewrote a bare trailing Return. The caller then
returned the callee's value (decimal.js pow() returned true), and at module
top level the stray ret produced invalid LLVM IR.

The statement arm now removes every return structurally
(discard_inlined_returns): `return e` becomes `e;`, and statements after
an `if` that returns move into the branch that falls through to them. No
loop wrapper is added. It declines (keeps the call) instead of duplicating
or dropping statements, or when a return sits under a loop, switch, try or
label.

Two siblings of the same blind spot are fixed as well: the declined-inline
fallback replaced the call statement with the setup hoisted out of its
arguments (deleting the call), and the void-method expression inliner
spliced statements that follow `return;`.
@proggeramlug proggeramlug added the package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings label Sep 17, 2026
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 1a42e463-1af4-4087-83ab-6024437c2e58

📥 Commits

Reviewing files that changed from the base of the PR and between 5030e6e and 8b73109.

📒 Files selected for processing (5)
  • changelog.d/10530-inliner-stmt-return.md
  • crates/perry-transform/src/inline/call_inliner.rs
  • crates/perry-transform/src/inline/discarded_result.rs
  • crates/perry-transform/src/inline/mod.rs
  • test-files/test_gap_10416_inliner_stmt_return.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The inliner now rewrites discarded returns safely, preserves calls for unsupported control flow, stops void-method expansion after bare returns, and adds unit, integration, and end-to-end regression coverage.

Changes

Discarded-result inlining

Layer / File(s) Summary
Return rewrite helper
crates/perry-transform/src/inline/discarded_result.rs
Adds return removal, expression conversion, continuation relocation, normal-completion analysis, and tests for supported and rejected control flow.
Inliner integration and void methods
crates/perry-transform/src/inline/mod.rs, crates/perry-transform/src/inline/call_inliner.rs, crates/perry-transform/src/inline/discarded_result.rs, changelog.d/10530-inliner-stmt-return.md
Routes statement calls through discard_inlined_returns, preserves calls when rewriting declines, and stops void-method splicing after return;.
TypeScript regression scenarios
test-files/test_gap_10416_inliner_stmt_return.ts
Covers discarded calls across loops, methods, arrows, switch and try blocks, generators, async functions, and module initialization.

Priority: ➖ Normal

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

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 8b731

The discarded-result rewrite preserves nested closure returns, and no actionable merge risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.85% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 54 functions across 4 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary fix: preventing statement-position inlined calls from returning out of the caller.
Description check ✅ Passed The description is complete and directly addresses the template requirements. It explains the problem, root cause, implementation, related issue, tests, validation results, performance, and remaining …
Linked Issues check ✅ Passed Issue #10416 requires statement-position inlining to discard callee return control flow while preserving expression evaluation and caller fall-through. discard_inlined_returns implements this transf…
Out of Scope Changes check ✅ Passed The reviewed changes stay connected to #10416. The fallback-call fix prevents semantic loss when safe return removal declines. The void-method fix prevents statements after return; from being splice…
Full details: Docstring Coverage

Explanation

Docstring coverage is 51.85% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 54 functions across 4 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/10416-inliner-stmt-return

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

test_gap_cron_cronjob on gap-suite shard 5 is a flake, not a regression from this PR

Short version: for this test my compiler emits byte-identical LLVM IR to the 7661bc05fe baseline, so the binary under test is the same program on both sides of the comparison. The failure could not have been caused by this change.

1. The compiled program is identical. Compiling test-files/test_gap_cron_cronjob.ts with --trace llvm on the baseline compiler and on this branch (both PERRY_NO_AUTO_OPTIMIZE=1, same runtime archive set) gives two t_ts.ll files that differ only in the absolute path of the compile directory, which is embedded in the entry-path string because the two runs used different working directories:

5c5
< @t_ts_.str.0 = private unnamed_addr constant [44 x i8] c".../ir-base/t.ts\00"
---
> @t_ts_.str.0 = private unnamed_addr constant [43 x i8] c".../ir-fix/t.ts\00"
5252c5252
<   call void @js_set_process_entry_path(ptr @t_ts_.str.0, i32 43)
---
>   call void @js_set_process_entry_path(ptr @t_ts_.str.0, i32 42)

Normalising that one string makes the two 5,438-line files compare equal (cmp clean). --print-hir likewise matches apart from the same path and the ext-archive path. That is expected: cron takes the native-binding route (crates/perry-ext-cron, no TypeScript shim), so the only TypeScript compiled here is the test itself, and it contains no user function the inliner would treat as a candidate — no helper is called as a statement anywhere in it. This PR only changes inline_calls_in_stmts's statement arm and the void-method path.

2. It does not reproduce, on either binary. On perrybuilder (Linux x64), with cron@4.4.0 present in node_modules:

baseline 7661bc05fe this branch
PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --filter test_gap_cron_cronjob 12/12 PASS 12/12 PASS
compiled test binary run directly, 24 concurrent instances 24/24 identical correct output 24/24 identical correct output
same binary pinned to one core against 3 busy loops, 5 runs 5/5 correct 5/5 correct

The test also passed in my full local gap sweep on this branch (test-parity/reports/…_103801.json: {"id": "test_gap_cron_cronjob", "status": "pass"}, 820 tests, GAP_EXIT=0, the only 6 failures being the same 6 the baseline sweep has).

3. CI history says the same. Across the last 40 test.yml runs I found 20 gap-suite jobs that ran this test. It passed in 19 and failed in exactly one: job 105204344569, the one on this PR. Passing jobs take 11–15 s for this test, and the failing one took 14.2 s, so the duration does not distinguish them either.

Why it can flake. The test asserts booleans that are gated on wall-clock ticks: it waits up to 10 s for a * * * * * * job to fire twice, then prints manual ticked at least twice: <bool> and auto ticked at least twice: <bool>. Node and Perry each run under their own deadline, so a slow moment on either side flips a boolean on that side only and the byte comparison fails. That matches the reported symptom — first line (constructed, ticks now: 0) equal, a later line differing, both exit 0. The harness truncated the diff, so I cannot say which side flipped; nothing in the run points at Perry rather than at the Node oracle.

I have not changed the test: it is not this PR's, and tightening it would be a change to an unrelated fixture. It may be worth a separate issue to make the timing assertions deterministic (or to give the deadline more headroom) — happy to file one if you want it.

Lint, for the record. scripts/run_lint_gates.sh (full, including the compile tier) is 82 of 83 green on this branch, with 2 CI-only steps skipped. The single red is Public benchmark evidence freshness (benchmarks/ci_public_baseline_check.py: "public artifact benchmark inputs changed"), which fails identically on a clean 7661bc05fe checkout — the known-red step that is left red on main.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10610 (v0.5.1594). All source commits preserve authorship; merged main matches the validated train exactly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inliner copies a callee's return into the caller when the call is a statement and the callee ends in if (...) { return ... }

1 participant