Skip to content

Commit 273a681

Browse files
committed
fix(codegen): #263 arrow function class field SIGSEGV when reading this (v0.5.377)
Pre-fix `apply_field_initializers_recursive` lowered every field initializer through Expr::PropertySet { object: This, ... }. For arrow inits with captures_this=true, that path lowered the closure (which reserves a capture slot at index auto_captures.len() initialized to 0.0) and stored the closure pointer as the field — but never patched the reserved this-slot with the constructor's this. Inside the arrow body, Expr::This read 0.0 from the slot, then `this.value` dereferenced address 0 → SIGSEGV. Fix mirrors the patch-after-build pattern already used by lower_object_literal for object-literal methods. New arm in the per-field loop: when init_expr is Expr::Closure { captures_this: true, .. }, lower the closure, read the current this from ctx.this_stack.last(), call js_closure_set_capture_f64 with this_idx = compute_auto_captures(...).len(), then emit the js_object_set_field_by_name write directly (bypassing PropertySet would re-lower the closure expression and produce a fresh, unpatched copy). Regression test test-files/test_issue_263_arrow_field_this.ts covers the exact issue repro + multi-arg arrow + per-instance capture (two Counter instances each call inc = () => { this.count++; ... } and observe independent counts). All 4 outputs match `node --experimental-strip-types` byte-for-byte. cargo test --release -p perry-runtime --lib 173/0; gap tests 25/28 = baseline.
1 parent b3d55bc commit 273a681

5 files changed

Lines changed: 145 additions & 32 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
88

99
Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation.
1010

11-
**Current Version:** 0.5.376
11+
**Current Version:** 0.5.377
1212

1313
## TypeScript Parity Status
1414

@@ -149,6 +149,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re
149149

150150
Keep entries to 1-2 lines max. Full details in CHANGELOG.md.
151151

