Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions js/net/src/lite/announce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Path from "../path.ts";
import type { Reader, Writer } from "../stream.ts";
import * as Message from "./message.ts";
import { type Origin, OriginSchema } from "./origin.ts";
import { Version } from "./version.ts";
import { hopsFixedWidth, Version } from "./version.ts";

// Must match the MAX_HOPS in Rust's model/origin.rs. Broadcasts with longer
// hop chains are rejected; this keeps loop-detection bounded and rejects
Expand Down Expand Up @@ -35,10 +35,15 @@ export class Announce {
await w.u53(this.hops.length);
break;
default:
// Lite04+: hop count + individual Origin varints.
// Lite04+: hop count + individual Hop IDs. Lite05+ carries each id
// fixed-width (64-bit); Lite04 used a 62-bit varint.
await w.u53(this.hops.length);
for (const origin of this.hops) {
await w.u62(origin);
if (hopsFixedWidth(version)) {
await w.u64(origin);
} else {
await w.u62(origin);
}
}
break;
}
Expand All @@ -63,12 +68,13 @@ export class Announce {
break;
}
default: {
// Lite04+: hop count + individual Origin varints.
// Lite04+: hop count + individual Hop IDs. Lite05+ carries each id
// fixed-width (64-bit); Lite04 used a 62-bit varint.
const count = await r.u53();
if (count > MAX_HOPS) throw new Error(`hop count ${count} exceeds maximum ${MAX_HOPS}`);
hops = [];
for (let i = 0; i < count; i++) {
hops.push(OriginSchema.parse(await r.u62()));
hops.push(OriginSchema.parse(hopsFixedWidth(version) ? await r.u64() : await r.u62()));
}
break;
}
Expand All @@ -92,8 +98,8 @@ export class Announce {

export class AnnounceInterest {
prefix: Path.Valid;
// 62-bit Origin id of the peer asking for announces. Zero means "no exclusion".
// Must be a bigint: peer origins are up to 62 bits and overflow u53.
// Hop ID of the peer asking for announces. Zero means "no exclusion".
// Must be a bigint: peer origins are up to 64 bits and overflow u53.
excludeHop: bigint;

constructor(prefix: Path.Valid, excludeHop: bigint = 0n) {
Expand All @@ -109,8 +115,12 @@ export class AnnounceInterest {
case Version.DRAFT_03:
break;
default:
// Lite04+: exclude_hop field (u62 varint).
await w.u62(this.excludeHop);
// Lite04+: exclude_hop Hop ID. Lite05+ fixed-width (64-bit); Lite04 a 62-bit varint.
if (hopsFixedWidth(version)) {
await w.u64(this.excludeHop);
} else {
await w.u62(this.excludeHop);
}
break;
}
}
Expand All @@ -124,7 +134,7 @@ export class AnnounceInterest {
case Version.DRAFT_03:
break;
default:
excludeHop = await r.u62();
excludeHop = hopsFixedWidth(version) ? await r.u64() : await r.u62();
break;
}
return new AnnounceInterest(prefix, excludeHop);
Expand Down Expand Up @@ -211,12 +221,13 @@ export class AnnounceOk {
}

async #encode(w: Writer) {
await w.u62(this.origin);
// lite-05 carries the Hop ID fixed-width (64-bit).
await w.u64(this.origin);
await w.u53(this.active);
}

static async #decode(r: Reader): Promise<AnnounceOk> {
const raw = await r.u62();
const raw = await r.u64();
// A zero responder id is never legitimate; it would stamp a placeholder onto chains.
if (raw === 0n) throw new Error("announce ok origin must be non-zero");
const origin = OriginSchema.parse(raw);
Expand Down
20 changes: 13 additions & 7 deletions js/net/src/lite/origin.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
import * as z from "zod/mini";

/**
* A relay origin id, encoded as a 62-bit varint on the wire.
* A relay origin id (Hop ID), randomly assigned and carried in the hop chain.
*
* The {@link OriginSchema} validates any incoming value and brands it so the
* type system enforces "only validated origins flow into hop lists." Internal
* code that synthesizes an id (e.g. {@link randomOrigin}) uses
* `OriginSchema.parse(...)` to produce a branded value from the raw bigint.
* On the wire lite-05+ encodes it as a fixed-width 64-bit integer (the full
* 64-bit space); older versions used a 62-bit varint. The {@link OriginSchema}
* validates any incoming value and brands it so the type system enforces "only
* validated origins flow into hop lists." Internal code that synthesizes an id
* (e.g. {@link randomOrigin}) uses `OriginSchema.parse(...)` to produce a
* branded value from the raw bigint.
*/
export const OriginSchema = z
.bigint()
.check(z.refine((value) => value >= 0n && value < 1n << 62n, "Origin must be a non-negative 62-bit integer"))
.check(z.refine((value) => value >= 0n && value < 1n << 64n, "Origin must be a non-negative 64-bit integer"))
.brand("Origin");

export type Origin = z.infer<typeof OriginSchema>;

/**
* Generate a fresh origin with a random non-zero 62-bit id.
* Generate a fresh origin with a random non-zero id.
*
* Masked to 62 bits: the same id is encoded as our self/exclude Hop ID, which on
* lite-04 is still a 62-bit varint (lite-05 carries the full 64-bit space). Staying
* within 62 bits keeps one generated id valid across both negotiated versions.
*
* `crypto.getRandomValues` is overkill for best-effort loop detection, but
* used for slightly better distribution than `Math.random` at negligible cost.
Expand Down
17 changes: 17 additions & 0 deletions js/net/src/lite/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ export const Version = {

export type Version = (typeof Version)[keyof typeof Version];

/// Whether Hop IDs (ANNOUNCE / ANNOUNCE_OK) and `Exclude Hop` (ANNOUNCE_INTEREST) are
/// carried as fixed-width 64-bit integers rather than varints. Added in lite-05: Hop IDs
/// are randomly assigned, so a varint would almost never be shorter, and the fixed width
/// buys the full 64-bit space (a varint caps at 62 bits).
export function hopsFixedWidth(version: Version): boolean {
// Explicitly list older versions so future versions default to fixed-width.
switch (version) {
case Version.DRAFT_01:
case Version.DRAFT_02:
case Version.DRAFT_03:
case Version.DRAFT_04:
return false;
default:
return true;
}
}

/// The WebTransport subprotocol identifier for moq-lite.
/// Version negotiation still happens via SETUP when this is used.
export const ALPN = "moql";
Expand Down
28 changes: 28 additions & 0 deletions js/net/src/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,34 @@ test("Reader u62 varint decoding", async () => {
expect(await reader.done()).toBe(true);
});

test("Reader u64 fixed-width decoding", async () => {
// Fixed-width always uses 8 bytes, including the full 64-bit space a varint can't reach.
const testValues = [0n, 1n, 63n, 1n << 53n, (1n << 62n) - 1n, 1n << 62n, (1n << 64n) - 1n];

const { stream, written } = createTestWritableStream();
const testWriter = new Writer(stream);

for (const value of testValues) {
await testWriter.u64(value);
}

testWriter.close();
await testWriter.closed;

const data = concatChunks(written);
// Every value occupies exactly 8 bytes.
expect(data.byteLength).toBe(testValues.length * 8);

const reader = new Reader(undefined, data);

for (const expectedValue of testValues) {
const actualValue = await reader.u64();
expect(actualValue).toBe(expectedValue);
}

expect(await reader.done()).toBe(true);
});

test("Reader string decoding", async () => {
const testStrings = ["hello", "🎉", "", "world with spaces", "multi\nline\nstring"];

Expand Down
21 changes: 21 additions & 0 deletions js/net/src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,16 @@ export class Reader {
return result;
}

// Fixed-width big-endian 64-bit integer (8 bytes), as opposed to the variable-length
// u62 varint. Used for randomly-assigned Hop IDs on lite-05+, where a varint would
// almost never be shorter and the fixed width buys the full 64-bit space.
async u64(): Promise<bigint> {
await this.#fillTo(8);
const slice = this.#slice(8);
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength);
return view.getBigUint64(0);
}

// Returns a Number using 53-bits, the max Javascript can use for integer math.
// Values > 2^53-1 are coerced to a Number (precision is lost) and logged. We
// downgrade overflow from throw to warn so a stray u64 field on the wire (e.g.
Expand Down Expand Up @@ -294,6 +304,11 @@ export class Writer {
await this.write(setUint16(this.#scratch, v));
}

// Fixed-width big-endian 64-bit integer (8 bytes); counterpart to {@link Reader.u64}.
async u64(v: bigint) {
await this.write(setBigUint64(this.#scratch, v));
}

async i32(v: number) {
if (Math.abs(v) > MAX_U31) {
throw new Error(`overflow, value larger than 32-bits: ${v.toString()}`);
Expand Down Expand Up @@ -373,6 +388,12 @@ function setInt32(dst: ArrayBuffer, v: number): Uint8Array {
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}

function setBigUint64(dst: ArrayBuffer, v: bigint): Uint8Array {
const view = new DataView(dst, 0, 8);
view.setBigUint64(0, BigInt.asUintN(64, v));
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}

// Returns the next stream from the connection
export class Readers {
#reader: ReadableStreamDefaultReader<ReadableStream<Uint8Array>>;
Expand Down
57 changes: 52 additions & 5 deletions rs/moq-net/src/lite/announce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ pub struct AnnounceInterest<'a> {
// Request tracks with this prefix.
pub prefix: Path<'a>,
// If non-zero, the publisher SHOULD skip announces whose hop IDs contain this value.
// Carried as a Hop ID (varint on Lite04, fixed-width 64-bit on Lite05+); 0 means no exclusion.
pub exclude_hop: u64,
}

Expand All @@ -105,7 +106,8 @@ impl Message for AnnounceInterest<'_> {
let prefix = Path::decode(r, version)?;
let exclude_hop = match version {
Version::Lite01 | Version::Lite02 | Version::Lite03 => 0,
_ => u64::decode(r, version)?,
// Decode as a Hop ID so the fixed-width vs varint choice tracks the version.
_ => Origin::decode(r, version)?.id,
};
Ok(Self { prefix, exclude_hop })
}
Expand All @@ -115,7 +117,7 @@ impl Message for AnnounceInterest<'_> {
match version {
Version::Lite01 | Version::Lite02 | Version::Lite03 => {}
_ => {
self.exclude_hop.encode(w, version)?;
Origin::from(self.exclude_hop).encode(w, version)?;
}
}

Expand Down Expand Up @@ -317,6 +319,49 @@ mod tests {
assert_eq!(round_trip(&msg), msg);
}

#[test]
fn announce_ok_full_64_bit_origin() {
// lite-05 carries the Hop ID fixed-width, so the full 64-bit space round-trips
// (a value a 62-bit varint could not hold).
let msg = AnnounceOk {
origin: Origin { id: u64::MAX },
active: 1,
};
assert_eq!(round_trip(&msg), msg);
}

#[test]
fn announce_ok_origin_is_fixed_width() {
let mut buf = bytes::BytesMut::new();
AnnounceOk {
origin: Origin { id: 1 },
active: 0,
}
.encode(&mut buf, Version::Lite05Wip)
.unwrap();
// size(1) + origin(8 fixed) + active(1 varint) = 10 bytes; a varint origin id of 1
// would have been a single byte, giving 3.
assert_eq!(buf.len(), 10);
}

#[test]
fn announce_interest_exclude_hop_round_trip() {
// A value above the old 62-bit varint ceiling only survives the fixed-width lite-05 path.
for &exclude_hop in &[0u64, 7, 1u64 << 53, u64::MAX] {
let msg = AnnounceInterest {
prefix: Path::new("foo/bar"),
exclude_hop,
};
let mut buf = bytes::BytesMut::new();
msg.encode(&mut buf, Version::Lite05Wip).unwrap();
let mut slice = &buf[..];
let got = AnnounceInterest::decode(&mut slice, Version::Lite05Wip).unwrap();
assert!(slice.is_empty(), "trailing bytes after decode");
assert_eq!(got.exclude_hop, exclude_hop);
assert_eq!(got.prefix, msg.prefix);
}
}

#[test]
fn announce_ok_rejects_old_versions() {
let msg = AnnounceOk {
Expand All @@ -340,11 +385,13 @@ mod tests {
}
.encode(&mut buf, Version::Lite05Wip)
.unwrap();
// origin id 1 sits right after the size prefix; rewrite it to 0.
// origin sits right after the size prefix; rewrite it to 0.
let bytes = &buf[..];
let mut patched = bytes.to_vec();
// size(1 byte) | origin varint(1 byte = 0x01) | active varint(1 byte)
patched[1] = 0x00;
// size(1 byte) | origin fixed-width 64-bit (8 bytes) | active varint(1 byte)
for b in &mut patched[1..9] {
*b = 0x00;
}
let mut slice = &patched[..];
assert!(matches!(
AnnounceOk::decode(&mut slice, Version::Lite05Wip),
Expand Down
14 changes: 14 additions & 0 deletions rs/moq-net/src/lite/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ impl Version {
_ => true,
}
}

/// Whether Hop IDs (ANNOUNCE / ANNOUNCE_OK) and `Exclude Hop` (ANNOUNCE_INTEREST)
/// are carried as fixed-width 64-bit integers rather than varints. Added in lite-05:
/// Hop IDs are randomly assigned, so a varint would almost never be shorter than its
/// fixed-width equivalent, and the fixed width buys the full 64-bit space (a varint
/// caps at 62 bits).
#[allow(clippy::match_like_matches_macro)]
pub fn hops_fixed_width(self) -> bool {
// Match form so future versions default forward (CLAUDE.md convention).
match self {
Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 => false,
_ => true,
}
}
}

impl fmt::Display for Version {
Expand Down
Loading