Skip to content

feat(ts): model typed allocation as a fresh JS array - #418

Merged
moq-bot[bot] merged 64 commits into
mainfrom
ts-unsafe/typed-alloc
Nov 2, 2026
Merged

moq-bot[bot] merged 64 commits into
mainfrom
ts-unsafe/typed-alloc

Conversation

@kixelated

@kixelated kixelated commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

smallvec spills to memory from alloc::alloc(Layout::from_size_align(size_of::<T>() * n, align_of::<T>())), cast to *mut T, and frees and grows it with dealloc and realloc through *mut u8. rust2-ts refused all of it (Layout, alloc, realloc, dealloc, align_of, and the casts), so smallvec, which moq-net's model and kio reach, could not translate.

Approach

The maintainer's decision 2 on #400 (2026-11-01): model a typed allocation as a fresh JS array; untyped and byte allocation stay refused. Builds on #400's rt.AtIndex and rt.RawSlice; #400 has landed.

  • rt.Layout is { size, align }, never changed once made: Layout::new, array, from_size_align (checked as std checks, against wasm32's isize::MAX), from_size_align_unchecked, size, align, constants, and std's Debug; LayoutError with its Debug and Display; mem::align_of.
  • alloc gives an rt.Block of the layout's bytes. A cast of a *mut u8 to *mut T (as or NonNull::cast) that mir/alloc.rs traces back through the body to alloc or realloc, through NonNull::new, Option::ok_or, unwrap, expect, and ?, makes it a JS array of as many holes as whole Ts fit in its bytes, as rt.AtIndex of its first. T must be a value JS copies, as for any pointer into an array.
  • A cast of *mut T to *mut u8 (as, or cast and NonNull::as_ptr) is allowed only as the pointer dealloc or realloc takes, and gives the block, made on demand for a Vec's array (smallvec's from_vec). realloc copies the values that fit into a new block; dealloc lets the array go.
  • The block keeps its Rust type (a MaybeUninit<T> as T), not only its size, so a cast to another type of the same size (i32 after u32) fails rather than rereading the values. Where one body casts memory from one alloc (followed through realloc, as, and cast) to two types, translation refuses each cast with a span.
  • Refused with a span: a read or write of a byte of memory from alloc (*p, ptr::write, and the other pointer functions) where the body shows it came from alloc, any other cast between bytes and values, a cast to a type JS keeps as an object, and alloc_zeroed. Through a pointer the body can't trace, each fails at run time with a rust2-ts: error, per decision 1.

Impact

  • Output: Layout, alloc, realloc, dealloc, handle_alloc_error, and align_of translate instead of failing.
  • Runtime: new rt/alloc.ts, re-exported by rt.ts: Layout, LayoutError, LAYOUT_ERROR, Block, layoutFromSizeAlign, layoutArray, fmtLayoutDebug, fmtLayoutErrorDebug, fmtLayoutErrorDisplay, alloc, allocAs, allocBytes, dealloc, realloc, handleAllocError, all tree-shaken when unused. No hand-written binding names them, so no MIGRATE.md entry.

Census at main b1656c97's moq pin, against #400's head 21e40904 merged with that main:

Census Before After
model 192 151
moq-net 820 757
kio 121 89

In moq-net, smallvec goes from 54 refusals to 26, none of them on its allocation. What remains is ManuallyDrop, its union, &raw through a reference, its iterators' trait impls, and Vec::from_raw_parts. crossbeam-epoch, zlib-rs, moq, and kio lose their Layout, alloc, and align_of refusals. No refusal is added.

Tests: fixtures/lang/src/heap.rs, differential against native wasm32: smallvec's spill shape over u32s, Arcs (strong counts after each drop), and Option pairs, pushed, grown and shrunk by realloc (dropping what no longer fits), and freed; storage of MaybeUninit<u32> read and freed as u32s; a MaybeUninit<u8> grown and freed through cast; a Layout of 9 bytes holding two u32s, grown to 11; Layouts across sizes and alignments up to isize::MAX, arrays of u64 and of (), constants, and their Debug and Display. fixtures/reject: allocated (bytes read and written), bytes_as_values (an array's bytes as u32s, memory from alloc as the crate's struct, a value's bytes read, a byte written by ptr::write), zeroed, alloc_retyped (one allocation cast to u32 and i32, also after realloc and a cast back to bytes). tests/pointers.ts: a block's bytes, a cast to another type of the same size, a pointer past the start cast back, dealloc and realloc of a pointer that is no block, and realloc keeping the values that fit.

