Skip to content

sui-rpc: better defaults to avoid http2 stream starvation#284

Merged
bmwill merged 9 commits into
masterfrom
fix-client-stall
Jul 15, 2026
Merged

sui-rpc: better defaults to avoid http2 stream starvation#284
bmwill merged 9 commits into
masterfrom
fix-client-stall

Conversation

@bmwill

@bmwill bmwill commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

bmwill added 9 commits July 14, 2026 21:02
h2 versions before 0.4.14 have several stream-cancellation flow-control
accounting bugs (hyperium/h2#893, #896, #897, #898, and #913, fixed
across 0.4.14 and 0.4.15) that can permanently wedge a multiplexed gRPC
connection: leaked connection send-window capacity, RST_STREAM frames
that are never transmitted, and permanently missed `poll_capacity`
wakeups. Add an explicit hyper 1.10 floor to sui-rpc (hyper 1.10 raised
its h2 floor to 0.4.14 for the same reason) so downstream consumers
cannot resolve a broken h2. The lockfile is not tracked in this repo, so
the manifest floor is the durable guard.
Every RPC made through a Client (and all of its clones) is multiplexed
over a single HTTP/2 connection, so the connection-level receive window
is shared by all in-flight responses. A streaming response that the
application holds without polling pins up to a full stream window of
that shared budget, and with hyper's client defaults (2 MiB stream /
5 MiB connection) about three stalled streams exhaust the connection
window. From then on every RPC on the channel hangs indefinitely --
response HEADERS are not flow-controlled and still arrive, but response
DATA can never be delivered -- while TCP and HTTP/2 keepalives stay
healthy, so nothing times out.

Client::new now sets the per-stream window to 2 MiB explicitly and
raises the connection window to 64 MiB, so ~32 concurrently stalled
streams are needed to starve the connection instead of ~3. Add
with_initial_stream_window_size and with_initial_connection_window_size
overrides, and document that Client::from_endpoint bypasses these
defaults and that http2_adaptive_window is not a substitute (adaptive
mode starts the connection window at the 64 KiB spec default, making
starvation worse).
Raising the flow-control windows makes the shared connection hard to
starve, but a response stream that the application holds without
polling still pins its window forever, and an RPC whose response can no
longer make progress still hangs forever. Nothing in the default stack
bounds either: HTTP/2 keepalive PINGs are answered by the peer's
transport layer even when every stream is stalled, so the connection
looks healthy throughout.

Add a watchdog layer, on by default, applied inside Client::channel()
beneath any user request layers. It moves each response body into a
spawned task bridged back to the caller through a bounded mpsc(1)
channel. The task's outermost await is an idle timer (default 30
seconds, comfortably above the fullnode's ~5 second subscription
watermark cadence), so it stays responsive even when the caller has
parked the response and nothing is polling it. A poll-driven timer
would not work here: a parked stream is never polled, which is exactly
when the timer is needed. On expiry the task drops the inner body,
which sends RST_STREAM (a control frame, deliverable even on a starved
connection) and releases all of the flow-control window the stream had
pinned; the caller observes DeadlineExceeded on its next poll. Dropping
the response aborts the task via an abort-on-drop guard, and a panic in
the task surfaces as an Internal status rather than a clean
end-of-stream.

The timeout is configurable with Client::with_body_idle_timeout,
disableable with Client::without_body_idle_timeout, and overridable per
call (including re-enabling or disabling) by inserting a
BodyIdleTimeout extension into the request.
Previously no per-call deadline was enforced end to end. tonic's
channel machinery parses the grpc-timeout header that
tonic::Request::set_timeout attaches, but only enforces it until
response headers arrive. On a starved connection that is precisely the
part that still works -- headers are not flow-controlled -- so the
deadline never bounded the actual hang: unary response DATA that can
never be delivered, or a streaming body that outlives its budget.

The watchdog task now also enforces the request's grpc-timeout deadline
across the whole response body, measured from request time so the
budget communicated to the server is the same one enforced locally. On
expiry the body is dropped (resetting the stream and releasing its
pinned flow-control window) and the call observes DeadlineExceeded.
This makes Request::set_timeout a total per-call deadline: the server
participates through the standard header once it honors it, and the
client enforces it locally regardless. Also document the deadline story
on Client.
Port the standalone reproduction of the indefinite gRPC hang as
permanent regression tests. A mock fullnode built from this crate's own
generated server stubs pushes large checkpoint-subscription frames as
fast as the transport accepts them, and the tests assert each layer of
the defense independently:

- a control test with the pre-fix configuration (hyper's default 2 MiB
  stream / 5 MiB connection windows via Client::from_endpoint, watchdog
  disabled) reproduces the hang with four parked subscription streams
  and recovers when they are dropped, proving the harness creates real
  starvation pressure;
