Skip to content

Commit a7bea50

Browse files
committed
fix(hir): #154 using/await using dispose hooks (v0.5.317)
Lowers `using x = expr` / `await using x = expr` into nested try/finally chains that invoke `[Symbol.dispose]()` (sync) or `await [Symbol.asyncDispose]()` (async) in reverse declaration order on block exit. Computed-key class methods `[Symbol.dispose]` and `[Symbol.asyncDispose]` are renamed to stable `__perry_dispose__` / `__perry_async_dispose__` names so the desugarer can dispatch via plain class-method-call. Module-level classes work end-to-end; class-in-function with enclosing-fn-local capture is dropped silently (separate codegen gap, filed as #212). Closes #154.
1 parent a34ca55 commit a7bea50

6 files changed

Lines changed: 330 additions & 49 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.316
11+
**Current Version:** 0.5.317
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.317** — Closes #154: `using` / `await using` (ES2024 explicit resource management). Pre-fix, Perry parsed the binding form but lowered it as a plain `const` — `[Symbol.dispose]()` / `[Symbol.asyncDispose]()` hooks never ran, and the `Symbol.dispose` / `Symbol.asyncDispose` accessors weren't recognized as well-known symbols. Three pieces: (1) `symbol_well_known_key` (`crates/perry-hir/src/lower_decl.rs`) and the `Symbol.<name>` member-expression lowerer (`crates/perry-hir/src/lower.rs:9264`) extended to recognize `dispose` / `asyncDispose` — same `@@__perry_wk_<name>` SymbolFor pattern as the existing `iterator` / `toPrimitive` family. (2) `lower_class_method` renames computed-key methods `[Symbol.dispose]` → `__perry_dispose__` and `[Symbol.asyncDispose]` → `__perry_async_dispose__` — stable string-keyed names so the using-block desugarer can dispatch via plain method-call. (3) New `lower_stmts_using_aware` helper in `lower_decl.rs` rewires `lower_block_stmt` / `lower_block_stmt_scoped`: when a using-decl is found, lowers its bindings via the existing `lower_var_decl_with_destructuring` (so `Type::Named("Resource")` flows through and static class-method dispatch fires), then recursively lowers the remaining statements as a try-body wrapped in N nested `Stmt::Try { catch: None, finally: ... }` — one per binding, innermost-first — so disposal runs in reverse declaration order. Each finally checks `id !== null && id !== undefined` before calling the dispose method (per spec). For `await using`, the call is wrapped in `Expr::Await`. Class-method-captures-enclosing-fn-local has its own pre-existing codegen gap (filed as #212): the dispose method is silently dropped when `ctx.scope_depth > 0` AND the body references locals not in `own_locals` — preserves the pre-fix "test compiles, produces no disposed output" baseline for `test_gap_async_advanced.ts`'s class-in-async-fn pattern instead of newly breaking compile. Module-level classes (the canonical pattern) work end-to-end. New regression test `test-files/test_issue_154_using_dispose.ts` covers sync `using`, `await using`, multi-binding (`using a = e1, b = e2, c = e3` — rightmost disposes first), null-skip, byte-for-byte against `node --experimental-strip-types`. SuppressedError chaining (when body throws and a disposer also throws) is out of scope for v1 — Perry's existing `try { ... } finally { ... }` (no catch) doesn't currently re-propagate exceptions to outer catches anyway, so even spec-compliant disposer-error wrapping would behave the same.
152153
- **v0.5.316** — Closes #167: silent SIGSEGV in tight `buf.readInt32BE(i*4)` / `buf.writeInt32BE(...)` loops past ~250k–300k iterations on macOS arm64 (8 MB stack). Two `alloca [N x double]` sites in `crates/perry-codegen/src/lower_call.rs` (the `js_native_call_method` dispatch at line 1755 + the class-dispatch fallback at line 1203) emitted into whatever basic block `ctx.block()` / `blk` currently represented. When the call site lived inside a loop, that's the loop body — and LLVM lowers a non-entry-block alloca as a runtime `sub %rsp, N` with no matching `add %rsp, N`, so every iteration permanently shrank the stack by 16 bytes (the args-array size, AArch64 16-byte-aligned). At ~250k–500k iterations the stack was exhausted → SIGSEGV with no error output (the crash happens after a flushed `console.log`, exit code propagates as 0 through shell pipes — easy to miss). Math from the issue's investigation: write loop (2-arg call) + read loop (1-arg call) × 16 bytes/iter × 250k each ≈ 8 MB → survives barely; 300k → SIGSEGV. Fix: new `LlFunction::alloca_entry_array(elem_ty, count)` helper in `crates/perry-codegen/src/function.rs` (4 LOC, mirrors the existing scalar `alloca_entry`); both call sites now hoist the args-array alloca to the function entry block where it's executed exactly once at function prologue. Verified end-to-end on the issue's repro: pre-fix N=300k crashed silently after "fill ok"; post-fix N=100k / 300k / 500k / 1M / 2M all complete cleanly with correct sums. New regression test `test-files/test_issue_167_loop_alloca_stack_eat.ts` runs N=500k (decisive pre-fix failure, <100 ms post-fix). No runtime change — pure codegen IR-emission fix that affects every dynamic method-call site routing through `js_native_call_method` (Buffer / Uint8Array numeric ops, Map / Set methods on plain object fields, any user-class method call where the receiver class id misses the static dispatch tower).
153154
- **v0.5.315** — Closes #106 properly: `--target watchos-simulator --features watchos-game-loop` now links cleanly even when no native lib is configured. Pre-fix the runtime-only path failed with `Undefined symbols: _perry_register_native_classes / _perry_scene_will_connect` because `crates/perry-runtime/src/watchos_game_loop.rs` declared both as `extern "C"` imports and called them unconditionally from `main()` and the fallback `applicationDidFinishLaunching` — but no object in the runtime's own .a archive exported them, so the linker had nothing to resolve against. The original v0.5.114 commit (4b297092) shipped the contract assuming Bloom-style native libs would always be linked alongside; users who tried the issue's literal acceptance test with no native lib hit the wall and the issue stayed open. Fix: add weak no-op fallbacks via `core::arch::global_asm!` at the top of `watchos_game_loop.rs` — `.weak_definition _perry_register_native_classes` + `.weak_definition _perry_scene_will_connect`, both single-`ret` arm64 stubs (no params read since they take c_void). Mach-O resolution rule: weak symbol + strong symbol → strong wins, so any native lib's strong impls override these defaults at link time. With no native lib, the .app bundle now produces correctly (`/tmp/WatchOSGameLoopTest.app/WatchOSGameLoopTest` — 795 KB Mach-O arm64, exports `__perry_user_main` + the two weak stubs); add Bloom and the FFI hooks become live without touching anything else. Also, one-line UX cleanup at `crates/perry/src/commands/compile.rs:8602` — added `!is_watchos` to the strip-skip condition (the watchOS bundle code at line 8330 moves `exe_path` into the .app and removes the original, so the post-link `strip exe_path` was always emitting a noisy `can't open file` error after the success-path "Wrote watchOS app bundle" message). iOS has the same extern-without-fallback shape but isn't fixed here — separate scope; iOS users hitting the acceptance test always plumb in a native lib (Bloom/etc.), and a follow-up can mirror this pattern at `ios_game_loop.rs` if anyone reports the same UX gap there.
154155
- **v0.5.314** — Closes #169: SIGTRAP when mixing Buffer + Uint8Array-typed function params. `substitute_locals` in `crates/perry-transform/src/inline.rs` had no arms for `Uint8ArrayGet`/`Uint8ArraySet`/`Uint8ArrayLength`/`Uint8ArrayNew(Some)` — they fell through to `_ => {}` at line 1795, so inlining a function taking a Uint8Array param left a stale `LocalGet(param_id)` in the body. Codegen's soft fallback boxed the unknown id as `TAG_UNDEFINED` (`expr.rs:702-720`), unbox-pointer reduced it to address 1, `safe_load_i32_from_ptr` returned len=0, and the slow-path bounds check fired `@llvm.assume(i1 false)` — UB → trap. Repro from issue prints `param sum: 1207680` then traps with exit 133 in `firstBytes(big)`. Fix: 4 arms in `substitute_locals` after the existing `BufferIndexGet`/`BufferIndexSet` arms (line 1761). Also mirrored the same arms in `find_max_local_id::check_expr` (line 604, latent ID-collision bug — `find_max_local_id` was undercounting fresh-id high-water mark when LocalGet was nested inside Uint8ArrayGet, which would have surfaced as collision-induced miscompiles in larger programs) and `inline_calls_in_expr` (line 953, missed-optimization — Calls nested inside `buf[clamp(i)]`-style index expressions weren't being inlined). New regression test `test-files/test_inline_uint8array_param.ts` covers all four shapes (Get / Length / Set / Buffer→Uint8Array param mix) plus the original issue's reverse ordering, compares byte-for-byte against `node --experimental-strip-types`. No runtime or codegen changes — pure HIR transform pass fix.

Cargo.lock

Lines changed: 27 additions & 27 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
@@ -109,7 +109,7 @@ opt-level = "s" # Optimize for size in stdlib
109109
opt-level = 3
110110

111111
[workspace.package]
112-
version = "0.5.316"
112+
version = "0.5.317"
113113
edition = "2021"
114114
license = "MIT"
115115
repository = "https://github.com/PerryTS/perry"

crates/perry-hir/src/lower.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9252,11 +9252,11 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result<
92529252

92539253
// Check if this is Symbol.<well-known> — Symbol.toPrimitive,
92549254
// Symbol.hasInstance, Symbol.toStringTag, Symbol.iterator,
9255-
// Symbol.asyncIterator. Lowered to `SymbolFor(String("@@__perry_wk_<name>"))`
9256-
// which the runtime's `js_symbol_for` sniffs via prefix and
9257-
// resolves from the well-known cache (not the registry). This
9258-
// gives each well-known symbol a stable pointer without needing
9259-
// a new HIR variant.
9255+
// Symbol.asyncIterator, Symbol.dispose, Symbol.asyncDispose.
9256+
// Lowered to `SymbolFor(String("@@__perry_wk_<name>"))` which the
9257+
// runtime's `js_symbol_for` sniffs via prefix and resolves from
9258+
// the well-known cache (not the registry). Gives each well-known
9259+
// symbol a stable pointer without needing a new HIR variant.
92609260
if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() {
92619261
if obj_ident.sym.as_ref() == "Symbol" {
92629262
if let ast::MemberProp::Ident(prop_ident) = &member.prop {
@@ -9268,6 +9268,8 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result<
92689268
| "toStringTag"
92699269
| "iterator"
92709270
| "asyncIterator"
9271+
| "dispose"
9272+
| "asyncDispose"
92719273
) {
92729274
return Ok(Expr::SymbolFor(Box::new(Expr::String(
92739275
format!("@@__perry_wk_{}", prop_name),

0 commit comments

Comments
 (0)