Skip to content

feat(xmtp_api_d14n): generalize XIP-83 bidi over a binding + d14n backend#3804

Merged
tylerhawkes merged 1 commit into
mainfrom
tyler/xip83-6-d14n-bidi
Jul 2, 2026
Merged

feat(xmtp_api_d14n): generalize XIP-83 bidi over a binding + d14n backend#3804
tylerhawkes merged 1 commit into
mainfrom
tyler/xip83-6-d14n-bidi

Conversation

@tylerhawkes

@tylerhawkes tylerhawkes commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Prerequisite #3789 has merged; this now targets main directly.

Generalizes the XIP-83 bidirectional subscription connection over a BidiBinding trait 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 a handle() that classifies an inbound response into an Inbound instruction.
  • v3 binding (queries/v3/connection.rs) — thin: mls_v1 frames, 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.
  • d14n binding (queries/d14n/connection.rs) — a stateless, pure extractor: it demultiplexes each delivered OriginatorEnvelope batch by topic kind and runs the matching extractor into the unified xmtp_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 concrete D14nClient through the stats wrapper.

Ordering

The d14n binding deliberately does not reorder envelopes (no icebox / cursor pipeline). Rationale:

  • Per XIP-49 §1.3, the broadcast network is not required to totally order. The payloads that do need strict ordering — commit and identity messages — are chain-anchored to a single fixed originator (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.
  • XIP-49 §3.3.5 makes cross-originator causal ordering explicitly optional ("clients are free to define an ordering strategy… for additional trustlessness… at the cost of additional complexity").

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 OrderedStream is left untouched for exactly that use. This keeps the d14n binding a dumb envelope shoveler, symmetric with v3.

Tests

  • 4 d14n live integration tests round-tripping to local xmtpd (mirror the v3 ones): happy-path catch-up → live marker → live stream, history-only, and history-only half-close (bounded sync).
  • The shared bidi.rs carries the same half-close behavior landed in feat(xmtp_api_d14n): XIP-83 bidi half-close + live integration tests #3789 — non-blocking try_reserve flush (a stalled transport can't wedge the half-close) and deterministic post-finish Closed — covered by unit tests in bidi.rs and v3/connection.rs.
  • Malformed-TopicsLive topics 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

  • Extracts the bidirectional subscription actor loop into a reusable Connection<B> generic in bidi.rs, with a BidiBinding trait defining wire vocabulary (frame constructors, inbound classifier) for each backend.
  • Adds a d14n binding in d14n/connection.rs that opens a /xmtp.xmtpv4.message_api.QueryApi/Subscribe stream, handles ping/pong/started/catchup/topics-live/envelope responses, and emits GroupMessage/WelcomeMessage events.
  • Rewrites the v3 binding in v3/connection.rs as a V3Binding impl delegating to the shared core, removing the old in-file actor and related types.
  • Adds integration tests in d14n_bidi_tests.rs covering welcome delivery, catch-up ordering, live streaming, history-only behavior, and half-close semantics against a real d14n backend.
  • Behavioral Change: malformed TopicsLive entries 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.

@tylerhawkes tylerhawkes requested a review from a team as a code owner June 29, 2026 21:23
@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @tylerhawkes's task —— View job


Code Review Complete

Review 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 BidiBinding trait. Here are the key findings:

✅ Strengths

  1. Strong abstraction design: The BidiBinding trait effectively decouples backend-specific wire formats from the core control logic, enabling code reuse between v3 and d14n backends.

  2. Comprehensive test coverage: The d14n integration tests in d14n_bidi_tests.rs cover the critical paths well:

    • Live welcome delivery
    • Catch-up ordering with concurrent messages
    • History-only mode
    • Bounded sync with half-close
  3. Clear ownership model: The actor pattern with single ownership of both wire halves ensures clean teardown semantics.

  4. Good error handling: The binding's per-envelope extraction (lines 144-172 in d14n/connection.rs) correctly isolates failures so one bad envelope doesn't sink the entire batch.

🟡 Issues to Address

1. Unresolved Macroscope comments warrant attention

The following medium-severity issues flagged by Macroscope appear to still be unresolved and deserve human review:

  • bidi.rs:628 (drain_after_finish): The flush uses try_reserve() which drops accepted frames under transient backpressure. This could silently discard a previously-accepted mutate() that returned Ok. Consider using reserve().await with a timeout budget instead.

  • d14n/connection.rs:183 (extract_messages): The _group_failures and _welcome_failures from try_consume are silently discarded. When extraction fails, messages are lost with no visibility. Adding tracing::warn! for non-empty failure vectors would surface these drops.

2. Test coverage gap: mid-stream mutate()

The integration tests only exercise the initial subscription in open_group_history_sub(). There's no test for calling mutate() mid-stream to add/remove topics, which is a supported feature (line 232 in bidi.rs). Consider adding a test that:

  • Opens a subscription to topic A
  • Receives some messages
  • Calls mutate() to add topic B
  • Verifies delivery from both topics

3. Minor: missing documentation on inner()

In api_stats.rs:42, the inner() method has good inline comments explaining why it's test-gated, but the public API lacks a doc comment. Consider adding:

/// 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

  • The WIRE_BUFFER (64), COMMAND_BUFFER (64), and EVENT_BUFFER (1024) constants seem reasonable for the expected workload.
  • The non-blocking try_send/try_reserve pattern in the actor loop correctly prevents wire backpressure from stalling liveness (auto-pong), which is critical for keepalive.
  • The MAX_PENDING_FRAMES cap (128) provides a safety valve against unbounded memory growth.

🔒 Security

No new security concerns beyond what's already been reviewed. The binding is stateless and doesn't introduce new attack surfaces.

Summary

This is solid work with a well-thought-out architecture. The two unresolved Macroscope comments (silent frame dropping in drain_after_finish and unreported extraction failures) are the main items I'd prioritize addressing. The test coverage gap for mid-stream mutate() is a nice-to-have but not blocking.


Comment thread crates/xmtp_api_d14n/src/queries/bidi.rs
} else {
pending.push_back(frame);
}
if pending.len() > MAX_PENDING_FRAMES {

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.

🟡 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.

@macroscopeapp

macroscopeapp Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

@tylerhawkes tylerhawkes force-pushed the tyler/xip83-5-bidi-half-close branch from 4856bda to 2b4a310 Compare June 29, 2026 21:32
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-6-d14n-bidi branch from 3559a02 to 60b212d Compare June 29, 2026 21:33
@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.56974% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.56%. Comparing base (58a583e) to head (00a7bad).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
crates/xmtp_api_d14n/src/queries/bidi.rs 86.97% 37 Missing ⚠️
...rates/xmtp_api_d14n/src/queries/d14n/connection.rs 93.93% 10 Missing ⚠️
crates/xmtp_api_d14n/src/queries/v3/connection.rs 98.24% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tylerhawkes tylerhawkes force-pushed the tyler/xip83-5-bidi-half-close branch from 2b4a310 to 9a92118 Compare June 29, 2026 21:38
Comment thread crates/xmtp_api_d14n/src/queries/bidi.rs
Comment thread crates/xmtp_api_d14n/src/queries/bidi.rs
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-6-d14n-bidi branch from 60b212d to 83b0020 Compare June 29, 2026 21:41
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-5-bidi-half-close branch from 9a92118 to 5d2c9c4 Compare June 29, 2026 21:56
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-6-d14n-bidi branch from 83b0020 to 937115c Compare June 29, 2026 22:05
Comment thread crates/xmtp_api_d14n/src/queries/bidi.rs Outdated
Base automatically changed from tyler/xip83-5-bidi-half-close to main June 29, 2026 22:32
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-6-d14n-bidi branch 2 times, most recently from f91942e to 963eda4 Compare June 29, 2026 23:12
Comment thread crates/xmtp_api_d14n/src/queries/d14n/connection.rs Outdated
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-6-d14n-bidi branch 2 times, most recently from 5bbf527 to 95f107e Compare July 1, 2026 05:16
tracing::debug!("opening d14n bidirectional subscription");
let outbound = requests.map(|frame| Bytes::from(frame.encode_to_vec()));
let response = client
.bidi_stream(

@insipx insipx Jul 1, 2026

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.

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

@tylerhawkes tylerhawkes force-pushed the tyler/xip83-6-d14n-bidi branch from 95f107e to a1a1afb Compare July 1, 2026 19:45
Comment on lines +183 to +186
let (group, _group_failures) = group_envs.try_consume::<GroupMessageExtractor>()?;
let (welcome, _welcome_failures) = welcome_envs.try_consume::<WelcomeMessageExtractor>()?;
Ok((group, welcome))
}

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.

🟡 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.

Suggested change
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.
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-6-d14n-bidi branch from a1a1afb to 00a7bad Compare July 1, 2026 22:58
@tylerhawkes tylerhawkes merged commit c792128 into main Jul 2, 2026
43 of 44 checks passed
@tylerhawkes tylerhawkes deleted the tyler/xip83-6-d14n-bidi branch July 2, 2026 22:00
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.

2 participants