refactor(net): encapsulate Group.state behind non-blocking read methods#1908
Conversation
…acklog-collapse Make `Group.state` private (`#state`) so callers can't poke its signals directly and the internal representation can change freely. `GroupState` is no longer exported. The only external reader was `Track.readFrameSequence`, now routed through new public Group methods: - `tryReadFrame()` / `tryReadFrameSequence()`: read an already-buffered frame without blocking, returning undefined when nothing is buffered (not end-of-group). The non-blocking equivalent of Rust's `poll_read_frame`. - `readable()`: resolves once `readFrame` would not block (a frame is buffered or the group closed), so a caller can fold "this group has a frame" into a larger wait without touching the group's signals. `readFrame`/`readFrameSequence` are reimplemented on top of these. Use them to give the `@moq/json` Consumer the same backlog-collapse the Rust consumer got: a single `next()` applies every buffered frame but yields only the latest reconstructed value, so a late joiner catches up to the head in one step instead of replaying every superseded state. Live consumers still see each update. Adds Group tests for the new methods and a json test for the collapse. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
✅ Files skipped from review due to trivial changes (1)
WalkthroughConsumer 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches✨ Simplify code
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
js/net/src/group.test.ts (1)
27-33: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert
readable()is pending before writing.This test would still pass if
readable()incorrectly resolved immediately for an empty, open group. Add a microtask check beforewriteString()so the new wait contract is actually covered.Test tightening
test("readable resolves once a frame is buffered", async () => { const group = new Group(0); // No frame yet: readable() is pending. Resolve it by writing on the next tick. const readable = group.readable(); + let settled = false; + readable.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); group.writeString("hi"); await readable; // must not hang expect(dec.decode(group.tryReadFrame())).toBe("hi"); });As per coding guidelines, "Write unit tests for critical functionality".
🤖 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/group.test.ts` around lines 27 - 33, The test in Group.readable() is too weak because it only verifies eventual resolution after writeString(), so it would miss a bug where readable() resolves immediately on an empty open group. Update the readable() test to explicitly assert that the promise is still pending before any write by checking it across a microtask boundary, then perform group.writeString() and verify it resolves and tryReadFrame() returns the frame. Use the Group.readable, Group.writeString, and Group.tryReadFrame symbols to keep the test focused on the wait contract.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/track.ts`:
- Around line 186-198: In readFrameSequence in track.ts, the current group wake
path can loop forever when group.readable() resolves because the group has
closed and no frames remain. Update the await Promise.race handling so that when
the current group wins the wake, you consume the group result, detect
exhaustion/closure, and remove or advance past that group before re-checking for
the next frame. Keep the logic aligned with the existing group,
this.state.groups, and this.state.closed flow so the reader waits for a new
group instead of spinning on a closed empty one.
---
Nitpick comments:
In `@js/net/src/group.test.ts`:
- Around line 27-33: The test in Group.readable() is too weak because it only
verifies eventual resolution after writeString(), so it would miss a bug where
readable() resolves immediately on an empty open group. Update the readable()
test to explicitly assert that the promise is still pending before any write by
checking it across a microtask boundary, then perform group.writeString() and
verify it resolves and tryReadFrame() returns the frame. Use the Group.readable,
Group.writeString, and Group.tryReadFrame symbols to keep the test focused on
the wait contract.
🪄 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: ec5fe270-cac0-4071-b1e0-5627b67d99c8
📒 Files selected for processing (5)
js/json/src/consumer.tsjs/json/src/json.test.tsjs/net/src/group.test.tsjs/net/src/group.tsjs/net/src/track.ts
tryReadFrame() returns undefined both when nothing is buffered yet and when the group has finished, which is ambiguous. Worse, readable() resolves immediately on a closed group, so Track.readFrameSequence busy-looped on a sole finished group: Promise.race([..., group.readable()]) resolved on every microtask, starving the macrotask queue so a newly arriving group was never observed (an event-loop livelock, not just a spin). Add a sync `Group.done` getter (closed and fully drained) so callers can tell "no frame buffered yet" from "finished". Track now only folds the current group's readable() into its wait while the group can still deliver frames; a finished group falls back to waiting on a new group or the track closing. Adds a Track regression test (a sole group finishing before the next arrives must not livelock) and a Group `done` semantics test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strengthen the readable() test so it would catch a regression where readable() resolved immediately on an empty, open group (it must only resolve on a buffered frame or close). Per CodeRabbit review on #1908. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/group.test.ts`:
- Around line 49-51: The Promise created in the group test is intentionally not
awaited, but it is currently a floating Promise that can trigger lint/CI
failures and hide async errors. Update the then-call on readable in the test to
be explicitly marked as intentional with void, and keep the settled flag logic
in place so the intent is clear in group.test.ts.
🪄 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: 6a92aa1a-6ca3-4c93-843e-91f3a925202b
📒 Files selected for processing (1)
js/net/src/group.test.ts
biome noFloatingPromises flagged the `.then` used to observe whether readable() had settled. Mark it `void`; the handler still runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Makes
@moq/net'sGroup.stateprivate and routes the one external reader through new public methods, then uses those methods to give@moq/json's consumer the same backlog-collapse the Rust consumer already has.Follow-up to #1905 (which extracted
moq-flate/@moq/flateand added the Rust-side backlog-collapse). This brings the JS side to parity and cleans up the leakyGroupsurface that made it possible.What changed
@moq/net—GroupencapsulationGroup.stateis now#state(private);GroupStateis no longer exported. Callers can't poke the reactive signals directly, and the internal representation can change freely.poll_read_frame):tryReadFrame()/tryReadFrameSequence()— read an already-buffered frame without blocking; returnundefinedwhen nothing is buffered.get done()—trueonce the group has closed and every buffered frame is read (no more frames will ever be read). DisambiguatestryReadFrame()'sundefined: "nothing buffered yet" vs "finished".readable()— resolves oncereadFramewould not block (a frame is buffered, or the group closed). Always settles, so pair it withdoneto avoid re-waiting on a finished group.readFrame/readFrameSequenceare reimplemented on top of these (behavior unchanged).Track.readFrameSequence(the only external reader of a Group'sstate) now usestryReadFrameSequence()+done+ racesgroup.readable()instead of reaching intogroup.state.frames.@moq/json— backlog-collapse parityConsumer.next()drains every buffered frame in the group but yields only the latest reconstructed value, so a late joiner (or any consumer that fell behind) catches up to the head in one step instead of replaying every superseded state. Live consumers still see each update.Livelock fix (found during review)
The first cut returned
undefinedfromtryReadFrame()for both "nothing buffered yet" and "finished", andreadable()resolves immediately on a closed group.Track.readFrameSequencetherefore busy-looped on a sole finished group:Promise.race([…, group.readable()])resolved on every microtask, starving the macrotask queue so a newly-arriving group was never observed (an event-loop livelock, not just a spin). Thedonegetter fixes it: Track only folds the current group'sreadable()into its wait while the group can still deliver frames. Covered by a new regression test.Public API changes (
@moq/net)Group.tryReadFrame,Group.tryReadFrameSequence,Group.readable,Group.done.Group.statefield and theGroupStateexport. Both were unintentionally-public internal reactive state; the only reader anywhere in the repo wasTrackitself, now migrated. No published consumer is known to use them.@moq/json's exported surface is unchanged.Branch targeting
Targeting main: it builds directly on #1905 (already on main), and the removed surface was incidental/unused. The
Group.state/GroupStateremoval is technically breaking per the js/net rules, so if you'd rather this go to dev, say the word and I'll retarget (it would need rebasing off dev, which doesn't yet have #1905's@moq/flateconsumer changes).Test plan
bun test— full suite 406 pass (incl. newjs/net/src/group.test.ts, the Track livelock regression, and the json collapse test)tryReadFrame/tryReadFrameSequence/readable/donesemantics (buffered drain, sequence numbers, resolve-on-frame, resolve-on-close, drain-after-close, finished-vs-empty); Track "does not livelock when a sole group finishes before the next arrives"; json "late joiner collapses a buffered backlog to the latest value"(Written by Claude)