Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Detailed changelog for Perry. See CLAUDE.md for concise summaries.

## v0.5.892 — fix(linker): two linker bugs reported on GitHub. (#732) **Windows: `--target windows` fails to link with `lld-link: error: undefined symbol: __declspec(dllimport) WinHttpOpen` (and seven sibling WinHttp* symbols)**, all referenced from `perry_ui_windows::widgets::image::fetch_url_blocking` — the function that fetches `Image(url, alt)` bytes from a background thread. Root cause: `perry-ui-windows` enables the `windows` crate's `Win32_Networking_WinHttp` feature, which emits `#[link(name = "winhttp")]` attrs in the rlib. Those attrs do NOT propagate through `perry-ui-windows`'s `staticlib` crate-type to perry's final link line — perry invokes `lld-link` / `cc` with its own hand-rolled Windows system-library list and `winhttp.lib` simply wasn't in that list. Fix: append `winhttp.lib` to the Windows arm of `crates/perry/src/commands/compile/link.rs`'s system-libs list, next to the existing `oleaut32.lib` / `iphlpapi.lib` entries. (#692) **Linux/macOS: `undefined reference to 'default'` at link time** — a literal symbol named `default` in the unresolved-extern list, no module prefix, no mangling. Reproducer is a single-file TS importing a default export from a module Perry can't resolve as native (`import sanitizeHtml from "sanitize-html"` when sanitize-html isn't in perry-stdlib / `compilePackages` / a perry.nativeLibrary). Root cause chain: (1) HIR's `register_imported_func` at `crates/perry-hir/src/lower.rs:3727` registers default-import bindings under the local name with the original-name sentinel `"default"`. (2) Identifier lookup in `lower.rs:7175` resolves the local to `Expr::ExternFuncRef { name: "default" }`. (3) Codegen's call path in `crates/perry-codegen/src/lower_call.rs:735+` falls through every special-case (setTimeout/jsx/perry-system/`js_*`-builtin) and the `import_function_prefixes` map (which only contains `NativeCompiled` imports from compile.rs:2814+, NOT V8-fallback `JsModule` imports — so `sanitize-html` has no entry). The fallthrough at line 848+ then emits a direct LLVM `call double @default(...)` and the system linker has no such symbol → `undefined reference to 'default'`. Fix: before the native-library fallthrough, detect `name == "default"` with no `import_function_prefixes` entry — the only producer of bare-`"default"` ExternFuncRefs is the default-import-from-unresolved-module path — and route to a new runtime stub `js_unresolved_default_call` (in `crates/perry-runtime/src/object.rs`) that returns NaN-boxed TAG_UNDEFINED and prints a one-shot diagnostic on first call. Args are lowered for side effects (string interning, closure collection) before the stub call so HIR/transform invariants don't break. Now the binary links; running prints `perry: called a default-imported binding from an unresolved module (returns undefined). The module's default export was not found in perry-stdlib or perry.compilePackages — run \`perry --print-api-manifest\` to see what's supported.` once, then `sanitizeHtml(x)` returns `undefined` rather than crashing the linker. **Validation.** Minimal repro `import sanitizeHtml from "sanitize-html"; console.log(sanitizeHtml("<b>hi</b>"));` pre-fix: `lld-link: undefined symbol: default` (Windows) / `ld: undefined reference to 'default'` (Linux); post-fix: links, runs, prints the diagnostic + `result: undefined`. `cargo build --release -p perry-runtime -p perry-codegen -p perry` clean. **Not addressed in this PR (#678).** `_perry_fn_..._render`-style unresolved symbols for V8-fallback modules referenced from native modules are a separate architectural fix: codegen needs to either route through `js_call_function` when the callee module is V8-fallback (requires threading the source-module ModuleKind to codegen) or emit weak stub symbols for V8-fallback module exports (requires threading the JsModule export surface). Either fix is more invasive than this PR's contained two changes; tracked separately. **Out of scope.** Plumbing the source-module path through `register_imported_func` so default-import calls to *V8-loaded* modules could route to `js_call_function` rather than the undefined stub — viable but requires changes to both HIR and the codegen ExternFuncRef machinery; current stub gracefully degrades for the unresolved case, which is the link-failure reproducer.

