Repository navigation
feat(ts)!: translate String as rs.String, holding std's exact capacity - #574
Conversation
Quest-Claim: 235c0d59cc4969ea
Ops 17 to 20 of the codec fixture's bench code a broadcast path as moq-net's Encoder and Decoder do, join and strip one as Path::concat and strip_prefix do, and key a HashMap by String, each over an ASCII path and one with multi-byte chars. str-routes.py rewrites a translation into two models of a String for just ts bench --route: a JS string with its UTF-8 length and capacity, and UTF-8 bytes in an rt.ByteVec. Prototype only, for the decision in the PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pacity # Conflicts: # ts/fixtures/codec/expected/bench.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pacity # Conflicts: # ts/bench/main.ts # ts/bench/moq.ts # ts/bench/workloads.ts # ts/fixtures/codec/expected/bench.ts # ts/fixtures/codec/src/bench.rs
A String is a JS string beside its UTF-8 length and capacity, edited in place, so capacity reads, into_bytes, Vec::from, and Bytes::from give std's. &str and Box<str> stay JS strings. Lifts the Bytes capacity refusal and drops the utf8 prototype. 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>
String::from of a reference returned the source object, so mutating the copy changed it. A panic hook's String payload is now the String panic_any was given, or one panic! writes its message into from empty, as std's is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pacity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shim calling std's mapping, which takes what a `&mut` points at, passes that, as a call's operands do. #593's forwarding through `&mut` impls relies on it, so `&mut &mut String` as a `dyn Write` passed its place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pacity # Conflicts: # quest/m3/ts-slice-object.md # ts/README.md # ts/fixtures/codec/expected/bench.ts # ts/fixtures/codec/expected/harness.ts # ts/fixtures/lang/arrays/expected/arrays.ts # ts/fixtures/lang/ascii/expected/ascii.ts # ts/fixtures/lang/borrows/expected/borrows.ts # ts/fixtures/lang/boxes/expected/boxes.ts # ts/fixtures/lang/capacity/expected/capacity.ts # ts/fixtures/lang/cells/expected/cells.ts # ts/fixtures/lang/chars/expected/chars.ts # ts/fixtures/lang/chunks/expected/chunks.ts # ts/fixtures/lang/clones/expected/clones.ts # ts/fixtures/lang/collections/expected/collections.ts # ts/fixtures/lang/conversions/expected/conversions.ts # ts/fixtures/lang/cows/expected/cows.ts # ts/fixtures/lang/defaults/expected/defaults.ts # ts/fixtures/lang/durations/expected/durations.ts # ts/fixtures/lang/dyns/expected/dyns.Tri.ts # ts/fixtures/lang/dyns/expected/dyns.ts # ts/fixtures/lang/dyns/expected/stds.ts # ts/fixtures/lang/edits/expected/edits.ts # ts/fixtures/lang/errors/expected/errors.ts # ts/fixtures/lang/fields/expected/fields.ts # ts/fixtures/lang/floats/expected/floats.ts # ts/fixtures/lang/folds/expected/folds.ts # ts/fixtures/lang/futures/expected/futures.ts # ts/fixtures/lang/hashes/expected/hashes.ts # ts/fixtures/lang/heap/expected/heap.ts # ts/fixtures/lang/ints/expected/ints.ts # ts/fixtures/lang/iters/expected/iters.ts # ts/fixtures/lang/lists/expected/lists.ts # ts/fixtures/lang/locks/expected/locks.ts # ts/fixtures/lang/nested/expected/nested.ts # ts/fixtures/lang/orders/expected/orders.ts # ts/fixtures/lang/panics/expected/located.ts # ts/fixtures/lang/panics/expected/panics.ts # ts/fixtures/lang/panics/hook/expected/hooked.Shown.ts # ts/fixtures/lang/panics/hook/expected/hooked.ts # ts/fixtures/lang/panics/hook/expected/located.ts # ts/fixtures/lang/panics/hook/expected/panics.ts # ts/fixtures/lang/refs/expected/refs.ts # ts/fixtures/lang/rings/expected/rings.ts # ts/fixtures/lang/saturates/expected/saturates.ts # ts/fixtures/lang/shapes/expected/fmt.Dropper.ts # ts/fixtures/lang/shapes/expected/fmt.Nested.ts # ts/fixtures/lang/shapes/expected/fmt.ts # ts/fixtures/lang/shapes/expected/generics.ts # ts/fixtures/lang/shapes/expected/shapes.ts # ts/fixtures/lang/shares/expected/shares.ts # ts/fixtures/lang/singles/expected/singles.ts # ts/fixtures/lang/statics/expected/statics.ts # ts/fixtures/lang/strs/expected/strs.ts # ts/fixtures/lang/utf8/expected/utf8.ts # ts/fixtures/lang/wrappers/expected/wrappers.ts # ts/fixtures/lang/wraps/expected/wraps.ts # ts/fixtures/lang/zeros/expected/zeros.ts # ts/fixtures/names/expected/lib.ts # ts/fixtures/stock/expected/buf.ts # ts/fixtures/stock/expected/harness.ts # ts/fixtures/stock/expected/maps.ts # ts/fixtures/task/expected/closures.ts # ts/fixtures/task/expected/drops.Key.ts # ts/fixtures/task/expected/drops.Wrap.ts # ts/fixtures/task/expected/drops.ts # ts/fixtures/task/expected/drops/called.ts # ts/fixtures/task/expected/drops/flattened.ts # ts/fixtures/task/expected/drops/folds.Pile.ts # ts/fixtures/task/expected/drops/folds.ts # ts/fixtures/task/expected/drops/inside.ts # ts/fixtures/task/expected/drops/iters.ts # ts/fixtures/task/expected/drops/lists.ts # ts/fixtures/task/expected/drops/objects.ts # ts/fixtures/task/expected/drops/once.ts # ts/fixtures/task/expected/drops/orders.Ranked.ts # ts/fixtures/task/expected/drops/orders.ts # ts/fixtures/task/expected/drops/released.ts # ts/fixtures/task/expected/drops/searched.ts # ts/fixtures/task/expected/futures.ts # ts/fixtures/task/expected/model.Guard.ts # ts/fixtures/task/expected/model.ts
A HashMap or HashSet keyed by String or Cow<str> hashed each key's text through the runtime's hasher into a chain, 2.3-4.0x main's JS string keys on the bench's str/keyed workloads. rs.HASH_TEXT files each key under its text as a JS map key, which the engine hashes, so a chain holds one key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…shes it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pacity # Conflicts: # ts/fixtures/lang/collections/expected/collections.ts # ts/fixtures/lang/iters/expected/core/option.ts # ts/fixtures/lang/iters/expected/iters.ts # ts/fixtures/stock/expected/harness.ts # ts/fixtures/stock/src/harness.rs
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head ed51944 against base b5f5545.
[P2] Preserve the mutable String place in BorrowMut trait shims — ts/src/lower/collections.rs:171–174. The new text branch returns .v for both Borrow and BorrowMut. For let mut s = String::from("abc"); let b: &mut dyn std::borrow::BorrowMut<str> = &mut s; b.borrow_mut().make_ascii_uppercase();, native Rust leaves s == "ABC". The synthesized method goes through dynamic::class_method → Body::call_def → this mapping, bypassing the direct MIR special case. It therefore returns a primitive string despite its rs.Place<string> return type, producing ill-typed TS and losing mutation access. Restrict this projection to immutable borrow, keep the String object for borrow_mut, and add a differential/type-check case through the trait object.
Verification limits: GitHub-only static review of production changes, relevant pinned Rust implementations, and targeted fixtures/snapshots in this 210-file PR. No translator, differential suite, census, benchmarks, or just ci run here; the fetched Queue runs were skipped. The finding is based on the source call path, not an executed reproducer.
(Written by OpenAI)
The text arm of the Borrow shims gave `borrow_mut` the String's JS text, so a write through a `&mut dyn BorrowMut<str>` read `.v` of a primitive string. Only `borrow` reads the text; `borrow_mut` lends the `rs.String`, the place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Fixed the BorrowMut finding in 1d16813. The text arm in Case 48 of the capacity fixture's (Written by Claude Opus 5.5) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Incremental review of head 7dd0c57 against current base b86a186, following the review of ed51944.
The previous P2 BorrowMut finding is addressed. The text projection in ts/src/lower/collections.rs now applies only to immutable borrow; borrow_mut retains the rs.String object. The generated of$String shim returns this.v for mutable borrowing and this.v.v for shared borrowing. Capacity fixture case 48 exercises the mutable trait object, reads through the shared trait object, and emits final text, length, and capacity. ASCII case conversion preserves UTF-8 byte length, so this mutation does not invalidate the cached length or capacity.
No new actionable findings in this focused follow-up. The merged benchmark dispatch consistently reserves op 18 for bytes/frames and moves string workloads to 19–22 in the Rust dispatcher, TypeScript dispatcher, and workload inputs. Direction: retain the trait-object regression coverage; no further change requested for the prior finding.
Verification limits: GitHub-only static inspection of the fix commit, current source and generated fixture, incremental comparison, and benchmark merge integration. This is not a fresh exhaustive review of all 221 changed files. I did not run the translator, TypeScript checks, differential suite, benchmarks, or just ci, and have not independently verified the execution results reported in the PR description.
(Written by OpenAI)
…pacity # Conflicts: # ts/fixtures/lang/dyns/expected/dyns.Over.ts # ts/fixtures/lang/dyns/expected/dyns.ts # ts/fixtures/lang/dyns/expected/lib.ts # ts/fixtures/lang/iters/expected/iters.ts
Problem
A translated
Stringwas a JSstring, which holds no capacity. SoString::capacity, and everything that takes it (into_bytes,Vec::from,Bytes::from), either guessed or was refused at translation, wherever aString's bytes might reach a read of aBytes' capacity. The quest (ts-string-capacity) makesStringa runtime type, as moq-dev/rust2#555 did forVec.Decision
Earlier revisions of this PR prototyped and measured two representations. The user settled it on 2026-11-06:
Which representation should
rs.Stringtake?js: a JS string, its UTF-8 length, and std's exact capacity.&strandBox<str>stay plain JS strings.utf8: anrs.ByteVec, with&stranrs.ByteSlice.utf8plus a lazily cached JS string.Why:
jskeeps engine string hashing and native interop (a hand-written caller still passes"name"for a&str).utf8won on encoding (3-14x overjsin the prototype), but it was 5.6-9.8x slower than main onString-keyed maps, which moq-net uses to find broadcasts, and it cost +3.1% gzip. The prototype numbers are in this PR's edit history.This narrows the quest's Goal, which named
Box<str>and&stras runtime types too. They hold no capacity, so they stay JS strings.Approach
rs.String(ts/runtime/rs/str.ts) is{ v, len, cap }: the text, its length in UTF-8 bytes, and std's capacity. It is an object, so a&mut Stringis the object, and edits (push_str,insert,truncate,retain,replace_range, ...) change it in place, checking byte offsets as std does.Capacity follows std's. Every growth step goes through
RawVec's amortized growth of aVec<u8>:reserveof what is added, orpushof each char where std pushes each.with_capacity,reserve,reserve_exact,shrink_to,shrink_to_fit, and theirtry_forms are std's. What makes a newStringgives it std's room:to_owned,clone,join, andto_stringof text, achar, abool, or an unsigned integer;format!'sestimated_capacity;replace,to_lowercase,from_utf16,from_utf8_lossy, and the rest.Bytes.
into_bytesandVec::<u8>::from(String)encode into storage of theString's capacity.String::from_utf8andfrom_utf8_uncheckedkeep the vector's room.Bytes::from(String)takes those bytes. So the translator's refusal ofBytescapacity reads (BytesMut::from(Bytes),Vec::from(Bytes),Bytes::is_unique,try_into_mut,truncate,clear) in a program that makes bytes of aStringis gone, along with the crates fixture case and the reject fixture'sbuffers::capacity_readsandstring_bytesthat proved it. The stock fixture'sbuf::stringscovers those reads against native.Maps. A
HashMap/HashSetkeyed byStringorCow<str>files each key under its text, as a JS map key the engine hashes (rs.HASH_TEXT). A&strlookup finds it the same way. Before this, each key's text went through the runtime's hasher into a chain, at 2.3-4.0x main.Cow<str>. An owned one is anrs.String.+,into_owned,clone, andclone_fromfollow std's capacity.Panics. A hook's
Stringpayload is theStringthatpanic_anywas given, or onepanic!writes its message into from empty, as std's is.Boundary. A hand-written caller passes
rs.stringFrom(text)for aString, with room for exactly its bytes, and reads.vfor its text.&strstays a JS string.Bench. The codec bench gains
str/workloads shaped on moq-net's string paths. Each runs over an ASCII path (demo/room-42/alice/camera) and one with 2-, 3-, and 4-byte chars:str/<path> encodeanddecode: 256 size-prefixed strings through moq-net'sEncoder::stringandDecoder::string;str/join/<path>:Path::concatandPath::strip_prefix;str/keyed/<path>: 256format!ed keys of aHashMap<String, _>, each looked up four times by&str.The prototype scripts under
ts/bench/prototype/are deleted.Commits since the decision:
af5887420: the translation.22a87cf80:String::from(&String)clones, and the hook payload.52a202249: a dyn shim's&mutreceiver, after moq-dev/rust2#593.e6f549267:rs.HASH_TEXT.06272388a:str::clone_into.1d168139d: a&mut dyn BorrowMut<str>of aStringlends thers.String, not its text, after the online review below.7dd0c5718) moves this PR's codec bench ops from 18-21 to 19-22, since main'sbytes/frames(moq-dev/rust2#595) took 18.Measured
Translated ÷ main's translator on the same bench source, one
just ts benchrun (seed 2495450320). Host: Ryzen 9 5900XT, shared, load about 14 on 32 threads. Measured ate6f549267; later commits don't touch these paths.fmt/linesstr/ascii encodestr/utf8 encodestr/ascii decodestr/utf8 decodestr/join/asciistr/join/utf8str/keyed/asciistr/keyed/utf8Before
rs.HASH_TEXT,str/keyedwas 3.74/3.18 (Node), 3.46/2.93 (Bun), 4.01/3.08 (Chrome), and 2.28/2.37 (Firefox).Most of
keyed's remaining gap isformat!, not the map. In Node, per key:format!("{name}/{i}")Each write into a
Stringscans the pushed text for its UTF-8 length and grows the capacity as std does. That same cost shows infmt/lines.decodeandjoingain mostly becauselenis now a field. Main'srs.stringLenandrs.stringAsBytescalledTextEncoder.encodeon every use, andpush_strconcatenated.Bundle, minified by Bun as the bench ships it (codec fixture): main 83,790 B raw / 26,444 B gzip; this PR 84,602 / 26,761 (+1.0% / +1.2%).
Impact
Consumer: moq. moq-net's paths,
Encoder::string,Decoder::string,Path::concat,Path::strip_prefix, and keying by path, translate with std's capacity. Thestr/workloads time them.Public API (breaking for hand-written callers):
Stringin a translated signature isrs.String, notstring. Callers convert withrs.stringFrom(text)and read.v.&strandBox<str>are unchanged.rs.StrCow's owned form isrs.String.rs.fmtToStringandrs.fmtFormatreturnrs.String.rs.String,rs.stringFrom, andrs.HASH_TEXT.rs.Hashing'shashmay return a string.Bytescapacity reads listed above.Quests:
Review
Rewritten by
quest reviewafter each round.01a1b390-07e9-7402-84ca-ac570955000f, round 5No in-scope defects found; the merge preserves String handling, and 32 focused cases covering generic fields, trait objects, and String stacks match native Rust.Validation
just cipasses at872f46f83.bun bench/main.ts --rounds 1 --sample-ms 1in the bench shell matched native on every route of the renumberedstr/andbytes/framesworkloads. Its timings are not measurements.Models
Follow-ups
Stringwrites infmt. Each write scans its text's UTF-8 length; a literal's length could be emitted at translation.fmt/linesruns at 0.90-1.25x main, andformat!of a key at 2.4x in Node.(Written by Claude Opus 5.5)
🤖 Generated with Claude Code