Skip to content

feat(net): BigInt-free varint codec, internal U64, and checked Varint.decode - #4454

Merged
kixelated merged 8 commits into
mainfrom
quest/m1/rs2ts/js-varint
Sep 29, 2026
Merged

kixelated merged 8 commits into
mainfrom
quest/m1/rs2ts/js-varint

Conversation

@kixelated

@kixelated kixelated commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

js/net converted every varint through BigInt: encodeTo and encodeLeadingOnesTo called BigInt(v) even for a 1-byte value, and every leading-ones decode and every 8-byte QUIC decode went BigInt then Number(). The rs2ts line also needs a TypeScript type for Rust's u64 that holds more than 2^53 without BigInt on the hot path. And Varint.decode silently rounded values above 2^53.

Quest: quest/m1/rs2ts/js-varint.md (deleted here, per the questline's explicit exception that lets this additive piece land on main).

Approach

  • js/net/src/util/varint.ts (new, package-internal): the QUIC and leading-ones codec on two u32 halves. Decoders return the low half and leave the high half in a module scratch, so a read allocates nothing. Encoders size first, then write into an exact-size view, one allocation per encode as before.
  • js/net/src/util/u64.ts (new, package-internal) holds U64, a generic unsigned 64-bit integer as an immutable pair of u32 halves. Varints are only its wire encoding. rs2ts will map every Rust u64 to it.
    • fromNumber/toNumber throw above 2^53 - 1, or on negative or fractional input.
    • fromBigInt/toBigInt span the full u64 range.
    • Also compare, equals, add(delta) (throws on overflow), toString, ZERO, and MAX.
    • Leading-ones encodes the full range through the 9-byte 0xFF form. The QUIC form can't hold the top of the range, so its encoder throws there rather than truncating.
  • One fast path in Cursor.u53 for both formats. Their 1 and 2-byte forms share a shape: a 1-byte varint is the byte, and a 2-byte one is the low 6 bits plus the next byte. Only the first-byte bound moves (0x40/0x80 for QUIC, 0x80/0xc0 for leading-ones). A third tier covers the rest of the forms up to 4 bytes (QUIC's 30 bits, leading-ones' 28), which need no upper half. Everything longer takes the general decode. The Cursor picks the format once, in its constructor, through isLeadingOnes(version). feat(lite): moq-lite-07 switches to 64-bit leading-ones varints #4455 (lite-07 to leading-ones) can drop its own u53 fast path on rebase and only widen isLeadingOnes.
  • Cursor/Reader/Writer (internal stream.ts): u53/u62 run on the halves codec, with no BigInt until u62 hands back its bigint. New varint() reads and writes a varint as a U64, which is what rs2ts will emit.
  • Every number-returning varint path now throws above 2^53 - 1 through one check (toNumber in util/varint.ts): Varint.decode, Cursor.u53, and U64.toNumber.
  • @moq/loc skips unknown even-typed properties with decodeBigInt, since their values may not fit a number. The timestamp and timescale properties still decode as numbers and now throw past 2^53 - 1. @moq/hang's legacy container throws on a timestamp past 2^53 - 1 instead of rounding it.
  • Interop: varint_interop in moq-net hands Rust's QUIC and leading-ones encodings of every size boundary (plus the 2^53 edge of a number, and Rust's largest encodable value) to test/interop/varint.ts. That script decodes them into U64, checks the number conversion, and returns js/net's encodings, which Rust requires to match byte for byte. It runs first in just test interop, and a mutated JS encoder fails it. Values Rust can't yet represent are covered by JS-only tests.

Impact

  • Public API (@moq/net): no new exports. The Varint namespace doc comment now names both formats.
  • Behavior change: Varint.decode throws a RangeError ("value larger than 53-bits: N") for values above 2^53 - 1, where it used to round. Use Varint.decodeBigInt for those. Reader.u53 already threw; its error is now a RangeError too.
  • Behavior: the Varint.* encoders throw RangeError instead of Error on overflow (same messages), and throw on fractional input before any BigInt conversion.
  • @moq/loc: a frame with an unknown property above 2^53 now decodes instead of silently rounding the skipped value. A timestamp or timescale above 2^53 - 1 throws.
  • @moq/hang (legacy container): a timestamp above 2^53 - 1 throws.
  • Internal: Cursor.varint(), Reader.varint(), Writer.varint(), and U64 in util/u64.ts.
  • Wire: none.
  • Bundle (bun build --minify --target browser of @moq/net, gzip -9): 85,762 B -> 86,017 B (+255 B, +0.3%).

Benchmarks

Only browser performance matters, so node (V8, as in Chrome) is the primary signal and bun (JavaScriptCore) is secondary.

Same machine, main vs branch, alternating runs (8 per side), min of each run's 9 reps, ns per varint. From js/net/bench/varint.ts (new, wired into nightly), which warms every case before timing so the first row doesn't measure JIT warmup. Each format is measured at the largest number value of its own 1, 2, 4, and 8-byte forms. Decode goes through Cursor.u53(), encode through the public encodeTo/encodeLeadingOnesTo.

