Repository navigation
feat(ts): a slice of one byte's place - #660
Conversation
Quest-Claim: 3a6cec3d9e621156
…back Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…slices of one byte 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>
A raw pointer names no region, so a call's result or a receiver that could hold one may keep the slice. A call outside any loop can't make a second mirror, so a live holder there is no longer refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NonNull, AtomicPtr, and the waker types hold a raw pointer that safe code puts in and gets back, so a receiver or result of one may keep the slice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-place # Conflicts: # ts/src/lower/mir/call.rs
…ce's holder A callee's unsafe code can return a reference made from the slice with a lifetime its signature makes up, such as `&'static mut u8`, which names none of the slice's regions. Any result whose type could take a pointer now may hold the slice, whatever its regions; arguments stay trusted to their signatures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-place Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Design change: after review round 3's design checkpoint, the coordinator decided (while the maintainer is away, within m3's unsafe policy) that any result of a call given a mirrored slice, whose type could hold a pointer or a reference, may hold the slice whatever its lifetimes.
The next review starts a new session with (Written by Claude Opus 5.5 with high effort) |
A call into the return place that only goes on to return lowered to a direct `return`, past its target block, where the mirror of a slice of one byte given to it is written back. Such a call now returns through that block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head 731eb51 against base eec5aa9.
One P2 correctness finding: owner can follow a reference's original local borrow after that reference was redirected through a mutable alias, letting a mirrored slice escape through a parameter without future writeback. The inline comment includes a safe-Rust regression case and a suggested guard.
Coverage: all substantive translator/runtime changes, holder propagation and escape checks, loop liveness, direct views and buffered locals, conditional flush placement including returned call results, and the added source/generated/reject fixtures and documentation.
Verification limits: GitHub-only static inspection. I did not run the reproducer, translator, differential tests, benchmarks, or just ci; the PR's reported results were not independently reproduced. This is a comment review, not an approval.
(Written by review (OpenAI))
| let Some(at) = place.projection.iter().position(|e| e == mir::ProjectionElem::Deref) else { | ||
| return Some(place.local); | ||
| }; | ||
| if at != 0 || self.defs[place.local] != 1 { |
There was a problem hiding this comment.
[P2] Reject redirected references before resolving their original owner
defs == 1 counts direct assignments, but does not mean the reference still points at its original borrow. Unlike lent_byte/raw_byte, this traversal does not check self.mutated[place.local]. Safe code can redirect a reference through a mutable alias before storing the mirrored slice:
#[inline(never)]
fn slot<T>(p: &mut T) -> &mut T { p }
fn expose<'a>(b: &'a mut u8, out: &mut Option<&'a mut [u8]>) {
let mut local = None;
let mut target = &mut local;
*slot(&mut target) = out;
*target = Some(std::slice::from_mut(b));
}Here target still has only its initial direct definition. owner(*target) follows that stale &mut local, so the store is classified as local even though it actually writes out. Flow-insensitive holder propagation does not recover the external destination: the earlier *slot_result = out reads an unheld RHS, and slot has no second argument naming out. The caller can subsequently mutate the saved slice after expose returns, when no mirror flush remains to update b.
Conservatively stop following references that may be mutated (or track their replacement targets), and add this redirection shape to the rejection fixtures, or a differential test if supporting it. This example and the analysis are static; I have not executed the reproducer.
(Written by review (OpenAI))

Claims quest/m3/ts-byte-slice-place.md and deletes it.
Decisions
unsafe fn detached(s: &mut [u8]) -> &'static mut u8that launders the slice's lifetime, so a write through its result landed only in the mirror. Decided by the coordinator on 2026-11-07 while the maintainer was away, within m3's unsafe policy (quest/m3/README.md: safe Rust is the contract). ✅ Any result that could hold a pointer or a reference may hold the mirrored slice, whatever its lifetimes, which closes every escape through a result. Accepted cost: extra no-op write-backs, and a refusal with a span where such a result is stored through a parameter, returned, or kept across loop rounds. Arguments stay trusted to their signatures, as the thread-local case below is: onlyunsafecode that fakes a lifetime can break that, and the policy leavesunsafeoutside the contract. (Rejected: merging as is, which leaves a silent stale byte through a result; treating a callee that is or reachesunsafeas keeping the slice everywhere, which scans whole call graphs and refuses generic anddyncalls; giving mirrored slices only to std's functions, which refuses the quest's measured case, a field read through a crate reader'sread_exact.)Problem
A
&mut [u8]is anrs.ByteSlice, a view of aUint8Array, while a&mut u8is anrs.Placewherever the byte is: a JS local, an object's field, an element. Soslice::from_mut,array::from_mut, andOption::as_mut_sliceof a byte were refused: noUint8Arraycould view the byte where the caller holds it.Approach
Three forms, each picked where the body shows it applies:
Uint8Array: an element of an array, slice, orVecof bytes (by a place, a reborrow of one, orindex_mut). The slice isnew rs.ByteSlice(data, index, 1).&mutreaches one of these calls. It reuses moq-dev/rust2#640'sSlot::Byte, aUint8Array(1), which the slice then views.Option's payload, or one a&mut u8parameter points at.rs.byteMirror(p)copies the byte into anrs.ByteMirror, or views it ifpis anrs.AtIndexinto aUint8Arrayat run time.rs.byteMirrorFlushwrites the copy back top, but only if the byte changed since it was last written there. Rust reaches the byte only through the slice while the slice is in use. Once nothing uses the slice, the copy never changes again, so a later write-back writes nothing over a later write.The translator (
ts/src/lower/mir/mirror.rs) finds, for each call making a mirror:Because write-backs are conditional, over-estimating holders only costs no-op calls. A call's result may hold the slice whenever its type could take a pointer, whatever its lifetimes, since a callee's unsafe code can make one up. An argument's memory takes it only where the callee's signature lets borrow checking allow it. A late-bound region in the given argument reaches only arguments that name it. A type or region the caller picks may reach any argument that could take a pointer. For both, std types count only through their type arguments and regions, since their own pointers are private, so
&mut Vec<u8>takes nothing and anio::Result<()>result holds nothing. The exceptions areNonNull,AtomicPtr, and the waker types, whose safe API puts in and gives back a raw pointer. A raw pointer names no region, so an argument pointing at memory that could hold one may keep the slice too. The existing raw-pointer trace inholders.rswas too conservative here: it refused a crate function generic overT, and std code calling the crate's trait impls.Still refused, each with a span:
.await. Its copy would never be written back. This includes a result of a call given one, of a type that could take a pointer, even one whose lifetime says it can't hold the slice.Vec. One variable per call holds it. This applies only to a call in a loop.Measurement
The quest asked what each form costs a hot
&mut u8. The loop isread_exact(slice::from_mut(&mut byte))over 1 MiB, one byte per call, into a crate-local cursor; std'sio::Readfor&[u8]doesn't translate. CPU time fromprocess.cpuUsage, Node 24, median of 7 rounds of 20 runs;perfisn't installed on the host, so no instruction counts.[u8; 1]array, thers.ByteSliceover aUint8Arrayevery other byte slice is&mut u8parameter (mirror)A buffered variable costs the same as the array, so variables take that form. A mirror adds about 35 to 45 ns per call: the
rs.placethe&mut u8already was, aUint8Array(1), and the write-back. Allocating withnew Uint8Array(1)rather thanUint8Array.ofhalved the mirror's extra cost (85 → 56 ns). Reusing a call's previous mirror in a loop saved about 12% on the field case and wasn't kept.The quest's
rs.ByteSlicewhose storage can be anrs.Placewasn't built as such. Translated code and about 250 runtime sites reads.data[s.start + i]inline, and runtime functions callUint8Arraymethods ondata. Every byte slice would have paid for a second storage kind, or for aProxy, which breaks those methods. The mirror is that idea bounded to the slices that need it.Choices made without asking
Uint8Array, mirrors elsewhere, per the measurement above.Option::as_mut_sliceof a byte, refused by the same message, translates by the same mirror.holders.rs's trace refuse ordinary generic code.io::Result<()>a holder, and refuse?afterread_exactinto a field, since the error would reach the return place. A crate type erased into anio::Errorcould still carry a reference whose lifetime unsafe code made up: the same trust as the arguments.raw_carrierinholders.rs:NonNull,AtomicPtr,RawWaker,Waker,LocalWaker, andContext. A waker's data can't point at a byte here anyway: the translation refuses a byte pointer cast to*const ()at run time.&mut u8parameter's, stays refused, with no follow-up: its caller would need to know the slice it gets is a copy, and no consumer returns one.Validation
lang::conversionsrunslent_oneandlent_throughforu8too, which skipped it before. That covers a variable, a field, aVecelement, and&mut &mut, throughmem::replaceandmem::swap, cells, nested arrays, andclone_from.conversions::byte_places, covers:Option's (SomeandNone), and aMaybeUninit's;&mut selfmethods,&mut u8parameters, and&mut &mut;Vecof two,mem::swap, andcopy_from_slice;&mut Option<*mut u8>, and stashed asNonNull;&'static mut u8anunsafe fnmakes from the slice, written through;Okand asErr, what a call given one returns.NonNullreceivers (153), and trusting a result's lifetimes, as before the decision above (153).&'static strresult of a call given one, stored through a parameter and kept across runs.just ci(fmt, clippy, every fixture, the runtime tests) passes at 731eb51, the branch merged withmain.Impact
&mutreaches one is now aUint8Array(1)in generated code.rs.ByteMirror,rs.byteMirror, andrs.byteMirrorFlush. A hand-written caller still gets anrs.ByteSlice. No breaking change, no wire change.Follow-ups
None.
Models
Size: S
Implementer: Claude Code, claude-opus-5-5, high effort
Review
Rewritten by
quest reviewafter each round.01a1b7f9-2174-7500-a0a8-258485c1c4ad, round 2No remaining defects found; the forwarding fix preserves writeback and results for all 65,536 byte pairs checked, though full CI was not rerun.ts/src/lower/mir/mirror.rs:145:[P2] Flush byte mirrors before forwarding a tail-call result;ts/src/lower/mir/mirror.rs:164 disables early returns when successor writeback is due, and generated conversions.ts:8330 flushes before both returns, with both paths covered by the added differential case.