Skip to content

[gc] Date/Temporal cells are non-movable and permanently pin blocks/pages — give them a movable representation #6186

Closed

Description

@proggeramlug

Problem

GC_TYPE_DATE_CELL and GC_TYPE_TEMPORAL are movable: false (gc/types.rs:462-503), as are large Buffers/TypedArrays (gc/types.rs:357-386). Consequences:

  • A single long-lived new Date() (16-byte cell) in a nursery block prevents that block from ever being evacuated — the block stays committed for the Date's lifetime (~1 MB pinned per scattered cell).
  • In old-gen, one non-movable object on a 4 KB page makes the whole page ineligible for defrag (gc/oldgen.rs:131-133,142-145, skip at :1553), and one per 1 MB block prevents whole-block reclaim.
  • An ORM-style app caching a Date per record sprays non-movable cells across every allocation-active block; compaction becomes structurally impossible for those blocks regardless of policy tuning.

Proposed fix

Date/Temporal cells are pointer-free — they are the cheapest possible objects to move (memcpy + forwarding, no interior rewriting). Mark them movable and let the existing forwarding machinery handle them. Alternatively, segregate non-movable types into dedicated blocks/pages so they can't contaminate movable space (also covers large buffers).

Context

2026-07-09 GC audit (allocator/fragmentation dimension). Related: the evacuation-policy metric fix (releasable block bytes instead of candidate object bytes) is being implemented separately; this issue covers the type-level movability.

Activity

  1. proggeramlug commented on Jul 9, 2026

    @proggeramlug
    ContributorAuthor

    Blocking analysis (from the 2026-07-09 fix campaign): this is NOT a one-flag change, and flipping movable: false → true would introduce a UAF.

    gc/types.rs marks GC_TYPE_DATE_CELL/GC_TYPE_TEMPORAL non-movable deliberately, and the in-code comment says why: a Date is referenced by a NaN-boxed pointer kept in a plain f64/DOUBLE local that codegen does not shadow-root. Its liveness rests entirely on the conservative stack scan pinning the found pointer — and keeping the address stable is what stops that un-rooted local from going stale. In production the conservative scan resolves to SkipDisabled, so even the pinning is mostly gone; stability is the only thing protecting these locals.

    Consequence: making Date/Temporal movable would let a copying minor evacuate a Date whose only reference is an un-shadow-rooted f64 local → the local dangles into freed from-space → UAF. That is precisely the bug class this audit is eliminating.

    Two real fixes, both substantial (hence this stays an issue, not campaign work):

    1. Shadow-root Date/Temporal pointer locals in codegen so they survive + get rewritten across a move — then the movable flag can flip. Large codegen change touching every Date-producing lowering path.
    2. Segregate non-movable arena types into a dedicated region so a single Date can't pin a block full of otherwise-movable survivors. This does NOT make Date movable (keeps the stability guarantee) but recovers the fragmentation win the issue is really about. Requires a separate non-movable arena sub-region with its own bump path + trigger accounting.

    Option 2 is the lower-risk path and delivers the low-RAM fragmentation benefit without touching the stale-pointer guarantee. Recommend scoping (2) first.

  2. proggeramlug commented on Jul 9, 2026

    @proggeramlug
    ContributorAuthor

    Verified unsafe as a flag flip — exact UAF path (traced end-to-end during the fix campaign):

    The safety of movability reduces to one question: can a moving collection run while a Date lives in an un-shadow-rooted f64 stack local? Moving and conservative-scan are mutually exclusive by construction — gc/copying.rs:773 makes the copied-minor path ineligible whenever conservative_stack_scan_decision() == Scan. So:

    • Automatic alloc-point collections force the conservative scan (ManualGcScanGuard::force_full_scan, gc/policy.rs:1242/1290) → non-moving, and the stack Date is CONS_PINNED (per-cycle, independent of the movable flag) → safe today and would stay safe.
    • Pump-boundary safepoint moving minor runs with precise roots, stack unwound (gc/policy.rs:1464 "roots are precise at this safepoint") → no un-rooted Date stack locals exist → safe.
    • Explicit gc() / public gc_collect_minor() (gc/mod.rs:71) does NOT force the conservative scan and, with production conservative_stack_scan_decision()==SkipDisabled, is eligible to MOVE. This is the exact watch-item the audit flagged ("runtime-internal callers can get a moving minor against un-re-derived cached pointers").

    ⇒ let d = new Date(); gc(); d.getTime(); — during gc(), d is a bare non-shadow-rooted f64 local. If Date were movable, the copied minor evacuates the cell and d dangles into recycled from-space → UAF on d.getTime().

    Prerequisites before the movable flag can flip (either one):

    1. Force the conservative scan on the explicit-gc()/internal gc_collect_minor() path so stack Dates are pinned there too. This is also a correctness fix for the flagged moving-vs-cached-pointer hazard — but it makes explicit gc() non-moving (no compaction on explicit gc), a behavior change worth its own decision.
    2. Shadow-root Date/Temporal pointer locals in codegen (every Date-producing lowering) so the cell is a rewritable root — large codegen change.

    Note on the "route Dates to old-arena" alternative: it preserves stability (Dates stay non-movable, never pin a nursery block) but immediately tenures every new Date(), so a Date-churning loop fills old-gen with dead cells until an OldReclaim instead of dying at the next nursery reset — a memory regression for that pattern. Not a clean win either.

    Bottom line: this is prerequisite GC/codegen architecture (option 1 or 2), not a Date-file change. Leaving open with this analysis rather than shipping the UAF.

  3. proggeramlug commented on Jul 9, 2026

    @proggeramlug
    ContributorAuthor

    Correction / supersession: my earlier 'verified unsafe as a flag flip' comment was based on an incomplete moving-path analysis. A complete enumeration (in #6214) shows it IS safe: the movable flag only gates OLD-PAGE DEFRAG — the nursery copied-minor already evacuates eden objects (incl. Dates) regardless of it — and old-page defrag runs only inside moving collections (the precise pump-boundary safepoint / opt-in back-edges, both stack-unwound). globalThis.gc() is conservative + non-moving; the raw moving gc_collect_minor() is test-only. So no production moving path runs with an un-shadow-rooted Date f64 local live. Safe by the same invariant that already makes movable Promises sound. Implemented + smoke-verified in #6214 (Date; Temporal left non-movable as a separate change).

  4. proggeramlug commented on Jul 10, 2026

    @proggeramlug
    ContributorAuthor

    Complete: Date shipped movable in #6214; Temporal shipped movable in #6243 (same safety enumeration — movable gates only old-page defrag at stack-unwound safepoints; the copied-minor already relocates both; embedded temporal_rs survives memcpy; bulk resets skip finalizers so TemporalCleanup fires exactly once; ExoticExpandoOwner rekey mirrors Date). Large-buffer segregation from the original proposal was superseded by #6190 (buffers are old-arena GC objects, born tenured).

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