format size op node main node branch bun main bun branch
quic 1-byte decode 10.9 9.6 4.8 4.1
quic 2-byte decode 10.7 9.3 5.3 5.8
quic 4-byte decode 10.4 10.5 5.5 5.9
quic 8-byte decode 157.6 18.9 92.3 11.4
quic 1-byte encode 48.6 28.6 54.0 40.3
quic 2-byte encode 106.0 29.2 88.1 40.7
quic 4-byte encode 105.3 29.3 86.9 45.0
quic 8-byte encode 141.2 29.7 94.9 46.4
leading-ones 1-byte decode 161.1 9.6 145.8 3.9
leading-ones 2-byte decode 173.1 9.3 187.7 5.8
leading-ones 4-byte decode 208.1 10.8 262.7 6.3
leading-ones 8-byte decode 529.1 22.5 409.4 13.9
leading-ones 1-byte encode 126.2 28.7 83.5 42.9
leading-ones 2-byte encode 169.3 29.0 150.4 42.7
leading-ones 4-byte encode 203.0 29.6 211.0 45.5
leading-ones 8-byte encode 266.4 30.2 295.0 53.6

On node, every 1 and 2-byte row matches or beats main. Bun's QUIC 2-byte decode is 0.5 ns (about 10%) slower; that gap is accepted.

End to end, js/net/bench/frames.ts (bun, median of 3 alternating runs, ns per frame through a group stream into a reader):

protocol frame bytes chunk bytes frames/chunk main branch delta
ietf (draft-19) 16 1200 50 1380 1051 -24%
ietf (draft-19) 16 65536 3000 (burst) 937 556 -41%
ietf (draft-19) 1000 65536 50 1245 694 -44%
lite 16 1200 50 606 581 -4%
lite 16 65536 3000 (burst) 532 555 +4%
lite 1000 65536 50 534 579 +8%

Lite rows land between -13% and +23% from run to run, the same spread an earlier run showed with main's exact lite decode code. The per-varint numbers above are the reliable signal for lite.

Public API tradeoffs

Decided: U64 stays internal. Nothing in watch, publish, hang, or the demos needs it, and rs2ts output lives inside js/net. Exporting it later is additive on main, but un-exporting would be a break.

Item Pros Cons Cost to watch/publish/hang Alternatives
U64 class (u32 halves) Exact to 2^64, no BigInt, cheap compare. Every Rust u64 maps to it one to one. One allocation per varint() read. Values need toNumber() at the edge. None while internal. If exported and used for group or sequence IDs, every caller converts. Plain number + bigint fallback (a union type everywhere, silent rounding risk). A {hi, lo} object literal (same cost, no methods or validation). bigint everywhere (the "main" columns above: 10-40x slower).
fromNumber/toNumber (throw past 2^53 - 1) Fails loud instead of rounding. Throws where Number() silently rounded. Varint.decode now shares the check (see Impact). hang/loc updated. Clamp or round (hides bugs).
fromBigInt/toBigInt Bridges existing bigint fields (IETF request IDs). BigInt cost at that edge. None Drop until a caller needs it. Kept because u62 callers and tests use the bigint form.
compare/equals/add Sequence logic (request ID += 2, group ranges) without converting. Methods instead of operators. None increment() only (does not cover +2). Operators via valueOf (silently lossy, rejected).
Cursor/Reader/Writer.varint() The shape rs2ts will call. u53/u62 are unchanged. Three near-duplicate readers (u53, u62, varint). None (stream.ts is not exported) Migrate IETF bigint fields to U64 now (large churn in code the generated IETF session replaces).
Varint.decode throwing past 2^53 - 1 One rule for every number-returning varint API. No silent corruption. Breaks a caller that relied on rounding. loc skips unknown properties at full width. hang legacy throws on an impossible timestamp. Keep rounding (documented but lossy). Add a separate checked variant (two APIs for one job).

