Skip to content

fix(runtime): #748 follow-up — Invalid Date is now typeof "object" / instanceof Date#816

Merged
proggeramlug merged 1 commit into
mainfrom
fix-748-invalid-date
May 16, 2026
Merged

fix(runtime): #748 follow-up — Invalid Date is now typeof "object" / instanceof Date#816
proggeramlug merged 1 commit into
mainfrom
fix-748-invalid-date

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Follow-up to v0.5.912's #748 condvar fix. The condvar wait is correct,
but a separate Date-representation bug in the runtime kept the
shop-admin signup symptom alive whenever an await chain produced an
Invalid Date — see the issue comment
for the full bisect.

What was wrong

Perry stores Date as a raw f64 timestamp with no NaN-box tag and
consults a thread-local DATE_REGISTRY: HashSet<u64> from typeof /
instanceof Date / JSON. js_date_new_from_value skipped registration
when result.is_nan(), so new Date(NaN) (and new Date("nope"), and
the zero-date branch of @perryts/mysql's MyDateTime.toDate())
escaped as a bare untagged NaN. Both instanceof arms in object.rs
also gated on !is_nan() && is_finite() before consulting the
registry, so even a hypothetically-registered NaN couldn't match.

Net: ECMA-262 §21.4.1.1 says new Date(NaN) is still a Date — typeof "object", instanceof Date true, time value NaN — and perry got all
three wrong. The six string formatters also lacked NaN guards and cast
NaN as i64 → 0, producing the 1970-01-01T00:00:00.000Z <garbage>
strings the issue reports.

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 pre-fix: "number"; node: "object"

What this PR does

Single canonical Invalid-Date sentinel DATE_NAN_BITS = 0x7FF8_0000_0000_0DA7 — a quiet NaN in the 0x7FF8 space JSValue::is_number
already treats as a plain number (not a NaN-box tag), so it flows
through arithmetic and every 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.

  • is_registered_date_bits short-circuits bits == DATE_NAN_BITS
    before the existing HashSet lookup.
  • js_date_new_from_timestamp / js_date_new_from_value route NaN
    through date_or_invalid → the sentinel.
  • Both js_instanceof arms (CLASS_ID_DATE, CLASS_ID_OBJECT) match
    the sentinel before their !is_nan() guard.
  • The six js_date_to_*_string formatters early-return "Invalid Date"
    for NaN inputs.
  • typeof is already correct — js_value_typeof's f64 fallthrough
    calls is_registered_date_bits(bits) unconditionally, no NaN gate.

The finite-Date registry path is byte-identical to before. The
reverted attempt described in the issue comment regressed Date.UTC(…)
valid dates by also touching finite registration; we deliberately do
not. Options 1 and 2-second-half from the comment (a dedicated NaN-box
tag for Date, or eliminating the value-keyed registry altogether) stay
out of scope — those address the broader 100 instanceof Date
false-positive and cross-thread loss cases and need a workspace-wide
refactor.

Validation

test-files/test_issue_748_invalid_date.ts — compiled and run against
the patched runtime:

w5 typeof: object
typeof inv: object
inv instanceof Date: true
inv instanceof Object: true
inv.getTime() isNaN: true
String(inv): Invalid Date
inv.toDateString(): Invalid Date
JSON.stringify(inv): null
JSON.stringify({d:inv}): {"d":null}
invStr typeof: object             ← new Date("not a date") also recognized
valid dtToIso: 2026-05-15T07:47:04.192Z
zero  dtToIso: null
v typeof: object                  ← Date.UTC(2026, 4, 15, …) regression block
v instanceof Date: true
v.getUTCFullYear(): 2026
v.toISOString(): 2026-05-15T17:29:35.402Z
JSON.stringify(v): "2026-05-15T17:29:35.402Z"
plain number typeof: number       ← no false positives
Date.now() typeof: number         ← Date.now() returns a number per spec
(new Date()) instanceof Date: true

Files: crates/perry-runtime/src/date.rs, crates/perry-runtime/src/object.rs,
plus version bump (Cargo.toml, Cargo.lock, CLAUDE.md) and the
CHANGELOG.md entry. ~50 lines of net runtime code change.

Closes the #748 follow-up.

…instanceof Date

`new Date(NaN)` (and the zero-date branch of `@perryts/mysql`'s
`MyDateTime.toDate()`) escaped the runtime as an untagged bare NaN —
`typeof` reported `"number"`, `instanceof Date` returned `false`, and the
string formatters cast NaN-as-i64 to 0 and emitted bogus `1970-01-01…`
strings. That reproduced the v0.5.912 #748 symptom (route promise
resolving to `0.0`) on shop-admin signup whenever any await chain
returned an Invalid Date.

Fix: a single canonical sentinel `DATE_NAN_BITS = 0x7FF8_0000_0000_0DA7`
(quiet NaN in the 0x7FF8 space `JSValue::is_number` already treats as a
plain number, so arithmetic and existing `is_nan()` getter guards keep
working). Recognized by exact bit pattern globally — no thread-local
registration step needed, so cross-thread Invalid Dates (mysql row
decode off the socket thread) work for free. `is_registered_date_bits`
short-circuits on the sentinel before consulting the existing HashSet;
`js_date_new_from_timestamp` / `_from_value` route NaN through
`date_or_invalid`; both `js_instanceof` arms (CLASS_ID_DATE,
CLASS_ID_OBJECT) match the sentinel before their `!is_nan()` guard. The
six string formatters early-return `"Invalid Date"` for NaN inputs.

The finite-Date registry path is byte-identical to before — the
reverted attempt in the issue comment regressed `Date.UTC(...)` valid
dates by also touching finite registration; we deliberately don't.

Closes #748 follow-up.
@proggeramlug proggeramlug merged commit dc6be08 into main May 16, 2026
9 checks passed
@proggeramlug proggeramlug deleted the fix-748-invalid-date branch May 16, 2026 03:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant