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.914 — fix: #809 — object literal with computed-key props + methods + cross-module spread now compiles correctly; `Object.create(proto)` actually wires the prototype. **Symptom (reported as #809).** The Effect end-to-end repro (#321) advanced past `ParseResult.ts` and threw `TypeError: value is not a function` in `effect/src/HashRing.ts__init`. The 20-line standalone repro (`Proto = { [TypeId]: TypeId, [Symbol.iterator]() {...}, pipe() {...}, ...Inspectable.BaseProto, toJSON() {...} }` then `Object.create(Proto)`) printed `keys: 2` (should be 4 — the issue's "5" predates checking against node, which excludes the `Symbol.iterator` key from `Object.keys`), `inst.toJSON()` → `"1970-01-01T00:00:00.000Z"`, `inst[TypeId]` → `undefined`. **Root cause — four independent bugs in the chain, all required for the repro to pass:** (1) **Object-literal `has_spread` lowering dropped entries.** `crates/perry-hir/src/lower/expr_object.rs`'s spread path lowered the literal to `Expr::ObjectSpread { parts }`, whose `parts` can only carry static-string `KeyValue` + spreads. `Prop::Method` fell into `_ => {}` and computed `KeyValue` keys into `_ => continue` — so `[Symbol.iterator]()`, `pipe()`, the `[TypeId]` computed string key, and the trailing `toJSON()` were all silently discarded, leaving only the spread (hence `keys: 2` = `BaseProto`'s two keys). (2) **Over-eager `DateToJSON` lowering.** `inst.toJSON()` where `inst`'s static class is unknown was rewritten to `Expr::DateToJSON(inst)`; `js_date_to_json` then read the object pointer's NaN-box bits as a millisecond timestamp → epoch string. (3) **`js_object_create` ignored its argument.** It allocated a bare empty object and dropped `proto` entirely (`_proto_value`), so `Object.create(P)` had no prototype link — `inst.x` / `inst.m()` saw nothing. (4) **Two prototype-walk gaps exposed once (3) was fixed:** the codegen PIC fast path for `obj.prop` spuriously "hit" when `obj->keys_array == null` (an `Object.create` result) because the zero-initialized per-site cache matched, returning the empty slot[0] without ever invoking the runtime miss handler; and `js_object_get_field_by_name` early-returned `undefined` on `keys_array == null` *before* its `class_id` prototype-walk, while `js_native_call_method`'s prototype-object walk was gated inside `if let Some(ref reg) = *CLASS_VTABLE_REGISTRY` (skipped entirely for a program with no user classes). **Fix.** (1) Rewrote the `has_spread` path as a fully **source-ordered IIFE** (`((__o) => { …ordered ops…; return __o })({})`) so static props, computed keys, `this`-binding methods, and spreads interleave in source order (a later prop/spread overrides an earlier same key, matching JS — the non-spread fast paths can't be used because they apply all static props before any post-init, which would let a trailing `...src` clobber the literal's own `toJSON()`). Spreads emit `js_object_assign_one(__o, src)`; static `this`-methods a new `js_object_set_method_by_name` runtime helper (string-key analog of `js_object_set_symbol_method` — sets the field AND patches the closure's reserved last `this` capture slot); computed `this`-methods reuse `js_object_set_symbol_method`; everything else `IndexSet`. Key-resolution and method-lowering were extracted into shared `resolve_keyvalue_key` / `lower_method_prop` helpers used by both the spread and non-spread paths (behavior-identical for the non-spread path). (2) `static_receiver_class` now returns `Some("Object")` for object-literal / `Object.create(...)` receivers and for locals tracked in a new `LoweringContext.plain_object_locals` set (populated in `lower_var_decl_with_destructuring` when the initializer is an object literal or `Object.create(...)`); the ambiguous-Date-method gate treats `Some("Object")` like `Some("URL")` and skips the Date arms. (3) `js_object_create` now allocates a synthetic class_id, maps it to `proto` in `CLASS_PROTOTYPE_OBJECTS`, and stamps the new object with it (reusing the #711 prototype-object machinery); `Object.create(null)` / non-object / builtin-backed sources fall back to the original prototype-less object. (4) Added a non-null `keys_array` requirement to the PIC hit predicate so keyless receivers fall to the slow path; `js_object_get_field_by_name`'s `keys_array == null` branch now consults the prototype chain before returning undefined (via a new shared `resolve_proto_chain_field` helper, which also de-duplicates the existing inline #711 walk); and `js_native_call_method` runs an independent `resolve_proto_chain_field` resolution after the registry walk so `Object.create(objLiteral).method()` works even when `CLASS_VTABLE_REGISTRY` is `None`. **Validation.** The exact #809 repro (`entry.ts` / `insp.ts`) is now byte-identical to `node --experimental-strip-types`: `keys: 4`, `inst.toJSON: {"_id":"HashRing","x":1}`, `inst[TypeId]: ~test/HashRing`. Gap suite 34/36 (the 2 failures — `test_gap_console_methods`, `test_gap_regexp_advanced` — are the pre-existing known categorical gaps, console.dir/group + lookbehind regex, unchanged). All `cargo test` for perry-hir / perry-codegen / perry-runtime green (0 failed). 12-file curated smoke over spread/object/prototype test-files (incl. `test_issue_711_function_prototype`, `test_spread`, `test_edge_rest_spread_defaults`, `test_get_prototype_of_instance`) byte-identical to node. Targeted regression run confirms real `new Date(0).toJSON()`/`toISOString()` still work, spread override ordering (`{a:1,...{a:2}}`→`{a:2}`, `{...{a:2},a:1}`→`{a:1}`), `Object.create(null)`, empty-object missing-prop, and `Object.create(proto).method()` with `this`-binding all match node. **Known remaining gap (pre-existing, not a regression, out of #809 scope).** Symbol-keyed property *inheritance* through an `Object.create` prototype chain (`o[sym]` where `sym` is on the proto) still returns `undefined` — the symbol side-table is keyed by the receiver pointer and the prototype walk only resolves string keys. Not exercised by #809's DoD (`Object.keys` excludes symbols; `inst[TypeId]` is a string key) and `Object.create` returned nothing before this commit, so symbol inheritance through it never worked. Tracked separately. `Object.getPrototypeOf(Object.create(p)) === p` likewise still returns the class-ref shim, not `p`. Closes #809. Refs #321, #711.