Alternatives

  • Export U64 from @moq/net now (the quest's original "additive" plan): rejected until a consumer needs it.
  • A varint-specific VarInt type: named U64 instead, since rs2ts stays generic and maps every Rust u64 (including the one inside Rust's VarInt) to it.
  • A 1-2 byte fast path only: node's QUIC 4-byte decode then regressed from 10 to 16 ns (lite timestamp deltas are 4-byte), so the fast path also covers every form up to 4 bytes.

Follow-ups

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 4 commits September 28, 2026 19:08
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

Copy link
Copy Markdown
Collaborator Author

Quest outcome: implemented, left as a draft for the maintainer's API call.

Local checks: just check and just test interop pass. varint_interop fails against a mutated JS encoder, so the check is live.

Open decisions:

  1. Keep VarInt internal (this PR) or export it from @moq/net. Recommend internal until watch, publish, or hang needs it, since exporting later is additive.
  2. Rust VarInt is 62-bit while the JS one is 64-bit. Recommend the dev-line VarInt codec quest decide between widening Rust and rejecting leading-ones values past 2^62 - 1 on decode (today it builds one above MAX unchecked).
  3. The ~2 ns QUIC 1-4 byte decode cost versus main's inline fast path. Recommend accepting it: the fast path showed no end-to-end effect and slowed the other rows.

(Written by Claude Opus 5.5)

kixelated and others added 2 commits September 28, 2026 21:22
…t 2^53

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title feat(net): BigInt-free varint codec and an internal 64-bit VarInt feat(net): BigInt-free varint codec, internal 64-bit VarInt, and checked Varint.decode Sep 29, 2026
kixelated and others added 2 commits September 28, 2026 21:46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title feat(net): BigInt-free varint codec, internal 64-bit VarInt, and checked Varint.decode feat(net): BigInt-free varint codec, internal U64, and checked Varint.decode Sep 29, 2026
@kixelated
kixelated marked this pull request as ready for review September 29, 2026 04:54
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 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-09-29T04:57:18.633936Z 1b10d20 Draft marked ready
ℹ️ 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.

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: d1620034-fd16-47ca-b07d-b2764d60fb4c

📥 Commits

Reviewing files that changed from the base of the PR and between 75d93c3 and 1b10d20.

📒 Files selected for processing (23)
  • .github/workflows/nightly.yml
  • js/hang/src/container/consumer.test.ts
  • js/loc/src/index.test.ts
  • js/loc/src/index.ts
  • js/net/bench/varint.ts
  • js/net/src/index.ts
  • js/net/src/stream.test.ts
  • js/net/src/stream.ts
  • js/net/src/util/u64.test.ts
  • js/net/src/util/u64.ts
  • js/net/src/util/varint.test.ts
  • js/net/src/util/varint.ts
  • js/net/src/varint.test.ts
  • js/net/src/varint.ts
  • quest/m1/rs2ts/README.md
  • quest/m1/rs2ts/ietf.md
  • quest/m1/rs2ts/js-varint.md
  • quest/m1/rs2ts/lite.md
  • quest/m1/rs2ts/translator.md
  • rs/moq-net/src/test_interop.rs
  • test/interop/README.md
  • test/interop/varint.ts
  • test/justfile
💤 Files with no reviewable changes (1)
  • quest/m1/rs2ts/js-varint.md

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.


Walkthrough

The change adds checked U64 values and shared codecs for QUIC and leading-ones varints. Stream APIs now read and write U64 values, and numeric decoding rejects values above JavaScript’s safe integer range. LOC decoding handles timestamp properties explicitly and skips large unknown properties using bigint decoding. The change also adds Rust-JavaScript interoperability tests and a nightly benchmark.

Priority: ➖ Normal

Severity of issue fixed: Low

Merge Risk: ⚪ Minimal · up to 1b10d

The varint changes are ready for normal checks; no actionable merge-blocking issue remains identified.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 1b10d

The changed decoding paths retain format selection, bounded read requests, and checks against unsafe number conversion. No newly exposed security failure was established, though the behavior of future callers using full-width values will depend on their field-specific checks.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • observed — The changed boundary processes peer-supplied network bytes. Cursor checks complete varint lengths before decoding, and Reader caps requested decode reads at 64 MiB; this is a read-request limit, not a claim that all transport buffering is bounded.

Trust Boundaries and Controls

  • observed — The stream boundary distinguishes exact U64 values from safe JavaScript numbers rather than silently narrowing a wide decoded value. Protocol version determines which wire-format decoder is used.

Resilience and Maintainability Implications

  • observed — Truncated reads do not commit bytes, and supported codec branches set or reset the scratch high half before a complete value is returned. The inspected transitions did not show stale high halves reaching a later U64 result.

Hardening Proposals

  • proposed — When callers adopt Reader.varint for lengths, identifiers, or other consequential fields, apply field-specific limits before using the full-width value for allocation or authority decisions; the generic wire decoder does not establish those domain limits.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 30 functions across 15 files. (7 skipped: 7…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: a BigInt-free varint codec, an internal U64 type, and checked Varint.decode behavior.
Description check ✅ Passed The description directly explains the implementation, behavior changes, API decisions, interoperability tests, benchmarks, and follow-ups covered by the changeset.
✨ Finishing Touches
✨ Simplify code
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary.

Reviewed the codec, U64, the Cursor.u53 tiers, and the loc/hang edges myself; Codex and CodeRabbit had no findings. Required checks and Interop pass (the new varint_interop step included). OBS (macOS) is still queued for a runner and is unrelated.

Decisions taken:

  • U64 stays package-internal; @moq/net gains no exports.
  • Varint.decode throwing past 2^53 - 1 instead of rounding is treated as a fail-loud fix, not a break: it only changes values that were already corrupted, and Reader.u53 already threw.
  • The U64 name matches the questline's "rs2ts stays generic, u64 maps to one 64-bit type" decision. The body's follow-up now points at refactor(net)!: concrete u64 varint codec for rs2ts #4463, which keeps and widens Rust's VarInt, instead of saying Rust drops it.

Ordering with related PRs:

(written by Claude Opus 5.5)

@kixelated
kixelated merged commit 8ddae84 into main Sep 29, 2026
10 of 11 checks passed
@kixelated
kixelated deleted the quest/m1/rs2ts/js-varint branch September 29, 2026 06:14
@moq-bot moq-bot Bot mentioned this pull request Sep 29, 2026
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