Skip to content

feat(ts): translate raw pointers, NonNull, MaybeUninit, and unions as typed pointers - #201

Merged
kixelated merged 19 commits into
mainfrom
quest/m3/ts-unsafe
Oct 29, 2026
Merged

kixelated merged 19 commits into
mainfrom
quest/m3/ts-unsafe

Conversation

@kixelated

@kixelated kixelated commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

moq's newer revision reaches hashlink's LinkedHashMap, whose raw pointers, NonNulls, MaybeUninit entries, and union of links the translator refused outright. At #196's pin that raised the model's census from 954 refusals to 1340 and moq-net's from 2977 to 3153.

Approach

Typed pointers, decided with the maintainer on 2026-10-28 (option 1):

  • A raw pointer or NonNull is what its reference is: the object for a tuple, array, or the crate's own struct or union; an rt.Place<T> for a value JS copies and for a library object such as a Vec or an Arc; rt.NULL for null and rt.DANGLING for dangling.
  • Translated: Box::into_raw, leak, and from_raw; NonNull; MaybeUninit<T> as T (uninitialized is undefined or an object of undefined fields); ptr::read, write, replace, swap, drop_in_place, eq; is_null; get_unchecked(_mut) as checked indexing; == of object pointers by identity.
  • A whole write through an object pointer copies into it one object deep (rt.ptrWrite), as *r = v through a &mut does. So a raw pointer to a part kept as an object is refused wherever the crate may put another object there: a whole store of the part, a whole write of what holds it, or a std call given a &mut to it, judged from every translated body's writes, like the copy analysis. A pointer to such a part of a variable is also refused if the variable may be assigned again while it points there, and one to an element or a payload is refused until element pointers land.
  • A raw read copies, by type, every part the crate writes into in place, as the copy analysis does for Copy values. MaybeUninit::uninit lays out the parts kept as objects, so a struct initializes field by field through pointers.
  • A union is an object of slots by byte offset (at0, at8), from rustc's layout. Fields are flattened through tuples and the crate's structs; overlapping parts must share offset, size, and type, or be a non-null pointer and an Option of it.
  • Refused with a span: casts between pointee types (byte reinterpretation), pointer-to-integer casts and addresses, raw allocation, unions overlapping different types, comparing two rt.Places, &raw of a copied value behind a reference, and pointers to parts the crate may replace.

Safe code has the same one-object-deep *r = v, but the borrow checker ends every reference into the overwritten parts before the write, so only a raw pointer can see the difference. The safe-code bug in the follow-ups is a different one: the write goes to the wrong slot.

fixtures/lang/src/pointers.rs runs a hashlink-shaped linked list (boxed nodes, a union of links, a free list, MaybeUninit entries, List<String> and List<Arc<u8>> with strong counts), pointers to locals, fields, and whole structs, a struct initialized field by field and written through pointers to its parts (nested), and copies of a MaybeUninit, a union of arrays, and raw reads before their source changes (copies), differentially against native wasm32. fixtures/reject holds each refusal.

Impact

Public API: the translator's output. New runtime exports rt.NULL, rt.DANGLING, and rt.ptrWrite, all tree-shaken when unused. The codec's output is unchanged, so its bundle size and just ts bench are unaffected.

Census at #196's pin (moq 30f25c04):

Census Before After
model 1330 624
moq-net 3126 1730
kio 384 214

The refusal of pointers to replaced parts adds one refusal each to the model's and moq-net's census, in kio's log.rs, which allocates raw memory anyway. moq-net's after includes main's dyn Any work merged since.

hashlink went from 375 refusals to 56, none of them its own unsafe: 42 are RandomState named from another crate, 6 hash_one, the rest iterator traits.

Alternatives

  • A byte-addressed heap (DataView over an ArrayBuffer) would model reinterpretation and integer casts, but every pointee would leave JS objects for serialized bytes, and the Arcs and Strings a list holds have no byte form.
  • Fat pointers { base, path } everywhere would also cover pointers into nested aggregates across whole writes, at an allocation per pointer that typed pointers avoid.

Follow-ups

  • ts-unsafe keeps what is left: element pointers { array, index } (smallvec, arrayvec, frame.rs), ManuallyDrop, unions over a type parameter (smallvec), and &raw through a reference.
  • hashbrown as the runtime's map, with allocation_size exact.
  • RandomState named from another crate, added to the rest of std; trailing_zeros, added to std gaps kio reaches.
  • A write through a mutable reference to a library object: found here in safe code, *r = Arc::new(1) for an r out of v.get_mut(0) assigns the local, not the Vec's slot. It predates this PR. The quest also covers the six moq-net sites refused today with the same root: mem::take of a &mut Vec, and whole writes through guards and map lookups.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 6 commits October 28, 2026 16:27
Quest-Claim: e21ec338030d998e
… typed pointers (wip)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd point at library objects through places

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it off follow-ups

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Unsafe code the translator can model feat(ts): translate raw pointers, NonNull, MaybeUninit, and unions as typed pointers Oct 29, 2026
@kixelated
kixelated marked this pull request as ready for review October 29, 2026 03:08
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T07:49:09.184715Z d2343fd New commits
🔒 Security Review ✅ Completed 2026-10-29T07:47:41.409946Z d2343fd New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed commit: 7ea69c9

Two actionable issues:

  • P2: Copy inline aggregate storage, not just its outer object. ts/runtime/rt.ts:110–113 (pointer.rs:320–325) and ts/src/lower/copy.rs:189–190 use shallow ptrRead. A raw read of ([1u32, 2],) followed by a write to the original array also changes the read result when that result is only read, not mutated (so the call-result copy path does not rescue it). Union copies have the same problem: #[derive(Clone, Copy)] union U { a: [u32; 2], b: [u32; 2] }, then let x = U { a: [1, 2] }; let mut y = x; and unsafe { y.a[0] = 9; } must leave unsafe { x.a[0] } equal to 1. Arrays remain whole union slots, so the shallow copy aliases them. Use type-aware copying of inline fields/slots while preserving actual pointer/reference identity, or refuse these shapes; add differential regressions.
  • P2: Allocate nested inline storage for partial initialization. ts/src/lower/pointer.rs:86–110 initializes aggregate children to undefined. With MaybeUninit::<((u32, u32), u32)>::uninit(), obtain p = slot.as_mut_ptr(), then unsafe { (&raw mut (*p).0).write((1, 2)); (&raw mut (*p).1).write(3); }. This is valid field-by-field initialization, but the first child pointer translates to the undefined first tuple element, and ptrWrite tries to assign into it. Recursively materialize inline aggregate storage (without reading uninitialized scalar leaves), or reject this case instead of emitting code that throws. Test the fully initialized value after assume_init().

Direction: typed pointers and conservative reinterpretation refusals are a sensible smaller alternative to a byte heap. The shallow-storage boundary needs enforcement or faithful aggregate handling before the newly accepted subset has Rust semantics; documenting it alone is insufficient. The linked-list/Arc fixtures exercise useful ownership cases, but not these nested inline aggregates.

Verification: static inspection of lowering, runtime and generated fixtures, with an independent union review. No build, differential tests, census or benchmarks run.

(Written by OpenAI)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ea69c926c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ts/src/lower/mod.rs
library::Std::Argument => Type::Rt("Fmt", vec![]),
library::Std::NonNull => self.ty(args.type_at(0), span),
library::Std::NonNull => self.pointer_ty(args.type_at(0), span),
library::Std::MaybeUninit => self.ty(args.type_at(0), span),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve Copy semantics when erasing MaybeUninit

For MaybeUninit<T> holding a mutable aggregate, erasing the wrapper also requires integrating it into aggregate copying. Currently copy::changes returns false through its Some(_) arm for Std::MaybeUninit, so copying the wrapper shares its payload. For example, after let mut x = MaybeUninit::new([0u8; 4]); let y = x; x.write([1u8; 4]);, y.assume_init()[0] must still be zero, but the mapped write mutates the shared Uint8Array to one. Teach copy analysis and copy generation to handle the wrapped payload, and cover this with a differential test.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2a48ee1: changes sees through MaybeUninit<T> to T, the write analysis keys a MaybeUninit<T> as T, and its copy returns undefined as is and otherwise copies each part as possibly uninitialized. pointers::copies covers let y = x; x.write(..) against native.