152+
- **v0.5.377** — Closes #263: arrow function stored as a class field SIGSEGV'd when the body read `this` (e.g. `arrowField = () => this.value`). Pre-fix the field-init hoisting in `apply_field_initializers_recursive` (`crates/perry-codegen/src/lower_call.rs:2479`) wrapped each `(prop, init_expr)` in a `PropertySet { object: This, property, value: init_expr }` and lowered through the generic path. For an `Expr::Closure { captures_this: true, .. }` init, that path lowers the closure (which reserves a capture slot at index `auto_captures.len()` and initializes it to 0.0 — see expr.rs:3294-3304) and stores the closure pointer as the field — but never patches the reserved this-slot with the constructor's `this`. Inside the arrow body, `Expr::This` reads slot `auto_captures.len()` of the closure's captures, gets back 0.0, then `this.value` calls `js_object_get_field_by_name(0, ...)` which dereferences address 0 → SIGSEGV. The same v0.5.x bug shape was already fixed for object-literal methods in `lower_object_literal` (expr.rs:9161-9228) via a "lower closure → save (val, slot) → after build, patch slot with NaN-boxed object pointer" pattern. **Fix**: mirror that pattern in `apply_field_initializers_recursive`. New arm in the per-field loop: when init_expr is `Expr::Closure { captures_this: true, .. }`, lower the closure to a NaN-boxed pointer, read the current `this` from `ctx.this_stack.last()`, call `js_closure_set_capture_f64(closure_handle, this_idx=auto_caps.len(), this_val)`, then emit the `js_object_set_field_by_name(this_raw, key_raw, closure_val)` write directly (bypassing PropertySet — going through it would re-lower the closure expression and produce a fresh, unpatched closure pointer). The `auto_caps.len()` is computed via the existing `crate::type_analysis::compute_auto_captures(ctx, cparams, cbody, ccaps)` which the closure-build site itself uses, so both sides agree on the slot index. Non-closure (or closure without captures_this) inits keep the original PropertySet path. New regression test `test-files/test_issue_263_arrow_field_this.ts` (4 cases: `Foo` with `value=99 + arrowField=() => this.value` matching the issue's exact repro, `arrowWithArg(n) => this.value + n` to verify multi-param plumbing, and a `Counter` class with two instances `a` and `b` each calling `inc = () => { this.count++; return this.count; }` — verifies per-instance `this` capture, since each `new Counter()` runs its own field-init pass and patches its own closure copy). All 4 outputs match `node --experimental-strip-types` byte-for-byte: `foo.value: 99 / typeof arrow: function / arrowField(): 99 / arrowWithArg(1): 100 / a.count: 3 / b.count: 1`. Verified: cargo build --release clean; cargo test --release -p perry-runtime --lib 173/0; gap tests 25/28 = baseline. The pre-existing test_issue_154_using_dispose SIGBUS is unrelated to this change (reproduces on clean main HEAD pre-fix).
152153
- **v0.5.376** — Docs-staleness sweep: the inverse audit of [#189](https://github.com/PerryTS/perry/issues/189) revealed that 6 closed wiring issues still had "Status: not yet wired" callouts in the docs after their codegen work shipped. Updated 7 pages: `plugins/{overview,creating-plugins,hooks-and-events}.md` (#189 closed — `PERRY_PLUGIN_TABLE` + `PERRY_PLUGIN_INSTANCE_TABLE` in `lower_call.rs:3390-3464` dispatch all 21 runtime FFIs), `ui/canvas.md` + `ui/overview.md` (#190 closed — Canvas + 13 instance methods in `perry-dispatch:577-640`, platform table flipped from "not yet exposed" → "Wired" on macOS / iOS / Linux / Android), `ui/overview.md` (#191 + #192 closed — CameraView + Table dispatch rows in `perry-dispatch:538` + `:561`), `system/notifications.md` (#98 closed — Android FCM wired via JNI through `PerryFirebaseMessagingService`), `stdlib/fs.md` (#193 partially closed — `rmRecursive` wired through `js_fs_rm_recursive` at `expr.rs:8485`, fixed wrong issue cross-reference from #198 → #193, flipped fence from `text` to `typescript`). Filed [#278](https://github.com/PerryTS/perry/issues/278) as the focused follow-up for the 3 holdouts from #193 that did NOT actually ship: `ethers.Wallet.createRandom`, perry/system `getLocale`, and `getAppIcon` — all three still hit the receiver-less early-out at `lower_call.rs:2849-2854` and silently return `undefined` from TS. The `crypto.md:33` Wallet.createRandom callout is left as-is because that gap is real. Filed [#276](https://github.com/PerryTS/perry/issues/276) for the genuinely-still-unwired global hotkey TS surface (runtime FFI exists on every platform but no dispatcher row, no TS declaration) and [#277](https://github.com/PerryTS/perry/issues/277) for JSX/TSX `_jsx`/`_jsxs` runtime symbols (parser+HIR lower correctly but link fails — needs weak runtime stubs). Snippet fence-conversion (`text` → `typescript` `{{#include}}` extracts) for plugins / canvas tracked as separate follow-up — needs end-to-end doc-tests examples that build + load a stub plugin or attach a canvas to a run loop.
153154
- **v0.5.375** — CI release-unblocker followup for v0.5.374. The v0.5.374 release-packages.yml gate timed out at 45 min while Tests was still running; both Tests runs ultimately failed parity-job's "new failures" check on `test_gap_typed_arrays` — a categorical TypedArray gap that's documented in CLAUDE.md's TypeScript-Parity-Status header (`typed_arrays (categorical gap)`) but was missing from `test-parity/known_failures.json`. Added entry with `status:known_limitation`, mirroring the existing `test_gap_array_methods` entry's framing. v0.5.374's other fixes (bswap intrinsics + Windows Shell imports + test_edge_promises triage) all worked — compile-smoke ✓, gtk4 doc-tests ✓, Windows compile reached doc-tests stage. v0.5.375 is the actual shipping release.
154155
- **v0.5.374** — CI release-unblocker for v0.5.373. Three independent fixes surfaced by the failed `Tests` workflow on the v0.5.373 tag SHA: (1) **bswap intrinsics not declared** (parity-job + compile-smoke leg). `crates/perry-codegen/src/lower_call.rs::lower_buffer_numeric_read/write` emits `call iN @llvm.bswap.iN(iN ...)` (size-keyed lookup at line 168), but the intrinsics were never declared in the LLVM IR module's prelude. Apple Clang ≥21 (Xcode 26 — local devs) auto-recognises bswap intrinsics even when undeclared, but Apple Clang 15 (LLVM 17 — macos-14 GitHub runner via Xcode 15.x) errors with `error: use of undefined value '@llvm.bswap.i16'`. Same root cause + same fix shape as #241's `@llvm.assume`. Surfaced when v0.5.373's compile-smoke ran the un-skipped Buffer family after #241's `known_failures.json` cleanup — the bswap-using tests (`test_buffer_numeric_read_intrinsic`, `test_gap_node_crypto_buffer`, `test_inline_uint8array_param`, `test_issue_167_loop_alloca_stack_eat`) all hit the same undefined-intrinsic error. Three-line fix: 3 `module.declare_function("llvm.bswap.iN", IN, &[IN])` calls in `setup_runtime_function_declarations` + new `pub const I16` in `crates/perry-codegen/src/types.rs`. (2) **Windows compile broken** by v0.5.347 (#210 — wire 4 of 5 styling stubs). `crates/perry-ui-windows/src/widgets/mod.rs` imported `windows::Win32::UI::Controls::SetWindowSubclass` and `::DefSubclassProc`, but in windows-rs 0.58 both functions live under `Win32::UI::Shell` (verified via `~/.cargo/registry/src/index.crates.io-*/windows-0.58.0/src/Windows/Win32/UI/Shell/mod.rs:242,4538`). The `Win32_UI_Shell` feature is already declared in `Cargo.toml`, so the fix is a one-line module-path correction. macOS hosts skip perry-ui-windows compile entirely and never caught the regression locally. (3) **`test_edge_promises` regression** from v0.5.371's async-to-generator transform. Two distinct symptoms in the test's diff: `if (cond) return await Promise.resolve('yes'); else return await Promise.resolve('no');` returns '0'/'0' instead of 'yes'/'no'; and `await Promise.resolve(42)` deep in a chain returns 0 instead of 42. The conditional-await-return shape isn't covered by the v0.5.371 transform's conservative-scope guards. Added to `test-parity/known_failures.json` as `status:bug` referencing the v0.5.371 follow-up (same bug class as the existing `test_stress_promises` entry). v0.5.373 release stays public but un-published (release-packages.yml gate failed); v0.5.374 is the actual shipping release. Gap tests 25/28 = baseline.

Cargo.lock

Lines changed: 29 additions & 29 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ opt-level = "s" # Optimize for size in stdlib
113113
opt-level = 3
114114

115115
[workspace.package]
116-
version = "0.5.376"
116+
version = "0.5.377"
117117
edition = "2021"
118118
license = "MIT"
119119
repository = "https://github.com/PerryTS/perry"

crates/perry-codegen/src/lower_call.rs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2477,7 +2477,78 @@ fn apply_field_initializers_recursive(
24772477
// resolves against the correct class.
24782478
ctx.class_stack.push(class_name_in_chain.clone());
24792479
for (prop, init_expr) in init_pairs {
2480-
// Build a PropertySet { this, prop, init_expr } and lower.
2480+
// Issue #263: arrow-function class fields like
2481+
// `arrowField = () => this.value` need their reserved `this`
2482+
// capture slot patched with the constructor's `this` AFTER
2483+
// the closure is built — same pattern `lower_object_literal`
2484+
// already uses for object-literal methods. Without this, the
2485+
// arrow's body reads slot `auto_captures.len()` of the
2486+
// closure's capture array (initialized to 0.0 by the
2487+
// closure-build site at expr.rs:3294-3304), then `this.value`
2488+
// dereferences address 0 and SIGSEGVs.
2489+
if let Expr::Closure {
2490+
params: cparams,
2491+
body: cbody,
2492+
captures: ccaps,
2493+
captures_this: true,
2494+
..
2495+
} = &init_expr {
2496+
let auto_caps = crate::type_analysis::compute_auto_captures(ctx, cparams, cbody, ccaps);
2497+
let this_idx = auto_caps.len() as u32;
2498+
2499+
// Lower the closure expression to a NaN-boxed pointer.
2500+
let closure_val = lower_expr(ctx, &init_expr)?;
2501+
2502+
// Read the current `this` from the constructor's this_stack.
2503+
let this_val = if let Some(slot) = ctx.this_stack.last().cloned() {
2504+
ctx.block().load(DOUBLE, &slot)
2505+
} else {
2506+
double_literal(0.0)
2507+
};
2508+
2509+
// Patch the closure's reserved this-slot in-place, then
2510+
// store the closure as the field via the runtime FFI.
2511+
let blk = ctx.block();
2512+
let bits = blk.bitcast_double_to_i64(&closure_val);
2513+
let closure_handle = blk.and(I64, &bits, POINTER_MASK_I64);
2514+
let idx_str = this_idx.to_string();
2515+
blk.call_void(
2516+
"js_closure_set_capture_f64",
2517+
&[
2518+
(I64, &closure_handle),
2519+
(I32, &idx_str),
2520+
(DOUBLE, &this_val),
2521+
],
2522+
);
2523+
2524+
// Now store the patched closure as the field. Emit the
2525+
// property-write call directly, mirroring PropertySet's
2526+
// codegen path (expr.rs:2559+) — we can't go through
2527+
// `lower_expr` again because that would re-lower the
2528+
// closure expression and produce a fresh, unpatched
2529+
// closure pointer.
2530+
let key_idx = ctx.strings.intern(&prop);
2531+
let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global);
2532+
let blk = ctx.block();
2533+
let key_box = blk.load(DOUBLE, &key_handle_global);
2534+
let key_bits = blk.bitcast_double_to_i64(&key_box);
2535+
let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64);
2536+
let this_bits = blk.bitcast_double_to_i64(&this_val);
2537+
let this_raw = blk.and(I64, &this_bits, POINTER_MASK_I64);
2538+
blk.call_void(
2539+
"js_object_set_field_by_name",
2540+
&[
2541+
(I64, &this_raw),
2542+
(I64, &key_raw),
2543+
(DOUBLE, &closure_val),
2544+
],
2545+
);
2546+
continue;
2547+
}
2548+
2549+
// Non-closure (or non-this-capturing closure) initializer:
2550+
// build a PropertySet { this, prop, init_expr } and lower
2551+
// through the existing path.
24812552
let set_expr = Expr::PropertySet {
24822553
object: Box::new(Expr::This),
24832554
property: prop,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Issue #263 — arrow function stored as a class field crashes (SIGSEGV) when
2+
// the body reads `this`.
3+
//
4+
// Pre-fix: arrow-function class field initializers were hoisted into the
5+
// constructor via `apply_field_initializers_recursive`, but the closure's
6+
// reserved `this` capture slot (index `auto_captures.len()`) was never
7+
// patched with the constructor's `this`. The slot stayed at 0.0 (the
8+
// initial sentinel `Expr::Closure` codegen writes), and any `this.x` read
9+
// inside the arrow body dereferenced address 0 → SIGSEGV.
10+
//
11+
// Post-fix: `apply_field_initializers_recursive` now mirrors the same
12+
// patch-after-build pattern `lower_object_literal` uses for object-literal
13+
// methods — when an init expression is `Expr::Closure { captures_this:
14+
// true, .. }`, lower the closure, patch its reserved this-slot with the
15+
// current `this`, then store the closure as the field. The arrow's body
16+
// now reads the real instance pointer.
17+
18+
class Foo {
19+
public value = 99;
20+
readonly arrowField = () => this.value;
21+
readonly arrowWithArg = (n: number) => this.value + n;
22+
}
23+
24+
const foo = new Foo();
25+
console.log("foo.value:", foo.value);
26+
console.log("typeof arrow:", typeof foo.arrowField);
27+
console.log("arrowField():", foo.arrowField());
28+
console.log("arrowWithArg(1):", foo.arrowWithArg(1));
29+
30+
// Two instances must each see their own `this` — the closure's capture slot
31+
// is per-instance because each `new Foo()` runs its own field-init pass.
32+
class Counter {
33+
count = 0;
34+
inc = () => { this.count++; return this.count; };
35+
}
36+
const a = new Counter();
37+
const b = new Counter();
38+
a.inc(); a.inc(); a.inc();
39+
b.inc();
40+
console.log("a.count:", a.count);
41+
console.log("b.count:", b.count);

0 commit comments

Comments
 (0)