feat: Pluggable transport response shaping inversion - #363
Open
sichanyoo wants to merge 1 commit into
Open
Conversation
Replace the pull-based Stream contract (response_headers + each_chunk) with a
push (sink) model, and split the transport send surface by operation mode so
the returned Stream is a pure control handle rather than a driving engine.
Transport contract (lib/smithy-client/transport.rb)
- transmit(request, sink): non-event-stream operations. Sends and drives the
response into the sink to its terminal synchronously, then returns nothing;
the transport owns teardown (there is no handle to abort). Networking
failures surface as sink.error(NetworkingError); an invalid verb raises
ArgumentError synchronously, before any I/O.
- transmit_background(request, sink): event-stream operations (output-only and
bidirectional). Starts the exchange concurrently (transport owns the
concurrency mechanism) and returns a Stream handle immediately; the event
stream layer feeds/consumes the sink and owns the handle's lifetime.
- event_queue: the concurrency-appropriate push->pull bridge queue for the
event stream layer (SizedQueue for NetHTTP).
Stream (handle) contract (lib/smithy-client/stream.rb)
- A Stream is only ever returned for event streams and is OUTBOUND + CONTROL
only (no driving logic). Capability tiers:
REQUIRED_FOR_OUTPUT_EVENT_STREAM_OPS = [abort]
REQUIRED_FOR_BIDI_EVENT_STREAM_OPS = [abort, write, close_write]
write/close_write raise NotSupportedError on a transport that supports
output-only but not bidirectional (e.g. HTTP/1.1).
NetHTTP: Exchange / Stream separation
- NetHTTP::Exchange (new) is the request/response driving engine: drive,
drive_background (Thread.new { drive }), abort, plus the run/perform_exchange/
push_response machinery, session-ownership handoff, and truncated-body
detection. It pushes sink.headers/data then a single terminal (done|error).
The Fiber that faked a pull interface over Net::HTTP's read_body is removed
(Net::HTTP's own callback maps directly onto sink.data).
- NetHTTP::Stream is now a thin handle wrapping an Exchange: abort delegates to
the exchange; write/close_write raise NotSupportedError; no driving.
- NetHTTP::Transport: transmit drives an Exchange inline (returns nil);
transmit_background backgrounds an Exchange and wraps it in a Stream.
SDK wiring
- SendHandler supplies a ResponseSink (over Http::Response) and selects the
send method by operation mode: transmit_background + store the handle when
context[:event_stream], otherwise transmit (no stored handle, no ensure-abort
since the transport owns teardown). SendHandler is not in the data path.
- ResponseSink adapts Http::Response to the sink contract; its signal_* event
bus is unchanged (Option X: the event bus is not removed in this pass).
- Context flag renamed context[:duplex_stream] -> context[:event_stream] to
name the whole event-stream category (output-only or bidirectional).
Tests
- New net_http/exchange_spec.rb covers driving/abort/truncation/HEAD/networking
errors/drive_background. net_http/stream_spec.rb now tests only the handle
(abort delegation, write/close_write NotSupportedError, contract tiers).
- Shared examples updated: 'a transport' exercises transmit (void, inline) and
transmit_background (returns a handle, drives in background, ArgumentError on
bad verb before spawning); 'a stream' covers abort + write/close_write.
- RecordingSink test helper records the pushed lifecycle for assertions.
No change to generated client options. Full smithy-client suite green
(896 examples, 0 failures, 2 pending); rubocop clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Followup to:
Issue #, if available:
5196
Description of changes:
Invert response shaping into the transport (push/sink model)
What changed at a glance
The previous PR (#357) landed the pull
Transport/Streamcontract:transport.transmit(request)returns aStream, and the SDK pulls the response off it (stream.response_headers, thenstream.each_chunk). This PR replaces that with a push (sink) model and splits the send surface by operation mode.transmit(request)→transmit(request, sink)and a newtransmit_background(request, sink).sink.headers→sink.data*→ one terminalsink.done/sink.error).Streamis now a pure control handle (event streams only); a new internalNetHTTP::Exchangeholds the driving engine.Net::HTTPFiber is deleted.No change to generated client options or any public client API. Suite: 897 examples, 0 failures, 2 pending (the 2 pending are the pre-existing
net_http/patches_specskips on net-http >= 0.7.0); rubocop clean.Why
You'll remember the two concurrency bugs fixed during the pluggable-transport PR — the abort/session-ownership race and the fiber-local
Thread#[]content-type flag. Both traced to the same root cause:Net::HTTPis push-native (http.request(req) { |resp| resp.read_body { |chunk| ... } }), but the pullStreamcontract forced us to fake pull by wrapping the exchange in a Fiber andFiber.yield-ing one chunk pereach_chunk. That Fiber was the bug source. The big picture problem of this is that this forces every 3rd party implementer that wants to use a push-based http library to force-fit it into SDK's pull-based interface, which is prone to bugs.If the transport instead pushes chunks into a sink,
Net::HTTP's own block callback maps straight ontosink.data(chunk)— no Fiber, and the entire class of fiber-related concurrency bugs becomes structurally impossible. That's the core motivation; the two-method split and theExchange/Streamseparation fall out of doing it cleanly.If SDK provides sink to push into, a custom implementation that uses push-based http library is trivial (just map it to sink methods), and a custom implementation that uses pull-based http library is also pretty convenient; it would just pull from transport and push into sink using sink methods. Unlike the pull based interface (where SDK "pulls" from transport) that causes push-based http library to have to go through painful push=>pull adaptor, push based interface provides painless interface for both push and pull based http libraries.
The new contract (
lib/smithy-client/transport.rb,stream.rb)Transport— two send methods, one per operation mode (mode is event-stream vs. non-event-stream and it comes from the service model, not the wire protocol):#transmit(request, sink)— non-event-stream ops. Drives the response into the sink to its terminal synchronously, returns nothing; the transport owns teardown (no handle to abort nor write to). Networking failures →sink.error(NetworkingError); invalid verb →ArgumentErrorsynchronously before any I/O.#transmit_background(request, sink)— event-stream ops. Starts the exchange concurrently (transport-owned mechanism) and returns aStreamhandle immediately.#event_queue— the concurrency-appropriate push→pull bridge queue for the (future) event-stream layer. Concretely, this bridges the [transport's push to sink] and the [SDK's event receiver pulling and enumerating events] to the SDK user. We leave this up to transport implementation because the method for allowing this concurrent access depends on the concurrency mechanism used by the transport (e.g., for Net::HTTP it's SizedQueue, for async client based on async gem, it's Async::LimitedQueue, etc.).REQUIRED_METHODS = %i[transmit transmit_background]— both required. A single-mode transport implements both and raisesNotSupportedErrorfrom the one it doesn't serve (mirroringStream#write/#close_writeon H1). This is deliberately symmetric — neither mode is a privileged "base."Stream— now the outbound + control handle returned only bytransmit_background. Inbound no longer flows through it. Capability tiers:REQUIRED_FOR_OUTPUT_EVENT_STREAM_OPS = [:abort]REQUIRED_FOR_BIDI_EVENT_STREAM_OPS = [:abort, :write, :close_write]NetHTTP:
Exchange(new) vsStream(thin)net_http/exchange.rb(new) — the request/response driving engine:drive/drive_background/abort, plusrun/perform_exchange/push_response, the session-ownership handoff, and truncated-body detection. This is where the bulk of the old request-response logic moved to. Semantics preserved exactly: abort/session handoff and truncated-body detection are the same mechanisms you reviewed before — re-run the abort + connection_pool + truncation specs as the acceptance gate.net_http/stream.rb— shrinks from the ~340-line Fiber stream to a ~50-line handle wrapping anExchange:abortdelegates toExchange;write/close_writeraiseNotSupportedError(H1 is not bidirectional). No inbound data handling (removedeach_chunk, etc.).net_http/transport.rb—transmitdrives anExchangeinline (returnsnil);transmit_backgroundbackgrounds one (Thread.new { drive }) and wraps it in aStreamand returns it as a handle to caller.SDK wiring
send_handler.rb— builds aResponseSinkand selects transport method to call by mode:transmit_background+ store the handle whencontext[:event_stream], elsetransmit(no stored handle, noensure-abort — the transport owns teardown). SendHandler is no longer handles feeding data into response.response_sink.rb(new) — thin adapter forwardingheaders/data/done/errorontoHttp::Response#signal_*. Thesignal_*/on_*event bus is unchanged (the event bus / listener API for response is not removed in this PR, it will be added in a followup PR after this one).context[:duplex_stream]→context[:event_stream]Concurrency review (worth your attention)
The abort path got hardened, since
transmit_backgroundmeansabortruns on a different thread than the driver:sink.headers/sink.datagoes throughExchange#deliver, which checks@abortedand calls the sink atomically under@mutex— so nothing is delivered once an abort is recorded (previously relied on the socket close eventually breaking the read loop).mark_donenow nils@session, making "done ⇒ nothing left to finish" a hard invariant rather than an ordering coincidence.ConnectionPool#finish_session(session, endpoint = nil)— targeted delete when the endpoint is known (O(sessions-at-endpoint) instead of scanning the whole global pool); removal +finishremain atomic under@pool_mutex(an off-lock variant was tried and reverted because it moved the abort-vs-checkin guarantee onto the caller).One honest limitation: the guarantee is "no delivery after an abort is recorded." A concurrent abort still racing to acquire
@mutexcan be beaten by one in-flight chunk already insidedeliver. That's inherent to not blocking the reader thread, and is the intended semantics.Scope / what's NOT here
context[:event_stream]is not set by any production code yet (rpc_v2_cboronly usesevent_stream?for the Content-Type/Accept headers), sotransmit_background/Stream/the capability tiers are forward-compat scaffolding exercised only by tests today. There's aTODOfor the event-stream plugin to add the call-time fail-fast for an event op against an event-incapable transport (respond_to?can't detect aNotSupportedError-raising stub).TruncatedBodyErrorstays internal — nested inExchange,< IOError(same as V3'sSeahorsehandler), wrapped intoNetworkingErrorbefore it reaches callers (so it retries as a transient). A test pins that customers can still distinguish it viaNetworkingError#original_error.Tests
net_http/exchange_spec.rb(driving, abort, truncation, HEAD,drive_background, and no-delivery-after-abort).net_http/stream_spec.rbrewritten to test only the handle (abort delegation,NotSupportedError, contract tiers).support/transport_contract.rb/stream_contract.rbupdated to the push contract;support/recording_sink.rb(new) records the pushed lifecycle.net_http/transport_spec.rbnow leans onit_behaves_like 'a transport'(removed same-class re-tests) and adds anevent_queuespec.Suggested review order
lib/smithy-client/transport.rb+stream.rb— the contract.net_http/exchange.rb— confirm the abort/session/truncation semantics match what you reviewed onmain(this is where they moved), and scrutinize#deliver/#abort/run.net_http/stream.rb+net_http/transport.rb— the thin handle + two-method wiring.send_handler.rb+response_sink.rb— SDK-side wiring; note the event bus is untouched.connection_pool.rb— thefinish_sessionchange.By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.