diff --git a/CHANGELOG.md b/CHANGELOG.md index df4b7138..e0bed74b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 `` 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 `` 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": }` 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. diff --git a/CLAUDE.md b/CLAUDE.md index 56074e1b..159d38f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 14947136..5e59cf2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,7 +4790,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "base64", @@ -4845,14 +4845,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.912" +version = "0.5.913" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "log", @@ -4865,7 +4865,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "perry-hir", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "perry-hir", @@ -4882,7 +4882,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "perry-dispatch", @@ -4892,7 +4892,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "perry-hir", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "base64", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "perry-hir", @@ -4922,7 +4922,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.912" +version = "0.5.913" dependencies = [ "serde", "serde_json", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.912" +version = "0.5.913" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "clap", @@ -4956,7 +4956,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.912" +version = "0.5.913" dependencies = [ "argon2", "perry-ffi", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", "reqwest", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.912" +version = "0.5.913" dependencies = [ "bcrypt", "perry-ffi", @@ -4981,7 +4981,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", "rusqlite", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", "scraper", @@ -4997,14 +4997,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.912" +version = "0.5.913" dependencies = [ "chrono", "cron", @@ -5013,7 +5013,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.912" +version = "0.5.913" dependencies = [ "chrono", "perry-ffi", @@ -5021,7 +5021,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", "rust_decimal", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", "serde_json", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5045,21 +5045,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.912" +version = "0.5.913" dependencies = [ "bytes", "http-body-util", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.912" +version = "0.5.913" dependencies = [ "lazy_static", "perry-ffi", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.912" +version = "0.5.913" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.912" +version = "0.5.913" dependencies = [ "bytes", "http-body-util", @@ -5115,7 +5115,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.912" +version = "0.5.913" dependencies = [ "lazy_static", "perry-ffi", @@ -5125,7 +5125,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.912" +version = "0.5.913" dependencies = [ "base64", "jsonwebtoken", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.912" +version = "0.5.913" dependencies = [ "lru", "perry-ffi", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.912" +version = "0.5.913" dependencies = [ "chrono", "perry-ffi", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.912" +version = "0.5.913" dependencies = [ "bson", "futures-util", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.912" +version = "0.5.913" dependencies = [ "chrono", "perry-ffi", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.912" +version = "0.5.913" dependencies = [ "nanoid", "perry-ffi", @@ -5183,7 +5183,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", "rustls", @@ -5194,7 +5194,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.912" +version = "0.5.913" dependencies = [ "lettre", "perry-ffi", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", "sqlx", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.912" +version = "0.5.913" dependencies = [ "governor", "perry-ffi", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.912" +version = "0.5.913" dependencies = [ "base64", "image", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.912" +version = "0.5.913" dependencies = [ "lazy_static", "perry-ffi", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", "uuid", @@ -5253,7 +5253,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.912" +version = "0.5.913" dependencies = [ "perry-ffi", "regex", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.912" +version = "0.5.913" dependencies = [ "futures-util", "lazy_static", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.912" +version = "0.5.913" dependencies = [ "flate2", "perry-ffi", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.912" +version = "0.5.913" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "perry-api-manifest", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "deno_core", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "perry-diagnostics", @@ -5337,7 +5337,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "base64", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.912" +version = "0.5.913" dependencies = [ "aes", "aes-gcm", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "perry-hir", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.912" +version = "0.5.913" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5447,11 +5447,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.912" +version = "0.5.913" [[package]] name = "perry-ui-android" -version = "0.5.912" +version = "0.5.913" dependencies = [ "itoa", "jni", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.912" +version = "0.5.913" dependencies = [ "rand 0.8.6", "serde", @@ -5476,7 +5476,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.912" +version = "0.5.913" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5495,7 +5495,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.912" +version = "0.5.913" dependencies = [ "block2", "libc", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.912" +version = "0.5.913" dependencies = [ "block2", "libc", @@ -5528,11 +5528,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.912" +version = "0.5.913" [[package]] name = "perry-ui-tvos" -version = "0.5.912" +version = "0.5.913" dependencies = [ "block2", "libc", @@ -5547,7 +5547,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.912" +version = "0.5.913" dependencies = [ "block2", "libc", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.912" +version = "0.5.913" dependencies = [ "block2", "libc", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.912" +version = "0.5.913" dependencies = [ "libc", "perry-runtime", @@ -5589,7 +5589,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.912" +version = "0.5.913" dependencies = [ "base64", "ed25519-dalek", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.912" +version = "0.5.913" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 6d01454c..5153b329 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -190,7 +190,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.912" +version = "0.5.913" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index edab02ec..0181648c 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -19,6 +19,61 @@ thread_local! { static DATE_REGISTRY: RefCell> = RefCell::new(HashSet::new()); } +/// Canonical "Invalid Date" bit pattern. +/// +/// An *Invalid Date* (`new Date(NaN)`, `new Date("nope")`, the zero-date +/// branch of `@perryts/mysql`'s `MyDateTime.toDate()`, …) is still a Date +/// object per ECMA-262 §21.4.1.1 — `typeof` must be `"object"` and +/// `instanceof Date` must be `true`, even though its time value is NaN. +/// +/// Perry stores Date as a raw f64 with no tag and tracks finite Dates in +/// the thread-local `DATE_REGISTRY`. A NaN can't go in that value-keyed +/// set: NaN never compares equal, the bit pattern isn't stable, and the +/// set is thread-local so a Date minted on a socket/worker thread (mysql +/// row decode) wouldn't be seen on the main thread anyway. So Invalid +/// Date gets a single canonical sentinel recognized *by bit pattern*, +/// globally, with no registration step — it works across threads for +/// free because it is a constant, not a tracked value. +/// +/// The pattern is a quiet NaN (exponent all ones, mantissa MSB set so it +/// stays quiet per IEEE-754 §6.2.1 and arithmetic propagates instead of +/// trapping). It lives in the 0x7FF8 space, which `JSValue::is_number` +/// treats as a plain number rather than a NaN-box tag, so the value +/// flows through arithmetic and the existing `if timestamp.is_nan()` +/// guards in every Date getter exactly like a bare NaN — only `typeof` / +/// `instanceof` / dynamic dispatch get to see that it is really a Date. +/// The low payload `0x0DA7` just distinguishes it from the FPU's +/// canonical `0x7FF8_0000_0000_0000`. +pub const DATE_NAN_BITS: u64 = 0x7FF8_0000_0000_0DA7; + +/// The canonical Invalid Date value. +#[inline] +pub fn date_invalid() -> f64 { + f64::from_bits(DATE_NAN_BITS) +} + +/// Map any NaN result to the canonical Invalid-Date sentinel; pass +/// finite values through untouched. Used by the `new Date(...)` +/// constructors so an invalid time value is still a recognizable Date +/// object instead of a bare, type-less NaN. +#[inline] +fn date_or_invalid(v: f64) -> f64 { + if v.is_nan() { + date_invalid() + } else { + v + } +} + +/// The string every Date string-method returns when the time value is +/// NaN. Matches `String(new Date(NaN))` / `new Date(NaN).toDateString()` +/// etc. Without this, the formatters cast NaN to `0i64` and emit a bogus +/// `1970-01-01…` string (the `` reported in issue #748). +fn invalid_date_string() -> *mut crate::StringHeader { + let s = "Invalid Date"; + crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32) +} + /// Mark `bits` as a Date so future `instanceof Date` checks can recognize it. pub fn register_date_bits(bits: u64) { DATE_REGISTRY.with(|r| { @@ -26,9 +81,11 @@ pub fn register_date_bits(bits: u64) { }); } -/// True when `bits` were previously registered as a Date. +/// True when `bits` are a Date: either the canonical Invalid-Date +/// sentinel (recognized globally, no registration) or a finite timestamp +/// previously registered by `new Date(...)` / `Date.now()` / a Date method. pub fn is_registered_date_bits(bits: u64) -> bool { - DATE_REGISTRY.with(|r| r.borrow().contains(&bits)) + bits == DATE_NAN_BITS || DATE_REGISTRY.with(|r| r.borrow().contains(&bits)) } /// Convert a UTC timestamp (seconds) to local-time components. @@ -118,11 +175,15 @@ pub extern "C" fn js_date_new() -> f64 { v } -/// Create a new Date from a timestamp (milliseconds since epoch) +/// Create a new Date from a timestamp (milliseconds since epoch). +/// A NaN timestamp produces a recognizable Invalid Date, not a bare NaN. #[no_mangle] pub extern "C" fn js_date_new_from_timestamp(timestamp: f64) -> f64 { - register_date_bits(timestamp.to_bits()); - timestamp + let result = date_or_invalid(timestamp); + if !result.is_nan() { + register_date_bits(result.to_bits()); + } + result } /// Create a new Date from a value that could be a number or a NaN-boxed string. @@ -153,6 +214,7 @@ pub extern "C" fn js_date_new_from_value(value: f64) -> f64 { // Numeric timestamp value }; + let result = date_or_invalid(result); if !result.is_nan() { register_date_bits(result.to_bits()); } @@ -294,6 +356,9 @@ pub extern "C" fn js_date_get_time(timestamp: f64) -> f64 { /// Returns a pointer to a StringHeader #[no_mangle] pub extern "C" fn js_date_to_iso_string(timestamp: f64) -> *mut crate::StringHeader { + if timestamp.is_nan() { + return invalid_date_string(); + } let ts_ms = timestamp as i64; let secs = ts_ms / 1000; let millis = (ts_ms % 1000).unsigned_abs() as u32; @@ -696,6 +761,9 @@ const MONTH_NAMES: [&str; 12] = [ /// date.toDateString() — e.g. "Mon Jan 15 2024" (local time). #[no_mangle] pub extern "C" fn js_date_to_date_string(timestamp: f64) -> *mut crate::StringHeader { + if timestamp.is_nan() { + return invalid_date_string(); + } let ts_ms = timestamp as i64; let secs = ts_ms.div_euclid(1000); let (year, month, day, _, _, _, tz_offset) = timestamp_to_local_components(secs); @@ -713,6 +781,9 @@ pub extern "C" fn js_date_to_date_string(timestamp: f64) -> *mut crate::StringHe /// date.toTimeString() — e.g. "12:30:45 GMT+0100 (local)" (local time). #[no_mangle] pub extern "C" fn js_date_to_time_string(timestamp: f64) -> *mut crate::StringHeader { + if timestamp.is_nan() { + return invalid_date_string(); + } let ts_ms = timestamp as i64; let secs = ts_ms.div_euclid(1000); let (_, _, _, hour, minute, second, tz_offset) = timestamp_to_local_components(secs); @@ -730,6 +801,9 @@ pub extern "C" fn js_date_to_time_string(timestamp: f64) -> *mut crate::StringHe /// date.toLocaleDateString() — simple en-US-style date (local time). #[no_mangle] pub extern "C" fn js_date_to_locale_date_string(timestamp: f64) -> *mut crate::StringHeader { + if timestamp.is_nan() { + return invalid_date_string(); + } let ts_ms = timestamp as i64; let secs = ts_ms.div_euclid(1000); let (year, month, day, _, _, _, _) = timestamp_to_local_components(secs); @@ -740,6 +814,9 @@ pub extern "C" fn js_date_to_locale_date_string(timestamp: f64) -> *mut crate::S /// date.toLocaleTimeString() — simple H:MM:SS AM/PM en-US style (local time). #[no_mangle] pub extern "C" fn js_date_to_locale_time_string(timestamp: f64) -> *mut crate::StringHeader { + if timestamp.is_nan() { + return invalid_date_string(); + } let ts_ms = timestamp as i64; let secs = ts_ms.div_euclid(1000); let (_, _, _, hour, minute, second, _) = timestamp_to_local_components(secs); @@ -814,6 +891,9 @@ pub extern "C" fn js_number_to_locale_string(n: f64) -> *mut crate::StringHeader /// date.toLocaleString() — combined date and time (local time). #[no_mangle] pub extern "C" fn js_date_to_locale_string(timestamp: f64) -> *mut crate::StringHeader { + if timestamp.is_nan() { + return invalid_date_string(); + } let ts_ms = timestamp as i64; let secs = ts_ms.div_euclid(1000); let (year, month, day, hour, minute, second, _) = timestamp_to_local_components(secs); diff --git a/crates/perry-runtime/src/object.rs b/crates/perry-runtime/src/object.rs index a2fba547..5c3acd89 100644 --- a/crates/perry-runtime/src/object.rs +++ b/crates/perry-runtime/src/object.rs @@ -5048,9 +5048,14 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { // number would match (the prior "approximate" rule), which made // `100 instanceof Date` true and broke the BSON encoder's typed // dispatch (`if (value instanceof Date) … else if (typeof v === 'number') …`). - if !value.is_nan() - && value.is_finite() - && crate::date::is_registered_date_bits(value.to_bits()) + // + // The Invalid-Date sentinel is itself a NaN, so it must be matched + // *before* the `!is_nan()` guard — `new Date(NaN) instanceof Date` + // is `true` per ECMA-262 even though its time value is NaN. + if value.to_bits() == crate::date::DATE_NAN_BITS + || (!value.is_nan() + && value.is_finite() + && crate::date::is_registered_date_bits(value.to_bits())) { return true_val; } @@ -5095,9 +5100,11 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { if jsval.is_pointer() { return true_val; } - if !value.is_nan() - && value.is_finite() - && crate::date::is_registered_date_bits(value.to_bits()) + // Invalid Date is still an Object (NaN time value, but a Date). + if value.to_bits() == crate::date::DATE_NAN_BITS + || (!value.is_nan() + && value.is_finite() + && crate::date::is_registered_date_bits(value.to_bits())) { return true_val; } diff --git a/test-files/test_issue_748_invalid_date.ts b/test-files/test_issue_748_invalid_date.ts new file mode 100644 index 00000000..5c7b800a --- /dev/null +++ b/test-files/test_issue_748_invalid_date.ts @@ -0,0 +1,61 @@ +// Issue #748 — `new Date(NaN)` / `: Date` object-method must be a Date +// object, not a bare number. Mirrors the comment's minimal repro plus +// regression coverage for valid dates. + +// --- the comment's deterministic minimal repro --- +function w5(y: number) { + return { + toDate(): Date { + return new Date(NaN); + }, + }; +} +console.log("w5 typeof:", typeof w5(2026).toDate()); // expect: object + +// --- core Invalid Date identity --- +const inv = new Date(NaN); +console.log("typeof inv:", typeof inv); // object +console.log("inv instanceof Date:", inv instanceof Date); // true +console.log("inv instanceof Object:", inv instanceof Object); // true +console.log("inv.getTime() isNaN:", Number.isNaN(inv.getTime())); // true +console.log("inv.getFullYear() isNaN:", Number.isNaN(inv.getFullYear())); // true +console.log("String(inv):", String(inv.toISOString())); // Invalid Date +console.log("inv.toDateString():", inv.toDateString()); // Invalid Date +console.log("JSON.stringify(inv):", JSON.stringify(inv)); // null +console.log("JSON.stringify({d:inv}):", JSON.stringify({ d: inv })); // {"d":null} + +// --- invalid via string / Date.UTC(NaN) --- +const invStr = new Date("not a date"); +console.log("invStr typeof:", typeof invStr); // object +console.log("invStr instanceof Date:", invStr instanceof Date); // true + +// --- the @perryts/mysql MyDateTime.toDate() shape (no mysql) --- +function makeMyDateTime(y: number, mo: number, d: number) { + return { + toDate(): Date { + if (y === 0 && mo === 0 && d === 0) return new Date(NaN); + return new Date(Date.UTC(y, mo - 1, d, 7, 47, 4, 192)); + }, + }; +} +function dtToIso(v: { toDate(): Date }): string | null { + const dt = v.toDate(); + const t = dt.getTime(); + return Number.isNaN(t) ? null : new Date(t).toISOString(); +} +console.log("valid dtToIso:", dtToIso(makeMyDateTime(2026, 5, 15))); +console.log("zero dtToIso:", dtToIso(makeMyDateTime(0, 0, 0))); // null + +// --- regression: valid Dates must be completely unaffected --- +const v = new Date(Date.UTC(2026, 4, 15, 17, 29, 35, 402)); +console.log("v typeof:", typeof v); // object +console.log("v instanceof Date:", v instanceof Date); // true +console.log("v instanceof Object:", v instanceof Object); // true +console.log("v.getFullYear():", v.getUTCFullYear()); // 2026 +console.log("v.getTime():", v.getTime()); // 1778880575402 +console.log("v.toISOString():", v.toISOString()); // 2026-05-15T17:29:35.402Z +console.log("JSON.stringify(v):", JSON.stringify(v)); // "2026-05-15T17:29:35.402Z" +const n = 1778880575402; // plain number equal to v's millis must stay a number +console.log("plain number typeof:", typeof n); // number +console.log("Date.now() typeof:", typeof Date.now()); // number +console.log("(new Date()) instanceof Date:", new Date() instanceof Date); // true