## v0.5.875 — fix(transform): `break` / `continue` inside loops containing `await` no longer hang or skip the update step. **Symptom.** `async function f() { for (let i = 0; i < 3; i++) { const x = await Promise.resolve(i); if (x === 2) break; count++; } return count; }` hung forever; same for `continue` inside the body of any for/while loop that contains an `await`. Surface area: every realistic real-world cancel-on-condition / skip-on-condition loop shape — `for (const item of items) { const result = await process(item); if (result.fatal) break; }`, `while (queue.length) { const job = await queue.shift(); if (job.skip) continue; await run(job); }`. Both shapes simply did not work in async functions until this commit. **Root cause.** `transform_async_to_generator`'s `linearize_body` splits a yielding for/while body into multiple states (init → cond → body-pre-yield → yield → body-post-yield → update → after). User `Stmt::Break` and `Stmt::Continue` inside that body were threaded through unchanged as raw HIR `Break`/`Continue` nodes. At codegen the state machine itself is a `while (true) { if (state==N) { ... } if (state==M) { ... } }` dispatch loop, so a raw `Stmt::Break` exits the dispatch — but the post-dispatch code then reads the scratch iter-result (last written by the in-flight await: `done=false`), returns `AsyncStepChain(stale_promise, step)`, the chain detects the resolved promise's value, queues another `Task::AsyncStep`, the step is re-entered, dispatch sees the state set by the await branch, runs the body again from the post-yield point, hits `break` again, and loops forever on a stale promise. Same shape with `Stmt::Continue`. **Fix.** Two coordinated additions in `linearize_body`'s For and While arms. (1) Before recursing into the body, pre-walk the body's top-level statements and rewrite `Stmt::Break` to `[LocalSet(state_id, BREAK_SENTINEL), Stmt::Continue]` and `Stmt::Continue` to `[LocalSet(state_id, CONTINUE_SENTINEL), Stmt::Continue]`. The walker recurses through If / Try-catch / Switch-cases / Labeled bodies but STOPS at nested loops and closure boundaries — their own break/continue belong to those constructs. (2) For for-loops specifically, restructure the post-body states: push a separate `tail_state` (containing the body's post-yield residual, exits to `update_state`) AND a separate `update_state` (containing ONLY the update expression, exits to `cond_state`). User `continue` jumps to `update_state` (the update-only state); body's natural fall-through goes tail_state → update_state. Without the split, the previous design conflated them and user `continue` would re-execute the residual, looping forever on the same iteration. For while-loops, `continue_target = cond_state` directly (no separate update). (3) After body linearization, walk the newly-created states (plus any trailing `current` residual) and replace `BREAK_SENTINEL` / `CONTINUE_SENTINEL` with the loop's real `after_loop_state` / `update_state` (for-loop) or `cond_state` (while-loop). Sentinel values are `1_000_001.0` / `1_000_002.0` — outside any legitimate state count for any realistic function. Inner loops are walked by their own recursive linearize_body call which fixes their sentinels first, so outer-loop fix-up only sees outer-loop sentinels. **Validation.** 6-case `/tmp/probe_brk_cont.ts` covers (t1) break in for-await, (t2) continue in for-await, (t3) break in for-loop with await body, (t4) continue in for-loop with await body, (t5) while-true with await + break, (t6) nested for-loops with break in inner only — all six byte-equal Bun. 7-case async-patterns suite still 7/7. 4-case throw-after-await suite still 4/4. 5-case catch-return suite still 5/5. 5-case for-await suite now 5/5 (previously 4/5; t4 hung pre-fix). Gap parity 26/28 unchanged. **Out of scope.** Labeled break/continue (`break outer;` / `continue inner;`) — Stmt::LabeledBreak / Stmt::LabeledContinue with custom labels. Real-world JS rarely uses labeled break/continue outside of nested-loop edge cases; the unlabeled form covers 99% of the surface area. Captured as follow-up. DoWhile with yield body is also out of scope — `do { await ...; } while (cond)` would need its own arm in `linearize_body` (currently falls through to the default which loses the loop semantics entirely; orthogonal to this commit). **Version-bump note.** Renumbered to v0.5.875 because 63fd3b7b (Ralph's ci-fix landing after my v0.5.873) claimed v0.5.874 in its commit message without bumping Cargo.toml — same pattern as cb496011 (claimed v0.5.871) and c6cfba98 (claimed v0.5.872). To stay ahead of the implicit collision I skip 874 entirely and ship as 875.

