Skip to content

feat(ts): translate a pointer's address as an id per live object - #596

Closed
kixelated wants to merge 17 commits into
mainfrom
quest/m2/ts-model/addresses
Closed

kixelated wants to merge 17 commits into
mainfrom
quest/m2/ts-model/addresses

Conversation

@kixelated

@kixelated kixelated commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

rust2-ts refused every read of a pointer's address (the 2026-10-28 decision against pointer-to-integer casts), so a crate keying a map by Arc::as_ptr(a) as usize could not translate. moq-net's model does this six times at the pin: Arc::as_ptr / Weak::as_ptr casts in model/cache.rs and model/frame.rs, and ptr::from_ref(self) as usize / self as *const Self as usize in the Drop of Track and FrameContent, which moq builds only as Arc::new(Self { .. }).

Decisions

  • ✅ 2026-11-04, with the maintainer: an address is an id unique among live objects, a multiple of the type's alignment, general to any crate, and anything without an exact answer is refused at translation with a span (rejected: a cfg(rust2_ts) twin in moq).
  • ✅ 2026-11-06, with the maintainer: addresses only of an Arc's or Rc's value (rejected: free at each type's Rust drop; keep freeing ids at JS GC). Ids belong to the rs.Arc allocation and are freed when its strong and weak counts reach 0, exactly as Rust deallocates. Reading the address of a type the program keeps anywhere else, and moving one out of its Arc, is refused. Freeing at GC was rejected because GC never runs within one synchronous job, so churning allocations could exhaust wasm32's 4 GiB where Rust reuses freed memory, a runtime failure Rust doesn't have. A counter that never reuses was rejected because usize is 32 bits on wasm32, so a long-running program would exhaust the address space where Rust reuses freed blocks.

Both are recorded in the ts-model epic's Plan, since this PR deletes the quest. The design change is recorded in this comment under Design change:.

Approach

  • rs.arcAt numbers a new Arc's or Rc's value as it is made, for each type whose address the program reads, as an allocator places the allocation: after its two counts, at a multiple of the value's alignment, in a block no other live allocation overlaps. rs.unaddress frees the block when the strong and weak counts reach 0. Numbering at creation keeps allocations whose lives overlap apart even when a pointer is read only after its allocation is freed.
  • rs.addr reads it: null is 0, a dangling pointer is at its type's alignment, Weak::new's is at usize::MAX. An exposed address casts to its destination integer type (as isize, as u8).
  • Address reads: p as usize, addr, expose_provenance, a raw pointer's Hash and Ord (rs.hashAddr, rs.cmpAddr), and ==, ptr::eq, PartialEq and hash-key equality (rs.sameAddr), so a pointer to a freed allocation equals one to an allocation that reuses its address, as in Rust. Weak::as_ptr translates (rs.weakAsPtr).
  • Refused with a span once translation settles: the address of a type the program keeps anywhere but as an Arc's or Rc's value (a local it borrows, drops, or gives std; a field, a Box, a collection; a static or constant; what JS is given or gives a root whole) or writes whole in place (as through get_mut); into_inner and make_mut of such a value (try_unwrap and unwrap_or_clone translate for no type); Weak::as_ptr of a value Arc::new_cyclic makes, as JS has no object until the closure returns; and pointers made of addresses (with_addr, map_addr, without_provenance, usize as *const T).

Census of moq-net's model at the pin, as the epic records: 73 → 66 refusals. All six address reads are gone; crossbeam-epoch's casts stay refused, rightly, and it was already refused for good.

Tests: lang::pointers gains addresses, run against native Rust: Arcs keyed by address in a B-tree, a hash map, and sets of raw pointers, removed in Drop by ptr::from_ref(self) and self as *const Self, checked for uniqueness and alignment among the live, reuse after free, order agreement between pointers and their addresses, == / ptr::eq / hash-set lookup / cmp of a freed pointer against a new one agreeing with whether the address was reused, two Arcs made and freed together and read only after, and Weaks keeping their address after the value drops. A mutation making sameAddr compare identity failed it with 94 differences. ts/tests/pointers.ts checks rs.arcAt reuse directly. reject gains addresses.rs for each refusal.

Impact

  • Generated code: p as usize, addr, expose_provenance, raw-pointer Hash/Ord/equality/ordering, and Weak::as_ptr now translate where exact; an Arc::new/Rc::new of an addressed type goes through rs.arcAt.
  • Runtime: new exports rs.addr, rs.addressAt (was private), rs.addrNew, rs.unaddress, rs.arcAt, rs.sameAddr, rs.hashAddr, rs.cmpAddr, rs.weakAsPtr.
  • Refusal messages: with_addr/map_addr/without_provenance say "a pointer made of an address, which JS has no memory at"; address refusals say "the address of a pointer to T, ...".

Follow-ups

  • {:p} and Debug of wakers still refuse writing addresses. They could print rs.addr, but the output then differs from native, so they need their own decision: plan it once a crate needs one.

Waiting

  • Maintainer: design checkpoint at review round 3. The fresh review session's round 1 (it counts as round 3: the earlier session's file was written by an older quest build that stored finding counts, not lists, so the current build could not resume it) reported 4 findings, listed under Review. Three are one class: the whole-program proof that an addressed type lives only as an Arc's value enumerates every place a value can be stored, and keeps missing some: a generic instance reached only through an address read (fn local<T>(v: T) -> usize), a &Foo a root takes from JS, and a temporary an iterator callback makes and filter borrows. Each lets a non-Arc value take an id nothing frees, so churn aborts at 4 GiB. The fourth is separate: a *const () cast of an addressed pointer still compares by JS identity, so it disagrees with == on the typed pointers after an address is reused. Options:
    1. Swap the storage walk for a construction-site check (recommended). Instead of finding everywhere a T may be kept, prove every T is born in an Arc: each aggregate of T in a reached body must move straight into Arc::new/Rc::new/new_cyclic's closure result, and T must not be Copy, appear in a root's signature, or be made by ptr::read, transmute, zeroed, or assume_init. Construction sites are a finite set the MIR shows, so the three findings fall out (each makes a T outside an Arc), and the Kept walk and storage tables in copy.rs go away. The existing move-out refusals stay. moq passes: Track and FrameContent are built as Arc::new(Self { .. }). It refuses a T built in a local and passed to Arc::new through a generic helper. Fix the erasure finding alongside by refusing a cast of an addressed type's pointer to another pointee.
    2. Continue patching the walk: record address-only generic instances, keep JS provenance through references, and walk callback temporaries. Smallest diff now, but each round so far has found a new storage path, and nothing bounds the next.
    3. Narrow to pointers the body shows come from an Arc (Arc::as_ptr, Weak::as_ptr, into_raw), refusing self in Drop. Simplest and local, but drops two of moq's six reads (cache.rs:1026, frame.rs:510), which then need a moq-side change.
    4. Drop the PR and give moq a cfg(rust2_ts) twin: contradicts the 2026-11-04 decision.