## v0.5.913 — fix(runtime): #748 follow-up — `new Date(NaN)` / Invalid Date is now a recognizable Date object (`typeof === "object"`, `instanceof Date === true`) instead of an untagged bare NaN that `typeof` reported as `"number"`. **Symptom (per #748 comment).** After the v0.5.912 condvar fix, the shop-admin signup still failed: `typeof new Date(NaN)` returned `"number"` under perry vs `"object"` under tsx/node, so `MyDateTime.toDate(): Date { … if (zero) return new Date(NaN); … }` from `@perryts/mysql` produced an untagged bare NaN that downstream `dtToIso` / dynamic `.getTime()` dispatched as a number method → `getTime is not a function` → fastify route promise resolved to the same `0.0` symptom as the original #748. The deterministic 3-line repro from the comment: `function w5(y: number) { return { toDate(): Date { return new Date(NaN); } }; } console.log(typeof w5(2026).toDate());` — perry: `"number"`; node: `"object"`. **Root cause.** Perry stores `Date` as a raw f64 timestamp with no NaN-box tag and consults a thread-local `DATE_REGISTRY: HashSet<u64>` of registered bit patterns from `typeof` / `instanceof Date` / JSON / promise side-table dispatch. `js_date_new_from_value` skipped registration entirely when `result.is_nan()` (`crates/perry-runtime/src/date.rs:156` pre-fix), so `new Date(NaN)` flowed out as a bare unregistered NaN — and the two `instanceof` sites in `crates/perry-runtime/src/object.rs` (`CLASS_ID_DATE` line 5051, `CLASS_ID_OBJECT` line 5098) both gated their registry lookup behind `!value.is_nan() && value.is_finite()` so even if the bits *were* registered, Invalid Date couldn't possibly match. Net: ECMA-262 §21.4.1.1 says `new Date(NaN)` is an Invalid Date — `typeof` "object", `instanceof Date` true, time value NaN — and perry got all three wrong. The seven string formatters (`js_date_to_iso_string`, `js_date_to_date_string`, `js_date_to_time_string`, `js_date_to_locale_*_string`) also lacked NaN guards and cast NaN-as-i64 → `0`, emitting bogus `1970-01-01T00:00:00.000Z` (the `<garbage>` zero-branch output in the bug report). **Fix.** Single canonical Invalid-Date sentinel `DATE_NAN_BITS = 0x7FF8_0000_0000_0DA7` — a quiet NaN (exponent all ones, mantissa MSB set per IEEE-754 §6.2.1) in the 0x7FF8 space, which `JSValue::is_number` already treats as a plain number rather than a NaN-box tag, so the value flows through arithmetic and every existing `if timestamp.is_nan() { return f64::NAN }` getter exactly like a bare NaN. Recognition is by exact bit pattern, *globally*, so the sentinel works across the socket-thread / main-thread boundary without registration — no thread-local lookup required. (1) `is_registered_date_bits` short-circuits `bits == DATE_NAN_BITS` before consulting the existing HashSet; the finite-Date registry path is byte-identical to before, which is what diverges from the reverted attempt the issue comment describes — that earlier try also touched finite registration ("stop value-registering finite millis") and regressed `Date.UTC(...)` valid dates, so we deliberately do not touch the finite path. (2) `js_date_new_from_timestamp` / `js_date_new_from_value` now route NaN through `date_or_invalid` → the sentinel. (3) Both `js_instanceof` arms (`CLASS_ID_DATE`, `CLASS_ID_OBJECT`) match `value.to_bits() == DATE_NAN_BITS` *before* the `!is_nan()` guard so Invalid Date is still a Date and still an Object per spec. (4) `typeof` is already correct for free because `js_value_typeof`'s f64 fallthrough calls `is_registered_date_bits(bits)` unconditionally (no NaN gate at that site). (5) Each of the six string formatters now early-returns `"Invalid Date"` for NaN — matches `String(new Date(NaN))` / `.toDateString()` etc. and replaces the `1970-…` garbage. Promise assimilation (`promise.rs:2381`) and JSON `write_number` (`json.rs:1558`) are unchanged: both gate on `is_pointer()` / `is_finite()` so the sentinel falls through their early-return arms as a NaN, which is correct — `await new Date(NaN)` resolves with itself, `JSON.stringify(new Date(NaN))` serializes as `null` (Date.prototype.toJSON-of-invalid spec behavior, satisfied here by the existing NaN → "null" arm in `write_number`). Roughly 50 lines of net code change across two files (`crates/perry-runtime/src/{date.rs,object.rs}`). **Out of scope.** Options 1 and 2-second-half from the issue comment — tagging Dates with a dedicated NaN-box tag space, or eliminating the value-keyed thread-local registry altogether — are correct soundness fixes for the broader `100 instanceof Date` false-positive and the cross-thread loss cases, but require a workspace-wide refactor of every Date method signature, codegen call site, and runtime side-table. This patch closes the specific #748 follow-up (`new Date(NaN)` representation) and the `<garbage>` 1970 strings; the false-positive/cross-thread cases stay tracked separately. **Validation.** `test-files/test_issue_748_invalid_date.ts` mirrors the comment's minimal `w5` repro plus the `makeMyDateTime` / `dtToIso` shop-admin shape and a valid-Date regression block. Compiled + run against the patched runtime: `typeof w5(2026).toDate()` is `"object"` (was `"number"`), `new Date(NaN) instanceof Date` is `true`, `instanceof Object` is `true`, `.getTime()` is `NaN`, `String(invalid)` is `"Invalid Date"` (was the `1970-01-01…` garbage), `JSON.stringify(invalid)` is `"null"`, `dtToIso(makeMyDateTime(2026, 5, 15))` is `"2026-05-15T07:47:04.192Z"`, `dtToIso(makeMyDateTime(0,0,0))` is `null`. Regression block: `Date.UTC(2026,4,15,…)` round-trips through `getTime` / `toUTCFullYear` / `toISOString` / `JSON.stringify`, plain numbers still report `typeof "number"`, `Date.now()` still reports `typeof "number"` (it returns a number, not a Date — matches JS), `new Date()` still `instanceof Date`. Closes the #748 follow-up.