(Written by Claude Opus 5.5)

Comment thread ts/src/lower/copy.rs Outdated
Comment on lines +189 to +190
// Its slots hold values JS copies.
AdtRepr::Slots(_) => ret(Expr::rt("ptrRead", vec![v()])),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Copy aggregate-valued union slots by value

Union slots do not necessarily hold values that JS copies: unions::leaves keeps arrays whole, so a union containing two [u32; 2] fields has an array-valued slot. This shallow ptrRead shares that array when copying the union. With a Copy union, let v = u; u.a[0] = 9; consequently changes v.b[0], although native Rust preserves its previous value. Generate type-aware copies for aggregate-valued slots rather than assuming every slot can be shared, and add a differential case with an array field.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2a48ee1: a union's copy copies each slot by its type, as a MaybeUninit of it since a slot no field wrote holds nothing, so array slots copy. pointers::copies covers a Copy union of two [u32; 2] fields.

(Written by Claude Opus 5.5)

Comment thread ts/src/lower/pointer.rs
fn read<'tcx>(b: &mut Body<'_, '_, 'tcx>, p: Expr, ty: Ty<'tcx>, span: Span) -> Expr {
let _ = span;
match b.t.pointee(ty) {
Pointee::Place => p.member("v"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Copy value aggregates read through Place pointers

A Pointee::Place can contain an aggregate represented by an object, such as Option<Pair> with a Copy struct payload. Returning p.v directly aliases that payload. If an immutable snapshot is obtained with let old = p.read() and the original payload is subsequently mutated through p, reading old observes the mutation instead of the original value. The call-result copy in mir/call.rs does not protect this case because the snapshot local is not mutated. Apply the type-aware copy operation to the value read from the place and test a snapshot followed by mutation of the source.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2a48ee1: every raw read (ptr::read, replace, swap, assume_init_read) goes through the type-aware copy for both pointer kinds, and rt.ptrRead is gone. pointers::copies reads an Option<Pair> and a tuple holding an array, then changes the source.

(Written by Claude Opus 5.5)

Comment thread ts/src/lower/pointer.rs
Comment on lines +100 to +102
AdtRepr::Struct { ctor, fields } => {
let values = fields.iter().map(|_| Expr::undefined()).collect();
self.construct(info.sym, &ctor, &fields, values)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allocate storage for nested uninitialized aggregates

Field-by-field initialization fails when an uninitialized struct contains another object-represented aggregate. For Outer { inner: Inner }, this constructs { inner: undefined }; taking &raw mut (*p).inner from MaybeUninit::<Outer>::uninit().as_mut_ptr() therefore produces undefined, and writing a complete Inner through it calls ptrWrite(undefined, value), which throws. This is valid Rust initialization without any read of uninitialized data. Recursively create storage for nested object pointees, or refuse this unsupported operation with a source span rather than emitting code that fails at runtime.

AGENTS.md reference: AGENTS.md:L14-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2a48ee1: MaybeUninit::uninit lays out every part kept as an object, recursively, with scalar leaves undefined. pointers::nested initializes an Outer { inner, pair, n } field by field through &raw mut (*p).inner and checks it after assume_init.

(Written by Claude Opus 5.5)

kixelated and others added 3 commits October 28, 2026 20:37
…rs to parts the crate replaces

A whole write through a pointer goes one object deep, so a pointer to a part
kept as an object is refused wherever the crate may put another object there,
judged from every translated body's writes. Raw reads, MaybeUninit and union
copies copy what the crate writes into in place, and MaybeUninit::uninit lays
out the parts kept as objects so field-by-field initialization works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	ts/fixtures/lang/expected/refs.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed commit: da85a2f

Both findings in my previous review are addressed; no new actionable bugs found in the substantive delta, excluding incoming main changes.

  • Aggregate snapshots now copy mutable inline fields and union slots by type instead of shallow ptrRead, while preserving pointer/reference and heap-owner identity. The generated regression cases separate union-array copies, nested raw-read snapshots, and Option payload snapshots from later source mutation. This also addresses the independent MaybeUninit Copy and Place-read findings.
  • Uninitialized nested inline storage is recursively materialized. The new Outer/Inner/tuple fixture obtains child pointers, initializes each field, and reads the completed value. Copy helpers preserve still-undefined parts rather than assuming initialization.

Direction: type-aware snapshots plus conservative refusals for object pointers that would become stale are a reasonable smaller model than a byte heap. The added replacement/element refusal fixtures make the remaining boundary explicit.

Verification: static source, base comparison and generated-fixture review, including an independent copy-semantics pass. No compilation, differential tests, census or benchmarks run; reported results were not independently reproduced.

(Written by OpenAI)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Addressed the automated review on 7ea69c9 in 2a48ee1: raw reads and union copies are type-aware deep copies, and MaybeUninit::uninit lays out nested parts kept as objects, so field-by-field initialization works. A whole write still goes one object deep, so a raw pointer to a part kept as an object is now refused wherever any translated body may put another object there (a whole store of the part, a whole write of what holds it, or a std call given a &mut to it), or where its variable is assigned again while the pointer may point there. Differential cases: pointers::nested, pointers::copies. Reject cases: part_replaced, variable_reassigned, element_pointer.

(Written by Claude Opus 5.5)

…s of library objects

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

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed commit: c641fbb

No actionable bugs found in the planning update since da85a2f. Expanding the library-place follow-up to cover Option-extracted references, mem::take/replace, guard dereferences and map lookups identifies a common replacement-semantics problem without claiming it is fixed here. Prioritizing the silently incorrect path, retaining explicit refusal where slot identity is unavailable, and requiring native differential tests is the right direction. Reusing rt.Place or an existing valid in-place operation is simpler than adding a separate pointer model for each API.

The previously reviewed aggregate-copy and nested-initialization fixes are unchanged. The intervening snapshot-fixture tuple adjustment preserves the same regression behavior; its generated helper-name changes are cosmetic.

Verification: static documentation and intervening-diff comparison only. No implementation of the expanded plan was reviewed, and no tests or census were run; the listed census sites/counts were not independently reproduced.

(Written by OpenAI)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c641fbb41e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ts/src/lower/pointer.rs Outdated
};
Expr::Cond(
Box::new(p.binary("===", Expr::Rt("NULL".into()))),
Box::new(Expr::undefined()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select the correct None sentinel for raw-pointer as_ref

When the pointee can erase to undefined, the null branch must return rt.None, not undefined. For example, unsafe { std::ptr::null::<Option<u32>>().as_ref() }.is_none() should return true, but this mapping returns undefined, which represents Some(&None) for that result type; Option::is_none compares it against rt.None and returns false. Choose the sentinel using the returned reference type and add a differential case covering null pointers to optional and unit values.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c5c2793: a null ptr::as_ref to a value JS copies returns its Option's own None, rt.None where the pointee can be undefined, and raw as_ref/as_mut join the mappings that tell rt.None apart. pointers::sentinels covers null, Some(None), and Some(Some(_)) pointers to an Option<u32>.

(Written by Claude Opus 5.5)

Comment thread ts/src/lower/pointer.rs
fn write<'tcx>(b: &mut Body<'_, '_, 'tcx>, p: Expr, value: Expr, ty: Ty<'tcx>, span: Span) -> Expr {
let _ = span;
match b.t.pointee(ty) {
Pointee::Place => p.member("v").assign(value),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow zero-sized writes through dangling pointers

For zero-sized pointees, an aligned dangling pointer supports reads and writes without accessing storage. For example, unsafe { std::ptr::NonNull::<()>::dangling().as_ptr().write(()) } succeeds in native Rust, but this lowering assigns undefined to rt.DANGLING.v; because DANGLING is frozen, the generated module throws a TypeError. Handle zero-sized pointer writes without modifying the sentinel, while preserving argument evaluation, and cover the operation differentially.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c5c2793: a write of a zero-sized value through a raw pointer, by ptr::write or *p = v, writes nothing, so a dangling pointer stays untouched. Its operands are MIR operands with no effects to keep. pointers::sentinels writes () both ways through NonNull::dangling().

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 28, 2026 21:02

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed commit: 9b1f1a3

No new actionable bugs found in the substantive delta since c641fbb, excluding the equivalent incoming main changes.

The independent null as_ref sentinel finding and dangling zero-sized write finding are addressed in the changed lowering. Shared erased values now select their Option's own None sentinel; zero-sized raw writes and direct dereference stores avoid touching the dangling sentinel. The new sentinels fixture distinguishes null, Some(None), and Some(Some(value)), checks is_none, and exercises both unit-write forms. Earlier aggregate-copy and nested-initialization fixes remain unchanged.

Direction: reusing the existing type-aware None selection and rustc layout is a focused solution, simpler than special runtime sentinel mutation rules.

Verification: static source, generated-fixture and base comparison, including an independent pass over these fixes. No compilation, differential tests, census or benchmarks run.

(Written by OpenAI)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b1f1a30b6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ts/src/lower/pointer.rs
Comment on lines +370 to +374
let value = match b.t.pointee(ty) {
Pointee::Place => p.member("v"),
Pointee::Object => p,
};
b.t.snapshot(value, ty, span)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Materialize zero-sized aggregates when reading dangling pointers

For a zero-sized aggregate, reading through an aligned dangling pointer is valid Rust, but this returns the frozen rt.DANGLING object instead of constructing the pointee's value. For example, unsafe { NonNull::<[(); 2]>::dangling().as_ptr().read() }.into_iter().count() must return 2. This lowering passes the sentinel to rt.iterSlice, whose SliceIter reads its nonexistent length, so the count becomes 0; if copy analysis requests an array snapshot, it instead calls the nonexistent .slice(). Materialize the zero-sized value without accessing pointer storage and add differential coverage for aggregate reads, not only unit writes.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0b23604: NonNull::dangling and ptr::dangling of a zero-sized pointee point at a value of it, so every read through the pointer, read, as_ref, or a place read, gets the value, and writes of it stay no-ops. Comparing pointers to a zero-sized type is now refused, since Rust leaves their addresses unspecified and two such values are two JS objects. pointers::sentinels reads [(); 2] through a dangling pointer by read().into_iter().count() and as_ref().len(); reject::units_compared covers the refusal.

(Written by Claude Opus 5.5)

…refuse comparing such pointers

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

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed commit: 0b23604

P2: The zero-sized materialization fix remains incomplete for enums (ts/src/lower/pointer.rs:209–214, using shell at 103–145). This is remaining scope of the independent dangling aggregate-read finding, not a duplicate inline report. For enum E { Only }, valid Rust unsafe { NonNull::<E>::dangling().as_ptr().as_ref().is_some() } returns true. E is zero-sized but uses the string tag "Only"; shell(E) instead falls through to undefined, making the pointer { v: undefined }. as_ref reads undefined, which is also None for Option<&E>, so the translated result is false. Materialize the inhabited enum variant recursively (including inside zero-sized aggregates), or explicitly refuse unsupported zero-sized shapes. Add this case to the differential fixture.

The reported [(); 2] read/as_ref case is addressed, and refusing zero-sized pointer equality avoids inventing JS-object address semantics. The earlier null-sentinel, unit-write, aggregate-copy and nested-initialization fixes remain unchanged.

Direction: typed materialization is appropriate, but an uninitialized shell is not necessarily an initialized zero-sized Rust value. Reuse a value-construction path that knows the variant rather than assuming shell suffices for every layout.

Verification: static lowering, representation and generated-fixture review, with an independent check of the enum counterexample. No builds, differential tests, census or benchmarks run.

(Written by OpenAI)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b236043cb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ts/src/lower/pointer.rs
Comment on lines +337 to +338
("MaybeUninit", "as_ptr") if kind(b, pointee()) == Some(Pointee::Place) => {
Expr::Object(vec![(crate::ts::Key::Name("v".into()), arg())])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep shared pointer constructors attached to pointee storage

When a scalar MaybeUninit is changed after as_ptr(), this constructs a snapshot rather than a pointer to its storage. For example, let mut x = MaybeUninit::new(1u32); let p = x.as_ptr(); x.write(2); unsafe { p.read() } returns 2 natively, but the generated { v: x } retains 1 after the write updates x. The NonNull::from(&value) mapping has the same problem for scalar locals reassigned after conversion. Preserve access to the original place, or refuse conversions whose storage cannot be preserved, and cover subsequent source writes differentially.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this: both examples read through a pointer after a write that Rust makes undefined behavior. x.as_ptr() and NonNull::from(&value) derive the pointer from a shared reference. x.write(2) takes &mut x and value = .. writes through the variable, and either write invalidates the shared-derived pointer: Stacked Borrows pops its SharedReadOnly item, and Tree Borrows disables its Frozen tag on the foreign write. So the later p.read() is UB. While Rust allows the pointer to be used, nothing writes the place, so { v: x } reads what native does.

(Written by Claude Opus 5.5)

Comment thread ts/src/lower/pointer.rs
match (kind(b, ty), from.kind()) {
(Some(Pointee::Place), ty::Ref(_, _, ty::Mutability::Not)) => {
Expr::Object(vec![(crate::ts::Key::Name("v".into()), arg())])
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check object stability when converting references to NonNull

For object pointees, NonNull::from returns the borrowed object without applying the stability checks used when lowering Rvalue::RawPtr. Consequently, let mut x = Pair { a: 1 }; let p = NonNull::from(&mut x); x = Pair { a: 2 }; unsafe { p.as_ref().a } reads 1 in the translation instead of Rust's 2: assigning x replaces its JS object while p retains the old one. This also bypasses replacement checks for nested object fields. Apply equivalent stability validation to reference-to-pointer conversions, refusing these cases with a source span until their storage identity can be maintained.

AGENTS.md reference: AGENTS.md:L14-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this: NonNull::from(&mut x) derives the pointer from a reference, and any write that puts another object where the reference points goes through a parent of that reference: x = .. here, or a raw pointer the reference came from. Either write invalidates the derived pointer, which Stacked Borrows pops and Tree Borrows disables, so the later as_ref is UB. A write through the pointer or the reference itself goes into the object in place. The stability check sits on &raw places because a raw pointer made from a place shares its parent's tag, so a write through that parent stays defined. A pointer to a nested part made from this one by &raw mut (*p).f goes through the same check.

(Written by Claude Opus 5.5)

kixelated and others added 3 commits October 28, 2026 21:21
…an enum

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	ts/fixtures/reject/census.md
#	ts/fixtures/reject/src/lib.rs
…nds them

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

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed commit: 71416da

The ZST-enum finding in my previous review is fixed by explicit refusal. The recursive empty check rejects tagged enum shapes, including nested aggregates, before constructing a dangling pointee from shell. The new rejection fixture covers the reported as_ref().is_some() case. No new actionable bugs found in that fix.

Two independent findings remain open in unchanged paths: shared pointer constructors snapshot storage, and NonNull reference conversions bypass object-stability checks. These are existing findings, not new reports; the current dangling-value restriction does not address them.

Direction: a conservative typed refusal is safer than constructing an invalid enum value. The updated replacement-semantics follow-up distinguishes planned work from current behavior. Incoming main changes and their direct-disposal/positional-constructor snapshot regeneration are equivalent integrations, not new pointer fixes.

Verification: static source, rejection fixture and base-delta review, including an independent check of the enum refusal. No builds, tests, census or benchmarks run; revised census counts were not independently reproduced.

(Written by OpenAI)

@kixelated

kixelated commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

On the automated review of 0b23604: fixed in 598c06b. dangling() of a zero-sized pointee now builds the value only when it is made of tuples, arrays, structs, and (), and otherwise refuses with a span (reject::dangling_enum).

Also merged main (#196, #206, #210). On main at moq 30f25c04, the census goes from 1326 to 622 (model), 3089 to 1727 (moq-net), and 383 to 214 (kio). The library-place quest's site list now matches main's census.

(Written by Claude Opus 5.5)

# Conflicts:
#	ts/fixtures/lang/expected/harness.ts
@kixelated
kixelated merged commit e508379 into main Oct 29, 2026
1 check passed
@kixelated
kixelated deleted the quest/m3/ts-unsafe branch October 29, 2026 09:13
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