Alternatives

  • Type the block at alloc from its Layout. Rejected at the design checkpoint: a layout is a size and an alignment at run time, and smallvec builds it in another function from size_of and align_of, so this would refuse smallvec unless the trace crossed functions.
  • Allow every byte-to-value cast with a runtime check. Rejected: it would turn every reinterpretation the body can see, now refused with a span, into a run-time failure.
  • Map SmallVec to a runtime type. Rejected by the maintainer in favor of this, which serves any crate's typed allocation.

Follow-ups

  • smallvec still needs ManuallyDrop (next in the quest, in its own PR), a union over its type parameter, &raw through a reference, its own iterators' trait impls, and Vec::from_raw_parts of its block; the quest keeps each.

Review

Codex gpt-6.1-sol at medium effort (quest review --base origin/main), round 4 on c88a91f9: no findings.

Design checkpoint at round 4 (2026-11-02): kept as is. It follows the maintainer's settled typed-allocation decision, and review converged (5, 2, 1, 0 findings). Rejected:

  • Typing the block at alloc from a Layout::array::<T> the body can see. smallvec builds its layout in another function, so this would refuse it.

  • Dropping the PR and keeping allocation refused.

  • Round 3 on 39a0a393: the lang harness's one-byte selector couldn't reach cases past 254, so this branch's heap cases and feat(ts): translate pointers into arrays as rt.AtIndex #400's elements cases never ran. Fixed on main by fix(ts): hold a byte vector's capacity as a heap or deque takes it #417, which carried test(ts): pick lang cases by two bytes, so none past 255 is silently skipped #414's two-byte selector. With it merged here, all 263 cases run, and just ci passes with them agreeing with native wasm32.

  • Round 2 on cd6c8e3d:

    • A MaybeUninit<u8> block freed through cast::<u8>() was not recognized as freeing. Fixed: freed takes as, cast, and NonNull::as_ptr, and rt.dealloc/rt.realloc fail with a rust2-ts: error for a pointer that is no block.
    • offset_from through a helper's parameters: refuted by the maintainer's decision 1 on feat(ts): translate pointers into arrays as rt.AtIndex #400. A case only visible at run time keeps its loud runtime error.
  • Round 1 on 9fe6969b: five findings, all fixed. is_std checks, the uninit copy, and null wrapping were fixed in feat(ts): translate pointers into arrays as rt.AtIndex #400 and merged here. A MaybeUninit type key and trailing bytes were fixed here, each with a heap::remnants differential case.

  • Merged feat(ts): translate pointers into arrays as rt.AtIndex #400's final head and main (unions of both sides' case lists, reject roots, ts/tests/pointers.ts, the quest Plan, and README's Not-yet sentence; snapshots regenerated; heap.Spill's drops now read through rt.RawSlice). New session on this checkout's branch, Codex gpt-6.1-sol at medium effort:

  • Round 1 on 1d82ffa2: 1 finding, declined for this PR: offset_from between byte pointers into two Uint8Array views of one buffer throws, in feat(ts): translate pointers into arrays as rt.AtIndex #400's ptrOffsetFrom (now on main). Loud, recorded in quest/m3/ts-unsafe.md.

  • Round 2 on 3c4b7c8d, round 3 on aa24f669: 1 finding, fixed after round 3: memory from one alloc cast to two types in one body is refused at translation, as decision 1 does for array pointers.

  • Round 4 on 43d032cf, round 5 on a816bfb6: the same finding through realloc, then through cast calls, each fixed. Design checkpoint at round 4: kept; the runtime type check catches every conflicting cast loudly, and translation refuses what one body proves.

  • Round 6 on 72367205: clean.

just ci passes locally on 7236720555ef532fda79cd188a66d3965c86f5d7.

