Skip to content

Async client: fix silent data loss on the final frame of a server stream - #312

Open
shvbsle wants to merge 1 commit into
containerd:masterfrom
shvbsle:ttrpc-bug
Open

shvbsle wants to merge 1 commit into
containerd:masterfrom
shvbsle:ttrpc-bug

Conversation

@shvbsle

@shvbsle shvbsle commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #311

Problem

The async client previously spawned one Tokio task per incoming frame. Tasks for the same stream could execute out of order, allowing a terminal REMOTE_CLOSED frame to remove the stream before an earlier DATA frame was delivered. This caused silent data loss and frame reordering. Processing frames directly through the existing bounded response channel preserved ordering but introduced connection-wide head-of-line blocking.

Solution

Give every client RPC an ordered per-stream mailbox:

  • ClientReader applies inbound transforms and enqueues frames into the corresponding unbounded mailbox in wire order.
  • One dispatcher task per active RPC forwards mailbox entries, in FIFO order, to the existing bounded consumer channel.
  • A slow consumer can block only its own dispatcher; the shared connection reader remains free to process other streams.
  • DATA frames, responses, close frames, and errors all follow the same ordered path.

The unbounded ingress queue is intentional. TTRPC has no per-stream receive-window mechanism, so a hard bound would require either blocking the whole connection or failing an individual stream at an arbitrary limit. This replaces the previous unbounded task-per-frame behavior with one queue and one dispatcher task per active RPC.

Regression tests

Added three self-contained regression examples:

  • async-stream-close-order sends DATA immediately followed by REMOTE_CLOSED across 2,000 calls and verifies the payload always arrives before EOF.
  • async-data-order sends 50 sequential frames across 400 concurrent streams and verifies every stream receives them in order.
  • async-slow-consumer leaves a 500-frame stream unread, issues an unrelated unary RPC on the same connection, and verifies that the unary RPC completes and all stream frames remain available.

All three examples are executed by tests/run-examples.rs, and spawned-task panics are propagated. Unit tests also verify mailbox ordering beyond the bounded channel capacity and delivery of terminal errors after buffered frames.

Compatibility

The public API and wire format are unchanged. Applications continue using the existing unary and streaming receive APIs without any additional handling.

@shvbsle

shvbsle commented Apr 20, 2026

Copy link
Copy Markdown
Contributor Author

On CI Failures: The CI build failures are unrelated to the code changes. Cargo 1.81 cannot resolve the current crate ecosystem because new crate versions now require edition2024. Seems like infrastructure rot.

Comment thread src/asynchronous/client.rs Outdated
@fletcherw

Copy link
Copy Markdown

On CI Failures: The CI build failures are unrelated to the code changes. Cargo 1.81 cannot resolve the current crate ecosystem because new crate versions now require edition2024. Seems like infrastructure rot.

You can update rust-toolchain.toml to 1.95 to fix this.

@shvbsle

shvbsle commented Apr 20, 2026

Copy link
Copy Markdown
Contributor Author

You can update rust-toolchain.toml to 1.95 to fix this.

I had a PR for this but its not as simple as updating the toolchain. Need to fix the new clippy issues that are introduced in new versions:
#313

I can cherry pick or rebase this PR after the above one is merged

@wllenyj

wllenyj commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator

LGTM.

At the time of implementation, it was simply designed to behave exactly like the Go version, even though the Go version might be incorrect...

@shvbsle
shvbsle force-pushed the ttrpc-bug branch 2 times, most recently from 2dadc6b to c908838 Compare May 2, 2026 17:09

@tzneal tzneal 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.

lgtm

@dims

dims commented May 4, 2026

Copy link
Copy Markdown
Member

@tzneal @shvbsle can the reproducer be turned into a test case perhaps? worth it?

@shvbsle

shvbsle commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

@tzneal @dims Added a regression test in the second commit. example/async-stream-close-order.rs sends one payload on a server-stream and returns, 1000 times on a multi_thread runtime. Verified it fails without the fix (8/1000 dropped) and passes with it. Wired into tests/run-examples.rs so it runs under cargo test.

Comment thread example/async-stream-close-order.rs Outdated
@shvbsle
shvbsle force-pushed the ttrpc-bug branch 5 times, most recently from 2afc15c to afa1063 Compare May 16, 2026 03:24
@shvbsle
shvbsle requested a review from tzneal May 16, 2026 03:30

@Tim-Zhang Tim-Zhang left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The ordering bug is real, but this implementation introduces connection-wide head-of-line blocking.

Each stream uses a bounded channel with capacity 100. If one stream is not consumed, resp_tx.send(...).await blocks Connection::run, preventing it from reading responses for every other stream on the same connection.

I reproduced this by leaving a 200-frame stream unread and issuing an unrelated unary RPC: it times out with this patch but succeeds with the previous implementation.

Increasing the channel capacity would only delay the problem. Please preserve ordering without blocking the connection reader, for example with a per-stream ordered mailbox/dispatcher. The reader should enqueue frames without awaiting the consumer-facing bounded channel; if strict memory bounds are required, apply a per-stream quota and fail only the overflowing stream.

There are also two test issues:

  1. let _ = h.await ignores task panics and can produce false positives.
  2. async-data-order is not executed by tests/run-examples.rs.

@shvbsle
shvbsle marked this pull request as draft September 13, 2026 23:25
@shvbsle
shvbsle force-pushed the ttrpc-bug branch 5 times, most recently from 45623ed to 86d2e96 Compare September 15, 2026 07:23
Comment thread src/asynchronous/client.rs Outdated
Comment thread src/asynchronous/client.rs Outdated
Comment thread src/asynchronous/client.rs Outdated
Comment thread src/asynchronous/stream.rs Outdated
Comment thread src/asynchronous/stream.rs Outdated
Comment thread tests/run-examples.rs Outdated
@shvbsle
shvbsle marked this pull request as ready for review September 16, 2026 21:05
@shvbsle

shvbsle commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

@Tim-Zhang @wllenyj I've incorporated your feedback and updated my implementation (PR description should have the details). I will appreciate if you can do another round of review!

Comment thread src/asynchronous/client.rs
@shvbsle
shvbsle force-pushed the ttrpc-bug branch 3 times, most recently from 3f4ffd7 to ad82d2a Compare September 17, 2026 22:55
Spawning a task per incoming client frame allowed DATA frames to reorder and
race a following REMOTE_CLOSED frame, silently dropping payloads. Processing
frames through the bounded response channel in the connection reader fixed
ordering but let one unread stream block every RPC on the connection.

Give every client stream ID, including unary RPCs, an unbounded ordered
response queue. The connection reader transforms and enqueues results directly
in wire order without waiting for the consumer. Each RPC reads that queue
through the existing receive path, so a slow consumer affects only its own
stream. Keep the server's existing bounded handler channel.

Add regressions for close ordering, data ordering, and an unread stream sharing
a connection with a unary RPC. Propagate spawned-task panics and execute the
regression examples from the integration suite.

Fixes containerd#311

Signed-off-by: Shiv Bhosale <shvbsle@amazon.com>
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.

Async client silently drops the final DATA frame of a server-streaming RPC

6 participants