- the Client::new window defaults absorb eight parked streams while
  unary calls and an actively polled subscription keep working, with
  the watchdog disabled to show the windows alone carry the load;
- the idle-body watchdog reaps parked streams on the small-window
  configuration, un-sticking the connection without any help from the
  caller, and the parked calls observe DeadlineExceeded when finally
  polled;
- tonic::Request::set_timeout cuts a streaming call at its deadline
  while items are flowing.
Previously the watchdog task pulled a frame from the transport and then
awaited sending it into the bridge channel, so a parked consumer left
one frame buffered in the channel plus one held in limbo by the blocked
send. Pulling a frame out of the body also releases its HTTP/2
receive-window capacity, inviting the server to send more data than the
consumer is actually accepting.

Reserve a permit with Sender::reserve before polling the body, so a
frame is only taken from the transport once it is immediately
deliverable. A parked consumer now buffers at most one frame in the
bridge, everything else stays in the transport where it remains subject
to flow control, and a dropped consumer is detected before pulling
another frame rather than after. The idle-timer semantics are
unchanged: the timer still covers both awaits, firing whether the
consumer is parked (reserve never resolves) or the transport is starved
(no frame arrives). Add a test pinning the no-double-buffering
property.
Previously the client had no way to bound every RPC by default: a deadline
had to be set per request with `tonic::Request::set_timeout`, and a call
against a server that never responded could wait on response headers
indefinitely (headers are not flow-controlled, so neither the flow-control
windows nor the idle-body watchdog bound that phase).

With this commit, `Client::with_response_headers_timeout` exposes
`tonic::transport::Endpoint::timeout`, which tonic enforces locally from
request dispatch until response headers arrive. Because a server does not
send response headers for a unary call until the handler completes, this
effectively bounds the total duration of unary calls, while the timer is
dropped once headers arrive, so a client-wide value does not cut off
long-lived streaming responses. The timeout is not communicated to the
server, and tonic combines it with a per-call `grpc-timeout` deadline by
taking the shorter of the two, so a per-call deadline can tighten the local
bound but never extend it.

Also normalize tonic's `TimeoutExpired` error, which tonic surfaces as
`Cancelled`, to `DeadlineExceeded` in the client's error mapping. The gRPC
code for an expired deadline is `DeadlineExceeded`, and the watchdog already
uses it for body-phase expiry, so both phases of a deadline now report the
same code.

Add integration tests covering a stalled unary call failing at the timeout
with `DeadlineExceeded`, the shorter-of-the-two interaction with per-call
deadlines in both directions, a subscription stream outliving the timeout,
and a healthy call passing through unaffected.
Previously the list_owned_objects, list_dynamic_fields, list_balances,
and list_package_versions streams treated a page with zero items as
end-of-stream even when the response carried a next_page_token. The
list-pagination contract allows such pages (for example, a filtered
scan that hit a server-side budget before finding a match), so the
streams would silently drop every remaining page -- data loss with no
error. The list_delegated_stake helper already handled this correctly
by looping on the token; the four stream helpers were the outlier.

With this commit the streams keep following the token until a page
yields an item or pagination ends. Also add integration tests against a
scripted mock server covering an empty middle page and a mid-pagination
error; the empty-page test fails against the previous behavior.
execute_transaction_and_wait_for_checkpoint subscribed to the
checkpoint stream and then held it unpolled while awaiting
ExecuteTransaction and GetTransaction on the same channel -- the
hold-and-wait shape at the root of the connection-starvation hangs.
Checkpoints keep arriving whether or not anyone reads them, so the
parked subscription pinned up to a stream window of the shared
connection receive window for the whole execution phase, and once the
execution phase outlasted the client's idle-body watchdog the
subscription was reset, turning a successful execution into a spurious
CheckpointStreamError.

Restructure the method to scan the subscription concurrently with the
execution phase: the digest is computed from the request up front (so
an invalid request now also fails before any network work), and a
select loop drives the execute-then-GetTransaction future and the
digest scan together. Semantics are preserved: the subscription is
opened before execution so the checkpoint cannot be missed, the
GetTransaction duplicate-submission shortcut still takes precedence,
and the wait timeout still covers only the confirmation phase. A scan
result that arrives mid-execution (for example, a duplicate of an
already executed transaction landing in a checkpoint) is remembered and
used once execution completes.

Add regression tests against a mock fullnode: a slow execution with a
short watchdog (fails as CheckpointStreamError against the previous
implementation), the duplicate-submission shortcut, and fail-fast
validation of a request with no transaction.
@bmwill
bmwill merged commit 9ee937f into master Jul 15, 2026
12 checks passed
@bmwill
bmwill deleted the fix-client-stall branch July 15, 2026 19:37
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