(written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 30 commits November 1, 2026 10:16
Quest-Claim: c56ea58c47a4ebd5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…older replaces

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s, views, and empty slices

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ue's pointer in an empty slice

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	ts/fixtures/lang/expected/harness.ts
#	ts/fixtures/lang/src/harness.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per the maintainer's decision on #400: offset_from between pointers into a
value in no array or a copied shared subslice and another array, a raw or
mutable slice of a lone byte, and constant offsets, slices, and copies
reaching past either are refused with a span. Pointers only run time shows
still fail loudly; ptrSlice, ptrByteSlice, and ptrCopy now check their
whole extent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shared slice of a byte in no array is a copy, so its pointer measured
against the byte's is refused; a slice from raw parts otherwise starts where
its pointer points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per the maintainer's decision on #400: memory from alloc, cast to *mut T
where the body shows the pointer came from alloc or realloc, is a JS array
of T; realloc copies it and dealloc frees it, through a cast back to *mut u8
only as their argument. Layout, LayoutError, and align_of map to the
runtime. Byte use of the memory, and every other cast, stay refused.

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>
…nd wrap null

The provenance trace matched the crate's own functions named as std's; a
copy ran a value's copy over memory nothing wrote; and wrapping arithmetic
on a null pointer threw from the lone-value map instead of moving it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dangling pointer is at its pointee's alignment, so wrapping arithmetic can
take it to null, as it can take null away and back.

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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	ts/fixtures/lang/expected/harness.ts
#	ts/fixtures/lang/src/harness.rs
#	ts/fixtures/reject/census.md
#	ts/fixtures/reject/expected.txt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	ts/fixtures/lang/expected/harness.ts
#	ts/fixtures/reject/census.md
#	ts/fixtures/reject/expected.txt
#	ts/src/lower/mir/origin.rs
…alues that fit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts were the harness case and import lists, the reject roots and cases,
and the reject snapshots, which were regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated and others added 11 commits November 2, 2026 08:42
…keep empty parts' pointers

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>
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>
# Conflicts:
#	ts/fixtures/reject/census.md
#	ts/fixtures/reject/expected.txt
# Conflicts:
#	ts/fixtures/lang/expected/harness.ts
#	ts/fixtures/lang/src/harness.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated and others added 9 commits November 2, 2026 11:37
# Conflicts:
#	quest/m3/ts-unsafe.md
#	ts/README.md
#	ts/fixtures/lang/expected/harness.ts
#	ts/fixtures/lang/src/harness.rs
#	ts/fixtures/reject/census.md
#	ts/fixtures/reject/expected.txt
#	ts/fixtures/reject/src/lib.rs
#	ts/fixtures/reject/ts.toml
#	ts/src/lower/mir/origin.rs
#	ts/tests/pointers.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	quest/m3/ts-unsafe.md
#	ts/README.md
#	ts/fixtures/lang/expected/core/bool.ts
#	ts/fixtures/lang/expected/harness.ts
#	ts/fixtures/lang/src/harness.rs
#	ts/fixtures/lang/src/lib.rs
#	ts/fixtures/reject/census.md
#	ts/fixtures/reject/expected.txt
#	ts/src/lower/mir/call.rs
#	ts/src/lower/mir/origin.rs
#	ts/src/lower/mir/rvalue.rs
#	ts/tests/pointers.ts
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>
# Conflicts:
#	ts/fixtures/lang/expected/harness.ts
#	ts/fixtures/lang/src/harness.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated enabled auto-merge (squash) November 2, 2026 20:38
@moq-bot
moq-bot Bot merged commit 7d651ab into main Nov 2, 2026
1 check passed
@moq-bot
moq-bot Bot deleted the ts-unsafe/typed-alloc branch November 2, 2026 20:47
kixelated added a commit that referenced this pull request Nov 2, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
moq-bot Bot pushed a commit that referenced this pull request Nov 2, 2026
…n asked (#425)

* chore(quest): claim quest/m3/ts-unsafe.md

Quest-Claim: c56ea58c47a4ebd5

* feat(ts): translate pointers into arrays as rt.AtIndex

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

* fix(ts): refuse a pointer to an element of an array its variable or holder replaces

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

* chore(ts): refresh the lang harness snapshot after merging main

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

* fix(ts): keep element pointers exact at wrapped addresses, lone values, views, and empty slices

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

* fix(ts): copy only a shared slice of a lone byte, and keep a lone value's pointer in an empty slice

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

* chore(ts): refresh the lang harness snapshot after merging main

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

* feat(ts): refuse pointers a body shows crossing what JS keeps apart

Per the maintainer's decision on #400: offset_from between pointers into a
value in no array or a copied shared subslice and another array, a raw or
mutable slice of a lone byte, and constant offsets, slices, and copies
reaching past either are refused with a span. Pointers only run time shows
still fail loudly; ptrSlice, ptrByteSlice, and ptrCopy now check their
whole extent.

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

* fix(ts): trace slices from raw parts, and type arguments past lifetimes

A shared slice of a byte in no array is a copy, so its pointer measured
against the byte's is refused; a slice from raw parts otherwise starts where
its pointer points.

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

* chore(ts): refresh the lang snapshot for a shared part's pointers

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

* feat(ts): model typed allocation as a fresh JS array

Per the maintainer's decision on #400: memory from alloc, cast to *mut T
where the body shows the pointer came from alloc or realloc, is a JS array
of T; realloc copies it and dealloc frees it, through a cast back to *mut u8
only as their argument. Layout, LayoutError, and align_of map to the
runtime. Byte use of the memory, and every other cast, stay refused.

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

* fix(ts): key memory from alloc by its type, and snapshot the heap cases

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

* style(ts): format the alloc refusal

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

* fix(ts): trace only std's pointer functions, copy unwritten memory, and wrap null

The provenance trace matched the crate's own functions named as std's; a
copy ran a value's copy over memory nothing wrote; and wrapping arithmetic
on a null pointer threw from the lone-value map instead of moving it.

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

* chore(ts): refresh the lang snapshot for the pointer fixes

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

* fix(ts): move null and dangling pointers by address

A dangling pointer is at its pointee's alignment, so wrapping arithmetic can
take it to null, as it can take null away and back.

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

* style(ts): format the dangling cases

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

* chore(ts): refresh the lang snapshot for dangling pointers

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

* fix(ts): measure equal addresses as none apart

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

* style(ts): format the equal addresses case

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

* chore(ts): refresh the lang snapshot for equal addresses

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

* chore(ts): refresh the snapshots after merging main

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

* fix(ts): key alloc memory by what a MaybeUninit holds, and keep the values that fit

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

* fix(ts): free alloc memory through cast and as_ptr

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

* chore(ts): refresh the reject snapshot after merging main

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

* feat(ts): hold a value by ManuallyDrop as the value, dropped only when asked

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

* chore(ts): refresh the snapshots for ManuallyDrop's copies and the renamed struct

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

* fix(ts): keep a ManuallyDrop's payload from dropping through its holder, and copy it as its payload

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

* test(ts): accept wasm32's fma a last place off where the translation is exact

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

* fix(ts): refuse offset_from across array fields, fail on dangling elements, keep null array casts null

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

* chore(ts): regenerate the reject census after merging main

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

* fix(ts): normalize pointers through mutable parts, keep null and dangling in empty raw slices

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

* fix(ts): keep the empty byte slices' sentinels tree-shakeable, regenerate elements

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

* fix(ts): keep the pointer an empty slice from no array was made from

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

* fix(ts): find an empty slice's null pointer, which ?? passes over

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

* fix(ts): unsize a raw pointer to an array as the place of its slice, keep empty parts' pointers

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

* test(ts): read an unsized raw slice pointer through a cast

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

* chore(ts): regenerate the elements snapshot

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

* feat(ts): keep a raw slice pointer as its start and length

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

* perf(ts): keep the slice a raw slice pointer's first read makes

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

* fix(ts): fail a raw slice read past an array that shrank

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

* fix(ts): read through NonNull::as_mut of a slice

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

* fix(ts): keep the pointer of an empty slice of a lone byte

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

* chore(ts): regenerate snapshots after merging #400's raw slices

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

* chore(quest): record offset_from across views of one byte buffer

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

* fix(ts): refuse memory from alloc cast to two types in one body

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

* fix(ts): trace an allocation's type through realloc in one body

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

* fix(ts): trace an allocation through pointer cast calls

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

* chore(ts): regenerate snapshots after merging #418

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

---------

Co-authored-by: Luke Curley <432854+kixelated@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant