Skip to content

Path memory sharing + 32 max parts enforcement#2156

Merged
kixelated merged 3 commits into
mainfrom
claude/origin-producer-consumer-memory-25fb8e
Jul 10, 2026
Merged

Path memory sharing + 32 max parts enforcement#2156
kixelated merged 3 commits into
mainfrom
claude/origin-producer-consumer-memory-25fb8e

Conversation

@kixelated

@kixelated kixelated commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two changes to Path aimed at OriginProducer/OriginConsumer memory usage and bounds.

1. Share one allocation across owned Path copies

Every owned path was its own String, and the origin announce fan-out copies the same path many times: the trie leaf (OriginBroadcast.path), the cleanup task closure, one pending-map key per consumer, and then every session-layer map downstream (stats_guards, subscriber producers, ietf namespaces, stats entries). That multiplies to O(broadcasts x consumers) heap strings on a relay.

Path's owned representation is now a suffix of a shared Arc<str> (buffer + start offset):

  • clone() and to_owned() on an owned path bump a refcount instead of copying.
  • strip_prefix / next_part (the per-consumer root strip in announce fan-out) are offset arithmetic on the shared buffer.
  • One publish_broadcast allocates the full path once; everything downstream shares it. No changes were needed outside path.rs.

Measured with 10k broadcasts x 50 announce consumers (counting allocator, release build):

main this PR
live heap after publish 96.3 MiB 77.7 MiB (-19%)
live heap with announces held 70.7 MiB 52.1 MiB (-26%)
total allocated 147.9 MiB 110.6 MiB (-25%)

Rejected alternatives: Vec<Arc<String>> segments (loses contiguous as_str() for wire/Ord/Hash; per-part Arc overhead; the OriginNode trie already interns segments structurally), global interner (needs GC for churning broadcasts; the dominant waste was same-value copies), tinyvec/array (does not remove the string allocations).

2. Enforce 32 max path parts in moq-lite, matching moq-transport

moq-transport caps namespace tuples at 32 fields; moq-lite paths had no depth limit, so a peer could announce arbitrarily deep paths and grow the origin tree without bound.

  • Path::MAX_PARTS (32) is now shared by both protocols; Path::parts() is a new public iterator.
  • The lite Encode/Decode impls reject deeper paths (covers announce/subscribe/fetch/setup uniformly).
  • publish_broadcast refuses a joined path past the limit: a decoded announce prefix and suffix are each within the wire bound, but their join may not be. This also bounds trie depth and guarantees forwarded paths can re-encode.
  • The ietf namespace codec reuses the constant.
  • js/net mirror: Path.MAX_PARTS, Path.parts, and a Path.decode validator used at every lite wire read site. The JS ietf namespace decoder previously looped an attacker-controlled count with no bound at all; it now rejects before reading parts.

Branch-targeting note: the lite depth limit rejects wire input that was previously accepted. The ietf side already enforced it (spec compliance), and this reads as DoS hardening, so it targets main per "when in doubt"; redirect to dev if you consider it a lite contract change. The moq-lite draft should probably document the limit as well (not part of this PR).

Public API changes

  • Path::MAX_PARTS (new pub const) and Path::parts() (new method): additive.
  • Path internals reshaped (field was already private); all existing methods keep their signatures. Derived PartialEq/Eq/Ord/Hash/Debug/Serialize replaced with manual impls that go through as_str(), matching the previous Cow forwarding behavior.
  • js/net: Path.MAX_PARTS, Path.parts, Path.decode (all additive).
  • Behavior change (both languages): paths deeper than 32 parts now fail wire encode/decode, and publish_broadcast returns false for them.

Test plan

  • cargo test -p moq-net (383 tests) + doc tests; new tests pin Arc sharing via pointer equality, wire limit round-trip at/past 32 parts, and publish rejection (including root+path join)
  • bun test js/net (161 tests) incl. new parts/decode tests; tsc -b and biome clean
  • cargo check --workspace, clippy clean, RUSTDOCFLAGS=-D warnings cargo doc
  • Before/after memory measurement (table above)

(Written by Claude Fable 5)

🤖 Generated with Claude Code

Owned paths were a separate String per copy, and the origin announce
fan-out copies the same path many times: the trie leaf, the cleanup
task, one pending-map key per consumer, then every session-layer map
downstream. That multiplies to O(broadcasts x consumers) heap strings
on a relay.

Path's owned representation is now a suffix of a shared Arc<str>
(buffer + start offset). Cloning bumps a refcount, and strip_prefix /
next_part are offset arithmetic, so a single publish allocates the
path once and every consumer shares it. The public API is unchanged;
comparisons, ordering, and hashing still go through as_str().

Measured with 10k broadcasts and 50 announce consumers: live heap
drops from 70.7 MiB to 52.1 MiB and total allocations from 147.9 MiB
to 110.6 MiB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@sourcery-ai sourcery-ai 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.

Sorry @kixelated, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Path now uses borrowed or Arc-backed storage instead of Cow, allowing clones and suffix operations to share allocations. Path construction, conversion, ownership methods, joining, comparisons, hashing, formatting, and serialization were updated, and PathOwned was added. Rust and JavaScript wire handling now enforce maximum path-part limits, while Lite decoders use validated path decoding. Publishing rejects joined paths exceeding the limit. Tests cover sharing, normalization, and boundary cases.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: shared path storage and 32-part limit enforcement.
Description check ✅ Passed The description directly matches the path-sharing and max-parts enforcement changes in the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/origin-producer-consumer-memory-25fb8e

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
rs/moq-net/src/path.rs (1)

210-252: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add rustdoc to the changed public methods.

as_str, empty, is_empty, len, to_owned, and into_owned are pub but lack doc comments (unlike borrow just below). This region also documents important sharing semantics worth stating explicitly (e.g. to_owned/into_owned preserve the shared Arc allocation without copying).

As per coding guidelines: "every pub Rust item ... must have a doc comment."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-net/src/path.rs` around lines 210 - 252, Add rustdoc comments to the
public methods as_str, empty, is_empty, len, to_owned, and into_owned,
describing their return values and behavior. Document the ownership and sharing
semantics explicitly, including that to_owned and into_owned preserve existing
shared Arc allocations without copying.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rs/moq-net/src/path.rs`:
- Around line 210-252: Add rustdoc comments to the public methods as_str, empty,
is_empty, len, to_owned, and into_owned, describing their return values and
behavior. Document the ownership and sharing semantics explicitly, including
that to_owned and into_owned preserve existing shared Arc allocations without
copying.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 87df4992-5f84-484e-8d9b-a1713d3007c9

📥 Commits

Reviewing files that changed from the base of the PR and between aa1275c and b192407.

📒 Files selected for processing (1)
  • rs/moq-net/src/path.rs

moq-transport caps namespace tuples at 32 fields but moq-lite paths
had no depth limit, so a peer could announce arbitrarily deep paths
and grow the origin tree without bound.

Path::MAX_PARTS (32) is now shared by both protocols. The lite
Encode/Decode impls reject deeper paths, the ietf namespace codec
reuses the constant, and publish_broadcast refuses a joined path past
the limit since a decoded prefix and suffix are each within bounds but
their join may not be. Path::parts() is a new public iterator over the
slash-separated parts.

js/net mirrors the same rules: Path.MAX_PARTS, Path.parts, and a
Path.decode validator used at every lite wire read. The ietf namespace
decoder previously looped an attacker-controlled count with no bound
at all; it now rejects before reading the parts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kixelated kixelated changed the title Share one allocation across owned Path copies Path memory sharing + 32 max parts enforcement Jul 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
js/net/src/ietf/namespace.ts (1)

5-16: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the updated exported codec APIs.

encode and decode are exported but have no doc comments describing their IETF namespace wire contract and bound-failure behavior.

Also applies to: 18-27

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/net/src/ietf/namespace.ts` around lines 5 - 16, The exported encode and
decode functions need documentation describing the IETF namespace wire format
and their behavior when the namespace exceeds Path.MAX_PARTS. Add doc comments
directly above both functions, referencing their exported API contract and
documenting the bound-related error behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@js/net/src/path.ts`:
- Around line 41-46: from can brand paths exceeding MAX_PARTS as Valid, allowing
Lite serialization to emit paths rejected by decoding. Add a shared outbound
path validator that checks the component count against MAX_PARTS, then invoke it
before serializing every Lite path field, using the existing path utilities and
Lite encoder symbols to keep validation consistent.

---

Nitpick comments:
In `@js/net/src/ietf/namespace.ts`:
- Around line 5-16: The exported encode and decode functions need documentation
describing the IETF namespace wire format and their behavior when the namespace
exceeds Path.MAX_PARTS. Add doc comments directly above both functions,
referencing their exported API contract and documenting the bound-related error
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 26a8e188-60ea-4d7a-8f83-a3bc0e196129

📥 Commits

Reviewing files that changed from the base of the PR and between b192407 and af0b294.

📒 Files selected for processing (10)
  • js/net/src/ietf/namespace.ts
  • js/net/src/lite/announce.ts
  • js/net/src/lite/fetch.ts
  • js/net/src/lite/subscribe.ts
  • js/net/src/lite/track.ts
  • js/net/src/path.test.ts
  • js/net/src/path.ts
  • rs/moq-net/src/ietf/namespace.rs
  • rs/moq-net/src/model/origin.rs
  • rs/moq-net/src/path.rs

Comment thread js/net/src/path.ts
Decode-side enforcement alone let a local peer emit a path the remote
is required to reject. Path.encode validates before every lite path
write, mirroring the Rust Encode impl.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kixelated kixelated enabled auto-merge (squash) July 10, 2026 20:23

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
js/net/src/lite/announce.ts (1)

169-175: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound the suffixes count in AnnounceInit.#decode before the loop.

The count read from the wire is unbounded. A malicious peer can send an enormous count, causing the decoder to attempt reading millions of strings before any Path.decode call rejects. The IETF namespace decoder in js/net/src/ietf/namespace.ts already bounds count > Path.MAX_PARTS before its loop — consider applying a similar guard here for consistency with the PR's theme of bounding attacker-controlled counts.

This is pre-existing (the count read was not changed in this PR), but it's low-effort to add and aligns with the PR's defensive scope.

♻️ Suggested bound check
 static async `#decode`(r: Reader): Promise<AnnounceInit> {
 	const count = await r.u53();
+	if (count > Path.MAX_PARTS) {
+		throw new Error(`announce init suffixes exceed ${Path.MAX_PARTS}`);
+	}
 	const suffixes: Path.Valid[] = [];
 	for (let i = 0; i < count; i++) {
 		suffixes.push(Path.decode(await r.string()));
 	}
 	return new AnnounceInit(suffixes);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/net/src/lite/announce.ts` around lines 169 - 175, Bound the
attacker-controlled count in AnnounceInit.#decode before iterating. After
reading count and before the suffixes loop, reject or throw when count exceeds
Path.MAX_PARTS, matching the guard used by the IETF namespace decoder, while
preserving normal decoding for valid counts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@js/net/src/lite/announce.ts`:
- Around line 169-175: Bound the attacker-controlled count in
AnnounceInit.#decode before iterating. After reading count and before the
suffixes loop, reject or throw when count exceeds Path.MAX_PARTS, matching the
guard used by the IETF namespace decoder, while preserving normal decoding for
valid counts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b9ff8a7a-23da-4466-a07b-1797d0535d5e

📥 Commits

Reviewing files that changed from the base of the PR and between af0b294 and 92baaf9.

📒 Files selected for processing (6)
  • js/net/src/lite/announce.ts
  • js/net/src/lite/fetch.ts
  • js/net/src/lite/subscribe.ts
  • js/net/src/lite/track.ts
  • js/net/src/path.test.ts
  • js/net/src/path.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • js/net/src/lite/track.ts
  • js/net/src/path.ts

@kixelated kixelated merged commit 12c1a48 into main Jul 10, 2026
3 checks passed
@kixelated kixelated deleted the claude/origin-producer-consumer-memory-25fb8e branch July 10, 2026 20:33
@moq-bot moq-bot Bot mentioned this pull request Jul 10, 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