## v0.5.912 — fix(fastify): #748 — replace 1-second polling timeout in `wait_for_promise` with a condvar wait, and surface rejected promises as `HTTP 500` instead of body `0`. **Symptom (reported as #748).** A native-compiled Fastify route doing `await checkSignup(); await getUserByEmail(); await hashPassword(); await createUser(); await createAccount(); await addMember(); await issueSession(); …` against `@perryts/mysql` returned the wire byte `0x30` (literal ASCII `0`) with HTTP 200 instead of the explicit `return { accessToken, … }`. The first INSERT committed; every subsequent `await pool.exec(...)` silently no-op'd (no error, no log, no DB row). The `tsx` baseline returned the correct JSON with HTTP 201 against the same code + same DB. **Root cause.** Both `crates/perry-ext-fastify/src/server.rs::wait_for_promise()` and the in-tree mirror at `crates/perry-stdlib/src/fastify/server.rs::wait_for_promise()` polled `js_promise_state` 10000 × 100 µs (≈ 1 s) and then returned regardless of state. After return, `js_promise_value(ptr)` (initialized to `0.0` in `Promise::new` per `crates/perry-runtime/src/promise.rs`) gave Rust `f64 0.0` for any Pending or Rejected promise. `build_response_body` then serialized that as wire byte `0x30` with the default `status_code = 200` (the route's `reply.code(201)` never ran). The orphaned chain was also abandoned: once the dispatcher returned, microtasks stopped pumping, so every queued INSERT after the timeout silently no-op'd — matching the "first INSERT commits, rest don't" symptom. The bug only surfaced once the await chain exceeded the 1 s budget, which is why the user's argon2 + JWT + multiple mysql writes hit it but minimal Fastify probes (object/string/number returns, two sequential `await delay()`s) didn't. **Fix.** (1) Replace the polling/sleep loop with a condvar-based wait via `js_wait_for_event` — the same primitive the codegen-emitted `await` body already uses. No fixed iteration limit; wakes the moment any stdlib worker calls `js_notify_main_thread` (which `js_promise_resolve` / `reject` already do). Drives timer ticks + stdlib pump + microtasks every iteration so chained timers / mysql / fetch awaits all keep moving. (2) After `wait_for_promise` returns, branch on `js_promise_state`: state == 2 (Rejected) now produces an HTTP 500 response with a `{ "error": <reason> }` JSON envelope (via new `render_rejection_body`) instead of letting `js_promise_value` return `0.0` and masquerade as a successful response. **Validation.** `/tmp/repro748_fastify.ts` — Fastify handler with 8 × 300 ms sequential awaits (2.4 s total chain). Pre-fix would return HTTP 200 body `0`; post-fix returns the full JSON body + HTTP 201 after ≈ 2.46 s. Existing `test_fastify_integration.ts` still passes; 10 fastify-ext unit tests + 22 fastify-stdlib unit tests all pass. Gap suite unchanged (33 pass / 2 pre-existing failures). **Caveat.** Could not exactly reproduce the user's "first INSERT commits, rest silently no-op" symptom in a synthetic repro without `@perryts/mysql` linkage — the 1 s timeout path is the only place `js_promise_value(0.0_initial)` reaches the response writer and matches every observable symptom (wire byte `0x30`, HTTP 200, dropped explicit return, abandoned subsequent awaits). Worth re-running skelpo-shop-admin against this build to confirm; if it still repros, `PERRY_MT_PROFILE=1` will narrow the stall point. Closes #748.
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.913
**Current Version:** 0.5.914


## TypeScript Parity Status
Expand Down
Loading