Skip to content

Date is a value type — setter mutations don't propagate through aliasing/function/closure boundaries (breaks effect DateTime.add) #2089

Closed

Description

@proggeramlug

Summary

Perry models Date as a value type (a raw NaN-boxed f64 timestamp), but a JavaScript Date is a mutable heap object passed by reference. Mutating a Date through any aliasing boundary — a function parameter, a closure parameter, or even a plain const b = a — does not propagate back, because each binding holds an independent copy of the f64.

This surfaced while bringing up effect (#321): DateTime.add(d, { days: 1 }) is a no-op, because effect implements it as mutate(self, (date) => { date.setUTCDate(date.getUTCDate() + parts.days) }) — mutating a Date inside a closure and reading it back. DateTime.addDuration works because it uses pure arithmetic (mapEpochMillis).

Minimal repro (no effect)

const a = new Date(1704067200000);
const b = a;                         // alias — same object in JS
b.setUTCDate(b.getUTCDate() + 1);
console.log(a.getTime() - 1704067200000); // Node: 86400000   Perry: 0   ✗
console.log(b.getTime() - 1704067200000); // Node: 86400000   Perry: 86400000

b sees its own mutation; a does not — they are not the same object. Same failure when the mutation happens through a function/closure parameter (the mutate(self, f) shape). A Date created as a top-level const and mutated via a param happens to work by accident (the inliner rewrites the setter writeback against the caller's local); a function-local Date passed to a mutating fn/closure fails.

Mechanism

  • Representation — crates/perry-runtime/src/date.rs (js_date_new*): returns the f64 timestamp itself and records its bits in a thread-local DATE_REGISTRY (for instanceof). There is no heap cell.
  • Setters — crates/perry-runtime/src/date.rs (js_date_set_*(timestamp, value) -> new_timestamp): each returns a fresh f64; nothing is mutated in place.
  • Writeback lowering — crates/perry-hir/src/lower/expr_call/url_date_instance.rs: a LocalGet(id) receiver wraps the setter as LocalSet(id, setter_call), which only updates the local in the current function; through a call/closure boundary the receiver is the callee's own copy, so the new timestamp never reaches the caller's variable.

Fix direction

Promote Date to a reference type — box the timestamp in a 1-slot mutable heap cell so all aliases/params share state. This is invasive (≈8 files, cross-crate, regression-prone): all ~20 getters/setters in date.rs, every site keyed on the f64 bit pattern (numeric coercion in builtins/arithmetic.rs, json/stringify.rs, object/instanceof.rs, object/util_types.rs, object/native_module_dispatch.rs, object/field_get_set.rs, promise/combinators.rs), the Date-producing codegen exprs currently typed DOUBLE (expr/arrays_finds.rs, expr/misc_methods.rs, expr/os_uri_dates.rs), and GC scanning for the new cell.

Acceptance

The repro above: a.getTime() reflects a mutation made through b / a function param / a closure. And effect's DateTime.add(d, { days: 1 }) adds 86400000 ms.

Refs #321.

Activity

  1. proggeramlug commented on May 27, 2026

    @proggeramlug
    ContributorAuthor

    Deeper analysis (second investigation) — this is an architectural spike, not a single-pass fix

    Confirmed the bug + that the rest of the Date matrix is byte-perfect today on the value-type representation (typeof, instanceof, +date, getTime, date - date, date < date2, toISOString, JSON.stringify, Date.parse, Invalid Date). So a reference-type change risks regressing many working paths to fix the aliasing one.

    Two hard sub-problems gate a regression-free landing:

    1. Relational comparison has no coercion hook. crates/perry-codegen/src/expr/compare.rs:370 lowers date1 < date2 / > / <= / >= as inline LLVM fcmp directly on operand values. Today operands ARE the timestamps so it works; a NaN-boxed pointer Date would make fcmp unordered → silently false. Operands are usually any-typed → can't statically detect Date here. Fix needs either a runtime relational helper on the Any path (perf regression for all numeric </>) or accepts breakage. (Arithmetic IS hookable via js_number_coerce's is_pointer() branch, builtins/numbers.rs:304 — only comparison is the gap.)
    2. Cell lifetime / GC + the is_pointer() consumer surface. A POINTER-tagged Date cell is seen by every is_pointer() consumer (typeof, equality, js_to_primitive, field-get, native dispatch, JSON, thread serialization, conservative stack scan). The box.rs registry-scanner keeps cells alive without shadow rooting but pins them forever → every Date leaks (bad for long-running servers). Leak-free needs GC-arena allocation + teaching codegen pointer/escape analysis (collectors/pointer_locals.rs, shadow_slots.rs) to treat Date-producing exprs as pointers.

    Recommended representation (keeps codegen churn minimal)

    Box the Date as a POINTER_TAG value that is itself an f64 pointing at a 1-slot DateCell { ts: f64 }. All codegen that types Date exprs as DOUBLE and passes (DOUBLE, &v) to getters/setters stays unchanged; getters deref, setters mutate in place, and the LocalSet writeback hack in lower/expr_call/url_date_instance.rs:350 is dropped. Implement as a spike behind problems (1) and (2); validate the full Date gap matrix + effect DateTime.add before expanding to all ~30 getters/setters; resolve the leak question (arena+rooting vs registry-pin) explicitly.

    Recommend tackling as a planned, phased effort rather than a single PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions