feat(xmtp_api_d14n): generalize XIP-83 bidi over a binding + d14n backend#3804
Conversation
|
Claude finished @tylerhawkes's task —— View job Code Review CompleteReview Date: July 1, 2026, 7:00 PM UTC I've reviewed this PR focusing on areas not already covered by existing review comments. Overall, the architecture is well-designed with clear separation of concerns via the ✅ Strengths
🟡 Issues to Address1. Unresolved Macroscope comments warrant attention The following medium-severity issues flagged by Macroscope appear to still be unresolved and deserve human review:
2. Test coverage gap: mid-stream The integration tests only exercise the initial subscription in
3. Minor: missing documentation on In /// Returns a reference to the wrapped client.
///
/// **Test-only**: This bypasses stat tracking and exists solely to let
/// integration tests reach concrete client types for bidi connection setup.
#[cfg(any(test, feature = "test-utils"))]
pub fn inner(&self) -> &C {📊 Performance Considerations
🔒 SecurityNo new security concerns beyond what's already been reviewed. The binding is stateless and doesn't introduce new attack surfaces. SummaryThis is solid work with a well-thought-out architecture. The two unresolved Macroscope comments (silent frame dropping in |
| } else { | ||
| pending.push_back(frame); | ||
| } | ||
| if pending.len() > MAX_PENDING_FRAMES { |
There was a problem hiding this comment.
🟡 Medium queries/bidi.rs:367
When the wire is wedged, pending is allowed to grow past WIRE_BUFFER (up to MAX_PENDING_FRAMES), but the commands.recv() gate in run_actor only blocks new commands once pending.len() >= WIRE_BUFFER. So once pending crosses WIRE_BUFFER, the actor stops reading commands entirely while pending still holds frames below the MAX_PENDING_FRAMES cap — a queued Command::Finish is never read, so the request half never closes and next() hangs indefinitely instead of terminating. Consider capping pending at WIRE_BUFFER (or lowering MAX_PENDING_FRAMES to match) so the command gate and the backlog limit stay aligned.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_api_d14n/src/queries/bidi.rs around line 367:
When the wire is wedged, `pending` is allowed to grow past `WIRE_BUFFER` (up to `MAX_PENDING_FRAMES`), but the `commands.recv()` gate in `run_actor` only blocks new commands once `pending.len() >= WIRE_BUFFER`. So once `pending` crosses `WIRE_BUFFER`, the actor stops reading commands entirely while `pending` still holds frames below the `MAX_PENDING_FRAMES` cap — a queued `Command::Finish` is never read, so the request half never closes and `next()` hangs indefinitely instead of terminating. Consider capping `pending` at `WIRE_BUFFER` (or lowering `MAX_PENDING_FRAMES` to match) so the command gate and the backlog limit stay aligned.
ApprovabilityVerdict: Needs human review 3 blocking correctness issues found. New feature introducing d14n bidirectional subscription capability with substantial new logic. Multiple unresolved medium-severity comments identify potential issues: command starvation causing hangs, silent frame drops during drain, and silently discarded extraction failures. Human review recommended to address these concerns. You can customize Macroscope's approvability policy. Learn more. |
4856bda to
2b4a310
Compare
3559a02 to
60b212d
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3804 +/- ##
==========================================
+ Coverage 84.54% 84.56% +0.01%
==========================================
Files 410 412 +2
Lines 60843 61209 +366
==========================================
+ Hits 51440 51759 +319
- Misses 9403 9450 +47 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
2b4a310 to
9a92118
Compare
60b212d to
83b0020
Compare
9a92118 to
5d2c9c4
Compare
83b0020 to
937115c
Compare
f91942e to
963eda4
Compare
5bbf527 to
95f107e
Compare
| tracing::debug!("opening d14n bidirectional subscription"); | ||
| let outbound = requests.map(|frame| Bytes::from(frame.encode_to_vec())); | ||
| let response = client | ||
| .bidi_stream( |
There was a problem hiding this comment.
small nit/non-blocker: one small improvement could be to make the bidi into an Endpoint which keeps path constants in one spot. Endpoints have less meaning since the HTTP1 shim crate was removed but it does remain minimally useful to separate some builder arguments and clean up client callsites/paths
95f107e to
a1a1afb
Compare
| let (group, _group_failures) = group_envs.try_consume::<GroupMessageExtractor>()?; | ||
| let (welcome, _welcome_failures) = welcome_envs.try_consume::<WelcomeMessageExtractor>()?; | ||
| Ok((group, welcome)) | ||
| } |
There was a problem hiding this comment.
🟡 Medium d14n/connection.rs:183
extract_messages discards the _group_failures and _welcome_failures returned by try_consume, so when an individual envelope fails extraction (e.g. malformed MLS payload bytes) that delivered message is silently dropped and the function still returns Ok((group, welcome)) containing only the successful items. The consumer receives a seemingly complete delivery with no indication that messages were lost. Consider logging the failures (as extract does for unreadable topics) so dropped envelopes are surfaced rather than silently swallowed.
| let (group, _group_failures) = group_envs.try_consume::<GroupMessageExtractor>()?; | |
| let (welcome, _welcome_failures) = welcome_envs.try_consume::<WelcomeMessageExtractor>()?; | |
| Ok((group, welcome)) | |
| } | |
| let (group, group_failures) = group_envs.try_consume::<GroupMessageExtractor>()?; | |
| let (welcome, welcome_failures) = welcome_envs.try_consume::<WelcomeMessageExtractor>()?; | |
| if !group_failures.is_empty() { | |
| tracing::warn!("d14n bidi: {} group envelope(s) failed extraction", group_failures.len()); | |
| } | |
| if !welcome_failures.is_empty() { | |
| tracing::warn!("d14n bidi: {} welcome envelope(s) failed extraction", welcome_failures.len()); | |
| } | |
| Ok((group, welcome)) |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_api_d14n/src/queries/d14n/connection.rs around lines 183-186:
`extract_messages` discards the `_group_failures` and `_welcome_failures` returned by `try_consume`, so when an individual envelope fails extraction (e.g. malformed MLS payload bytes) that delivered message is silently dropped and the function still returns `Ok((group, welcome))` containing only the successful items. The consumer receives a seemingly complete delivery with no indication that messages were lost. Consider logging the failures (as `extract` does for unreadable topics) so dropped envelopes are surfaced rather than silently swallowed.
Generalize the XIP-83 bidi connection over a backend BidiBinding so the control
loop (probe/timeout, non-blocking select, give-up cap, finish/half-close,
ownership teardown) lives once:
- queries/bidi.rs: the backend-agnostic Connection<B>, Event<G,W>, Inbound,
BidiBinding trait, and the actor.
- v3 becomes a thin V3Binding; BidiConnection/BidiEvent are type aliases over
the generic core, so the merged v3 actor's public API and behavior are
unchanged (13 unit + 4 live integration tests still green).
- d14n binding (queries/d14n/connection.rs): transport over the xmtpv4
QueryApi/Subscribe RPC; OriginatorEnvelope delivery decoded into unified
xmtp_proto::types::{GroupMessage, WelcomeMessage} via the existing extractor +
dependency-ordering (resolve/icebox) + per-topic vector-cursor pipeline. Uses
a fresh connection-local cursor store (decoupled from the MLS-coupled durable
store). open() takes the D14nClient via a new TrackedStatsClient::inner().
- 4 d14n live integration tests round-trip to the local xmtpd (xmtpd #2020):
welcome, catch-up/marker/live ordering, history_only, bounded-sync half-close
— validating the full envelope-extraction pipeline end to end.
a1a1afb to
00a7bad
Compare
Prerequisite #3789 has merged; this now targets
maindirectly.Generalizes the XIP-83 bidirectional subscription connection over a
BidiBindingtrait so the backend-agnostic control core lives once, and adds the d14n backend.What this does
Connection<B: BidiBinding>(queries/bidi.rs) — the control core (probe + timeout, the non-blocking select loop, the give-up cap,finish()/half-close + best-effort flush, and ownership teardown) now lives once, generic over a backend.BidiBinding— each backend supplies only its wire vocabulary: request/response/mutate/message types, frame construction, and ahandle()that classifies an inbound response into anInboundinstruction.queries/v3/connection.rs) — thin:mls_v1frames, messages surfaced as raw proto. Its public API (BidiConnection,BidiEvent) is preserved via type aliases, so the feat(xmtp_api_d14n): XIP-83 bidi half-close + live integration tests #3789 tests are unchanged.queries/d14n/connection.rs) — a stateless, pure extractor: it demultiplexes each deliveredOriginatorEnvelopebatch by topic kind and runs the matching extractor into the unifiedxmtp_proto::types::{GroupMessage, WelcomeMessage}, surfacing them in wire order. Symmetric with the v3 binding — no reordering happens at the transport.TrackedStatsClient::inner()— lets the d14n test reach the concreteD14nClientthrough the stats wrapper.Ordering
The d14n binding deliberately does not reorder envelopes (no icebox / cursor pipeline). Rationale:
Originators::MLS_COMMITS = 0), so MLS's epoch chain is the real dependency and gates processing natively: an out-of-order application message just waits for its epoch.A single per-process transport is the wrong layer for that. If a consumer wants trustless causal ordering it belongs as a durable client-level icebox — the server-streaming path's
OrderedStreamis left untouched for exactly that use. This keeps the d14n binding a dumb envelope shoveler, symmetric with v3.Tests
bidi.rscarries the same half-close behavior landed in feat(xmtp_api_d14n): XIP-83 bidi half-close + live integration tests #3789 — non-blockingtry_reserveflush (a stalled transport can't wedge the half-close) and deterministic post-finishClosed— covered by unit tests inbidi.rsandv3/connection.rs.TopicsLivetopics are logged with their bytes (capped at 33) before being skipped; the hex preview is built only on the error path.🤖 Generated with Claude Code
Note
Generalize XIP-83 bidi subscription over a pluggable binding and d14n backend
Connection<B>generic inbidi.rs, with aBidiBindingtrait defining wire vocabulary (frame constructors, inbound classifier) for each backend.d14n/connection.rsthat opens a/xmtp.xmtpv4.message_api.QueryApi/Subscribestream, handles ping/pong/started/catchup/topics-live/envelope responses, and emitsGroupMessage/WelcomeMessageevents.v3/connection.rsas aV3Bindingimpl delegating to the shared core, removing the old in-file actor and related types.d14n_bidi_tests.rscovering welcome delivery, catch-up ordering, live streaming, history-only behavior, and half-close semantics against a real d14n backend.TopicsLiveentries are now skipped with a warning instead of terminating the connection; envelopes that fail decoding are skipped per-envelope rather than aborting the batch.Macroscope summarized 00a7bad.