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.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.

## v0.5.911 — fix(jsruntime): #755 — register `fs/promises` and sibling Node-builtin subpath aliases. **Symptom.** A Colyseus demo compiled cleanly but failed at runtime with `[js_load_module] FAILED to load '.../colyseus/build/index.mjs': Cannot find module 'fs/promises' in node_modules`. **Root cause.** `crates/perry-jsruntime/src/modules.rs::is_node_builtin` was an exact-string `matches!` list that recognized `fs` but not Node's separately-exposed `fs/promises` subpath alias (nor `stream/promises`, `stream/consumers`, `dns/promises`, `timers/promises`, `readline/promises`, `util/types`, `assert/strict`, `process`). Specifiers that fell through hit `resolve_from_node_modules`, which then looked for a literal package directory named `fs/promises` and failed. Real packages (colyseus, but anything pulling in `fs/promises` directly) couldn't resolve the import. **Fix.** Added the missing specifiers — both bare and `node:`-prefixed forms — to `is_node_builtin`, plus matching real stubs in `get_builtin_stub` so the import site doesn't downstream-fail on `Cannot read properties of undefined` when consumers reach for e.g. `fsp.readFile`. **Validation.** Two new unit tests in `crates/perry-jsruntime/src/modules.rs::tests` — `test_is_node_builtin_promise_subpaths` enumerates every added specifier (bare + `node:`) and asserts recognition, `test_get_builtin_stub_promise_subpaths` asserts each new stub returns a real body (not the empty-fallback `export default {}`). `cargo test --release -p perry-jsruntime --lib modules::` green (13/0/0). Synthetic Perry-compiled `.ts` that `import`s the five promise subpaths resolves and exports the expected functions; gap suite unchanged (33 pass / 2 pre-existing failures). **Out of scope.** The secondary `Unexpected strict mode reserved word at express/lib/express.js:158:14` in #755's report is a separate CJS-wrap / strict-mode collision and is not addressed here. Closes #755.
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.912
**Current Version:** 0.5.913


## TypeScript Parity Status
Expand Down
Loading
Loading