## v0.5.873 — fix(hir): `for await (const x of arr)` on plain-array iterables now awaits each element. **Symptom.** `async function f() { let s = 0; for await (const x of [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]) s += x; return s; }` returned `NaN` instead of `6` — Bun returns `6`. Each iteration bound `x` to the raw `Promise` object, and `s += <Promise>` produced `NaN`. The same shape covers any real-world pattern where you iterate an array of promises with `for await`, e.g. `for await (const result of taskPromises) { await persist(result); }`. **Root cause.** Perry has two ForOf lowering paths: an iterator-protocol path (for `for await ... of asyncGen()` / class-iterator receivers) that already wrapped the `__iter.next()` call in `Expr::Await`, AND a standard array-based desugar path (`for (let i = 0; i < __arr.length; ++i) { const x = __arr[i]; … }`) that ignored `for_of_stmt.is_await` entirely. SWC parses `for await` and sets the AST flag correctly; both lowering files (`crates/perry-hir/src/lower.rs` for module-init for-of and `crates/perry-hir/src/lower_decl.rs` for function-body for-of) just didn't read it in the array-desugar arm. Confirmed via temporary `eprintln!("for-of is_await={}", for_of_stmt.is_await);` at the binding-stmts construction site: the debug-print didn't fire from `lower.rs` because the function-body path goes through `lower_decl.rs` (the module-level path only handles module-init for-of). After applying the fix in both files, the simple `for await ... of [...promises]` shape unwraps correctly. **Fix.** At the binding-statements construction site in both files, when `for_of_stmt.is_await` is true, wrap the per-element `Expr::IndexGet { arr, idx }` access in `Expr::Await`. This is the spec-equivalent of "Await the value from the iterator" — for a plain-array iterable that means "Await arr[i]". A Promise element unwraps to its resolved value; a non-Promise value passes through unchanged (per the Await codegen's `js_value_is_promise` check + early-merge). Mirrors the iterator-protocol path that already wraps `__iter.next()` for `async function*` / class-iterator receivers. **Validation.** Single probe `for await (const x of [Promise.resolve(1), …]) sum += x` returns `6` byte-equal Bun. 5-case `/tmp/probe_for_await_full.ts` covers (t1) promise array sum, (t2) mixed promise+raw values, (t3) destructuring of resolved promise of object, (t5) plain numeric array (raw values, await passes through). 4/5 pass. (t4, `for await ... if (x === 2) break;`, hangs — separate pre-existing state-machine gap: `break` inside any loop containing `await` doesn't propagate through the state-machine state graph, verified via `for (let i ...) { await ...; if (...) break; }` which also hangs. Captured as follow-up; outside this commit's scope.) 7-case `/tmp/probe_async_patterns.ts` now 7/7 (was 6/7 with t2 failing). All earlier fixes preserved. Gap parity 26/28 unchanged. **Out of scope.** True async-iterator-protocol invocation (calling `[Symbol.asyncIterator]()` on the iterable, falling back to `[Symbol.iterator]()`) for non-array, non-async-generator iterables — this commit covers the plain-array case which is what real-world code uses; arbitrary user-defined async iterables remain a follow-up. **Version-bump note.** Renumbered to v0.5.873 because c6cfba98 (Ralph's chore-ci commit landing after my v0.5.872 fix) claimed v0.5.872 in its commit message without bumping Cargo.toml, leaving the workspace.package.version at 0.5.872 from my prior commit — this push goes straight to v0.5.873 to avoid further collisions.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

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.

**Current Version:** 0.5.891
**Current Version:** 0.5.892


## TypeScript Parity Status
Expand Down
Loading
Loading