Models

  • Size: M
  • Implementer: Claude Code, claude-opus-5-5, unknown effort (earlier sessions)
  • Implementer: Claude Code, claude-opus-5-5, high effort (this session: merge of origin/main)

Review

Rewritten by quest review after each round.

  • Reviewer: Codex, gpt-6.1-sol, high effort
  • Session: 01a1b3a1-e7bc-7661-b313-72bd16991d4f, round 1
  • Head: ee6f142, named in full once a round reports no findings
  • Base: origin/main at b86a186
  • Score: 7/10, Three non-Arc storage paths bypass validation, and erased-pointer equality disagrees with numeric addresses after allocation reuse.
  • Findings, open until the next round settles them:
    • Medium, ts/src/lower/copy.rs:609: [P2] Record generic instances required by storage validation
    • Medium, ts/src/lower/address.rs:289: [P2] Preserve external-storage provenance through references
    • Medium, ts/src/lower/pointer.rs:142: [P2] Preserve numeric pointer equality through erasure
    • Medium, ts/src/lower/address.rs:295: [P2] Validate temporary values produced by iterator callbacks

(Written by Claude Opus 5.5 with high)

🤖 Generated with Claude Code

kixelated and others added 7 commits November 4, 2026 09:54
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Addresses as identity feat(ts): translate a pointer's address as an id per live object Nov 4, 2026
kixelated and others added 4 commits November 4, 2026 12:16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated and others added 3 commits November 6, 2026 13:33
Pointee::Bytes is now Pointee::Array, which covers every array as an
rs.Slice, so the address of a pointer to an array is refused as a view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An id is the address of the rs.Arc allocation whose value a pointer
points at, freed for reuse when its strong and weak counts reach 0, as
Rust frees it, so churning addressed allocations never exhausts wasm32's
4 GiB. Reading the address of a type the program keeps anywhere else (a
local it borrows, drops, or gives std, a field, a Box, a collection, a
static, or what JS is given whole), or writes whole in place, is
refused, as is moving one out of its Arc by into_inner or make_mut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Design change: addresses only of an Arc's or Rc's value (option 1 from ## Decisions needed, picked by the maintainer on 2026-11-06).

  • An id is the address of the rs.Arc allocation whose value the pointer points at, freed for reuse when its strong and weak counts reach 0 (a strong Arc holds the implicit weak while its value drops, as std's do). No FinalizationRegistry, so churn within one job reuses freed blocks and never exhausts wasm32's 4 GiB.
  • Refused with a span: the address of a type the program keeps anywhere else (a local it borrows, drops, or gives std; a field, a Box, a collection; a static or constant; what JS is given or gives a root whole), or writes whole in place (as through get_mut), and into_inner / make_mut of such a value. try_unwrap and unwrap_or_clone translate for no type.
  • Dropped: the old per-object ids, the enclosing-allocation alignment walk (kept), and the "many of / beside another" layout check. An Arc's value is always at the head of its allocation, so neither is needed.
  • Rejected, a counter that never reuses: usize is 32 bits on wasm32, so a long-running program would exhaust the address space where Rust reuses freed blocks.

Review rounds restart with quest review --new.

(Written by Claude Opus 5.5)

kixelated and others added 2 commits November 6, 2026 15:27
A pointer read only after its allocation was freed took a block then,
so two freed together could share an address, and frees before the
first read reserved blocks for good. Now each Arc or Rc of a type whose
address the program reads gets its address as it is made, by rs.arcAt,
which a run settles like the comparisons it makes copies for, and keeps
it once freed, as Rust's pointer does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed iterate session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Design change: closing this PR. Decided with the maintainer on 2026-11-07: pointer addresses stay refused.

  • Every read of a pointer's address as an integer (as usize, addr, expose_provenance, a raw pointer's Hash/Ord) stays refused at translation with a span, as on main. Pointer identity (ptr::eq, Arc::ptr_eq, Weak::ptr_eq) keeps translating as JS identity.
  • Rejected: ids for any crate (2026-11-04), ids only for an Arc's or Rc's value (2026-11-06), and only for pointers visibly from an Arc. Each round found values whose ids were never freed, or comparisons that disagreed with Rust after reuse. Exact addresses everywhere would need a linear-memory model.
  • moq keys Track and FrameContent by explicit ids in both builds instead, and writes no cfg(rust2_ts) twins. Pointer addresses get planned again once a library other than moq needs them.

The quest's deletion and these decisions are in a separate PR, which records them in the model epic's Plan.

(Written by Claude Opus 5.5 with high effort)

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