From 31f428550c452ebfb609229fab0f566cabc70361 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 3 Aug 2026 13:00:55 +0100 Subject: [PATCH 1/4] Add RESPite.Transports.DuplexTransport + Tunnel.ConnectTransportAsync (SER009, experimental) The [Experimental] transport seam decided with Marc: a duplex byte-transport abstraction that is deliberately NOT a Stream and NOT a pipe, plus a null-default virtual on Tunnel that supplies the ENTIRE transport for a connection -- the same hijack as BeforeAuthenticateAsync one level deeper (no socket is created at all), returning null for every existing tunnel. The shape is derived from measured transport work rather than taste, and each member's derivation is in its doc comment: any-thread copying writes with an explicit Flush (batching at the caller's natural boundaries was the largest single lever measured), PUSH inbound with transport-owned spans (pull adapters over a push transport measured 24-40%), and an OnBatchEnd notification (coalescing burst responses into one flush eliminated a measured 3x send amplification). Abstract classes by design so members can be added with safe defaults while experimental; receive-into-caller-buffer is deliberately deferred to exactly that mechanism. House ceremony: SER009 registered in Experiments, docs/exp/SER009.md, PublicAPI entries in both projects with the [SER009] experimental prefix. A working SocketSet implementation of this shape already exists and is gated across plaintext/TLS/@abstract surfaces (SocketSet repo, src/SocketSet.StackExchange.Redis + bench/tunnel-selftest); it retargets to these types next. --- docs/exp/SER009.md | 11 ++++ src/RESPite/PublicAPI/PublicAPI.Unshipped.txt | 11 ++++ src/RESPite/Shared/Experiments.cs | 1 + src/RESPite/Transports/DuplexTransport.cs | 57 +++++++++++++++++++ .../Configuration/Tunnel.cs | 11 ++++ .../PublicAPI/PublicAPI.Unshipped.txt | 1 + 6 files changed, 92 insertions(+) create mode 100644 docs/exp/SER009.md create mode 100644 src/RESPite/Transports/DuplexTransport.cs diff --git a/docs/exp/SER009.md b/docs/exp/SER009.md new file mode 100644 index 0000000000..e68d6f5ee6 --- /dev/null +++ b/docs/exp/SER009.md @@ -0,0 +1,11 @@ +`RESPite.Transports.DuplexTransport` (and `TransportReceiver`) are an experimental transport +abstraction: staged-and-flushed outbound, push inbound with transport-owned memory, and an explicit +batch-end notification. They exist to let alternative IO engines plug in beneath the library (see +`Tunnel.ConnectTransportAsync`) without being constrained to `Stream` or pipe semantics. + +While experimental: + +1. The shape may gain members (the abstract-class form exists so additions can default safely) or + change semantics between minor versions. +2. Implementations and consumers should expect to track such changes without the usual + breaking-change ceremony. diff --git a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c58110..5b4a3e94c6 100644 --- a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,12 @@ #nullable enable +[SER009]RESPite.Transports.DuplexTransport +[SER009]RESPite.Transports.DuplexTransport.DuplexTransport() -> void +[SER009]abstract RESPite.Transports.DuplexTransport.DisposeAsync() -> System.Threading.Tasks.ValueTask +[SER009]abstract RESPite.Transports.DuplexTransport.Flush() -> bool +[SER009]abstract RESPite.Transports.DuplexTransport.Output.get -> System.Buffers.IBufferWriter! +[SER009]abstract RESPite.Transports.DuplexTransport.Start(RESPite.Transports.TransportReceiver! receiver) -> void +[SER009]RESPite.Transports.TransportReceiver +[SER009]RESPite.Transports.TransportReceiver.TransportReceiver() -> void +[SER009]abstract RESPite.Transports.TransportReceiver.OnReceived(System.ReadOnlySpan payload) -> bool +[SER009]virtual RESPite.Transports.TransportReceiver.OnBatchEnd() -> void +[SER009]virtual RESPite.Transports.TransportReceiver.OnClosed(System.Exception? fault) -> void diff --git a/src/RESPite/Shared/Experiments.cs b/src/RESPite/Shared/Experiments.cs index f72ece073b..39fb2426d7 100644 --- a/src/RESPite/Shared/Experiments.cs +++ b/src/RESPite/Shared/Experiments.cs @@ -20,6 +20,7 @@ internal static class Experiments public const string UnitTesting = "SER005"; public const string GeoRedundantFailover = "SER007"; public const string Server_8_10 = "SER008"; + public const string Transport = "SER009"; // ReSharper restore InconsistentNaming diff --git a/src/RESPite/Transports/DuplexTransport.cs b/src/RESPite/Transports/DuplexTransport.cs new file mode 100644 index 0000000000..28a14a1a4e --- /dev/null +++ b/src/RESPite/Transports/DuplexTransport.cs @@ -0,0 +1,57 @@ +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace RESPite.Transports; + +/// +/// A duplex byte transport, deliberately NOT a and NOT a pipe: outbound +/// is staged-and-flushed (batching is an explicit contract point, not an implementation accident), and +/// inbound is PUSH — the transport delivers bytes to a on the +/// transport's own schedule, in transport-owned memory. +/// +/// The shape is derived from measured transport work rather than taste: any-thread copying writes with +/// an explicit flush (batching at the caller's natural boundaries was the largest single lever +/// measured), push delivery (pull adapters over a push transport measured 24-40% overhead), and a +/// batch-end notification (coalescing responses produced during a delivery burst into one flush +/// eliminated a measured 3x send amplification). +/// +[Experimental(Experiments.Transport, UrlFormat = Experiments.UrlFormat)] +public abstract class DuplexTransport : IAsyncDisposable +{ + /// Stage outbound bytes. Callable from any thread; bytes are copied during the call, so + /// caller buffers need not survive it. + public abstract IBufferWriter Output { get; } + + /// Hand everything staged since the last flush to the wire, as one send where the + /// transport allows. Returns false if the transport is closed (staged bytes are dropped). + public abstract bool Flush(); + + /// Begin inbound delivery. Exactly one receiver, set once, before any data is expected; + /// delivery runs on the transport's schedule and threads. + public abstract void Start(TransportReceiver receiver); + + public abstract ValueTask DisposeAsync(); +} + +/// +/// The consumer half of . Callbacks run on the transport's threads and +/// must be bounded and non-blocking; anything long-running belongs on the consumer's own scheduler. +/// +[Experimental(Experiments.Transport, UrlFormat = Experiments.UrlFormat)] +public abstract class TransportReceiver +{ + /// Bytes arrived. is TRANSPORT-OWNED and valid only for the + /// duration of the call — copy anything retained. Return false to request the transport close. + public abstract bool OnReceived(ReadOnlySpan payload); + + /// A delivery burst has ended (for loop transports: the event batch is drained). Flush + /// anything staged in response to the burst HERE, once, rather than per — + /// per-callback flushing measurably amplifies peer segmentation. + public virtual void OnBatchEnd() { } + + /// The transport closed; fires exactly once. is the failure when + /// the transport can attribute one, else null (a clean or unattributed close). + public virtual void OnClosed(Exception? fault) { } +} diff --git a/src/StackExchange.Redis/Configuration/Tunnel.cs b/src/StackExchange.Redis/Configuration/Tunnel.cs index 9c28cbd2db..72cdbc4d26 100644 --- a/src/StackExchange.Redis/Configuration/Tunnel.cs +++ b/src/StackExchange.Redis/Configuration/Tunnel.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Net; using System.Net.Sockets; @@ -35,6 +36,16 @@ public abstract class Tunnel /// public virtual ValueTask BeforeAuthenticateAsync(EndPoint endpoint, ConnectionType connectionType, Socket? socket, CancellationToken cancellationToken) => default; + /// + /// Optionally supply the ENTIRE transport for this connection — the same hijack as + /// one level deeper: instead of yielding a + /// over a socket the library owns, yield a + /// the tunnel owns, and no socket is created at + /// all. Return null (the default for every existing tunnel) for the standard socket path. + /// + [Experimental(RESPite.Experiments.Transport, UrlFormat = RESPite.Experiments.UrlFormat)] + public virtual ValueTask ConnectTransportAsync(EndPoint endpoint, ConnectionType connectionType, CancellationToken cancellationToken) => default; + private sealed class HttpProxyTunnel : Tunnel { public EndPoint Proxy { get; } diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c58110..ddf237e1ab 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +[SER009]virtual StackExchange.Redis.Configuration.Tunnel.ConnectTransportAsync(System.Net.EndPoint! endpoint, StackExchange.Redis.ConnectionType connectionType, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask From d3784c593d67ac0f2dca55fd1a9665954b51a08c Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 3 Aug 2026 13:44:41 +0100 Subject: [PATCH 2/4] SER009: the transport IS the IBufferWriter -- Output deleted, writer members abstract on the transport With Flush on the transport, a separate .Output object never truly described output; the split was inherited from IDuplexPipe, whose 3-object shape exists for independent Input/Output completion that this contract does not have. GetMemory/GetSpan/Advance are now abstract members of DuplexTransport itself, and passing the transport AS IBufferWriter grants stage-only access: the holder composes, the owner flushes at its batch boundary. TransportReceiver deliberately stays separate: it is the CONSUMER's half, and it must remain abstract-class-evolvable on the net461/netstandard2.0 targets, where interfaces cannot grow. Experimental (SER009), so this is a free change; PublicAPI entries updated to match. Implementation side re-gated ALL PASS across plaintext/TLS/@abstract before this landed. --- src/RESPite/PublicAPI/PublicAPI.Unshipped.txt | 4 +++- src/RESPite/Transports/DuplexTransport.cs | 21 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt index 5b4a3e94c6..63b3440f76 100644 --- a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt @@ -1,9 +1,11 @@ #nullable enable [SER009]RESPite.Transports.DuplexTransport [SER009]RESPite.Transports.DuplexTransport.DuplexTransport() -> void +[SER009]abstract RESPite.Transports.DuplexTransport.Advance(int count) -> void [SER009]abstract RESPite.Transports.DuplexTransport.DisposeAsync() -> System.Threading.Tasks.ValueTask [SER009]abstract RESPite.Transports.DuplexTransport.Flush() -> bool -[SER009]abstract RESPite.Transports.DuplexTransport.Output.get -> System.Buffers.IBufferWriter! +[SER009]abstract RESPite.Transports.DuplexTransport.GetMemory(int sizeHint = 0) -> System.Memory +[SER009]abstract RESPite.Transports.DuplexTransport.GetSpan(int sizeHint = 0) -> System.Span [SER009]abstract RESPite.Transports.DuplexTransport.Start(RESPite.Transports.TransportReceiver! receiver) -> void [SER009]RESPite.Transports.TransportReceiver [SER009]RESPite.Transports.TransportReceiver.TransportReceiver() -> void diff --git a/src/RESPite/Transports/DuplexTransport.cs b/src/RESPite/Transports/DuplexTransport.cs index 28a14a1a4e..94cda09e34 100644 --- a/src/RESPite/Transports/DuplexTransport.cs +++ b/src/RESPite/Transports/DuplexTransport.cs @@ -16,13 +16,26 @@ namespace RESPite.Transports; /// measured), push delivery (pull adapters over a push transport measured 24-40% overhead), and a /// batch-end notification (coalescing responses produced during a delivery burst into one flush /// eliminated a measured 3x send amplification). +/// +/// The transport IS the outbound — there is no separate output object. +/// Staging is callable from any thread (single logical writer at a time); bytes are owned by the +/// transport once returns; hands the staged bytes to the +/// wire. Passing the transport AS deliberately grants stage-only +/// access: the holder composes, the owner flushes at its batch boundary. /// [Experimental(Experiments.Transport, UrlFormat = Experiments.UrlFormat)] -public abstract class DuplexTransport : IAsyncDisposable +public abstract class DuplexTransport : IBufferWriter, IAsyncDisposable { - /// Stage outbound bytes. Callable from any thread; bytes are copied during the call, so - /// caller buffers need not survive it. - public abstract IBufferWriter Output { get; } + /// Request writable space to stage outbound bytes (see ). + public abstract Memory GetMemory(int sizeHint = 0); + + /// + public abstract Span GetSpan(int sizeHint = 0); + + /// Commit bytes obtained via or + /// ; the transport owns them when this returns, so caller state need not + /// survive it. + public abstract void Advance(int count); /// Hand everything staged since the last flush to the wire, as one send where the /// transport allows. Returns false if the transport is closed (staged bytes are dropped). From 499cb2f67151ecc570b0c329dc09e2db79faae7f Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 3 Aug 2026 13:54:03 +0100 Subject: [PATCH 3/4] SER009: GetSpan is a virtual convenience over GetMemory, not a second abstract GetMemory is the one true abstract of the writer face; GetSpan defaults to GetMemory(sizeHint).Span, so an implementer owes exactly one buffer-acquisition member. Transports with a genuinely cheaper span path (the SocketSet implementation forwards to its connection's own GetSpan) override it -- nice if they do, correct if they don't. --- src/RESPite/PublicAPI/PublicAPI.Unshipped.txt | 2 +- src/RESPite/Transports/DuplexTransport.cs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt index 63b3440f76..3b10f3da49 100644 --- a/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/RESPite/PublicAPI/PublicAPI.Unshipped.txt @@ -5,7 +5,7 @@ [SER009]abstract RESPite.Transports.DuplexTransport.DisposeAsync() -> System.Threading.Tasks.ValueTask [SER009]abstract RESPite.Transports.DuplexTransport.Flush() -> bool [SER009]abstract RESPite.Transports.DuplexTransport.GetMemory(int sizeHint = 0) -> System.Memory -[SER009]abstract RESPite.Transports.DuplexTransport.GetSpan(int sizeHint = 0) -> System.Span +[SER009]virtual RESPite.Transports.DuplexTransport.GetSpan(int sizeHint = 0) -> System.Span [SER009]abstract RESPite.Transports.DuplexTransport.Start(RESPite.Transports.TransportReceiver! receiver) -> void [SER009]RESPite.Transports.TransportReceiver [SER009]RESPite.Transports.TransportReceiver.TransportReceiver() -> void diff --git a/src/RESPite/Transports/DuplexTransport.cs b/src/RESPite/Transports/DuplexTransport.cs index 94cda09e34..2701d60b6b 100644 --- a/src/RESPite/Transports/DuplexTransport.cs +++ b/src/RESPite/Transports/DuplexTransport.cs @@ -30,7 +30,9 @@ public abstract class DuplexTransport : IBufferWriter, IAsyncDisposable public abstract Memory GetMemory(int sizeHint = 0); /// - public abstract Span GetSpan(int sizeHint = 0); + /// Defaults to GetMemory(sizeHint).Span; override when the transport has a cheaper + /// span path than a round-trip. + public virtual Span GetSpan(int sizeHint = 0) => GetMemory(sizeHint).Span; /// Commit bytes obtained via or /// ; the transport owns them when this returns, so caller state need not From 7a6635fa2e4f2e9179047aab56521827dc7d0d11 Mon Sep 17 00:00:00 2001 From: mgravell Date: Mon, 3 Aug 2026 13:58:18 +0100 Subject: [PATCH 4/4] SER009 doc: state plainly that this is not for external use at this time The seam is public only because a seam must be public to be implemented; the doc now says so, spells out that no stability of any kind is implied (members, semantics, name, home, existence -- any version, no ceremony), and frames suppressing the diagnostic as accepting exactly that. If a generally useful transport seam emerges it gets stabilised deliberately, not by this surface quietly hardening. --- docs/exp/SER009.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/exp/SER009.md b/docs/exp/SER009.md index e68d6f5ee6..a0f9218596 100644 --- a/docs/exp/SER009.md +++ b/docs/exp/SER009.md @@ -1,11 +1,22 @@ `RESPite.Transports.DuplexTransport` (and `TransportReceiver`) are an experimental transport -abstraction: staged-and-flushed outbound, push inbound with transport-owned memory, and an explicit -batch-end notification. They exist to let alternative IO engines plug in beneath the library (see -`Tunnel.ConnectTransportAsync`) without being constrained to `Stream` or pipe semantics. +abstraction: staged-and-flushed outbound (the transport itself is the `IBufferWriter`), push +inbound with transport-owned memory, and an explicit batch-end notification. They exist to let +alternative IO engines plug in beneath the library (see `Tunnel.ConnectTransportAsync`) without being +constrained to `Stream` or pipe semantics. -While experimental: +**This API is not intended for external use at this time.** It exists to support internal +experimentation with alternative transports, and is public only because a seam has to be public to be +implemented. Specifically: -1. The shape may gain members (the abstract-class form exists so additions can default safely) or - change semantics between minor versions. -2. Implementations and consumers should expect to track such changes without the usual - breaking-change ceremony. +1. **No stability whatsoever is implied.** The shape may gain members, change semantics, be renamed, + move assembly or namespace, or be removed outright — between *any* two versions, including + patches, with no breaking-change ceremony, no deprecation period, and no migration notes. +2. **Do not implement or consume it in code you ship.** If you build on it anyway, you are accepting + that any update may break you without warning, and issues asking for compatibility or support for + it will be closed. +3. The `[Experimental]` diagnostic (`SER009`) is the enforcement mechanism: suppressing it is you + signing up to the above. + +If a transport seam useful beyond this experiment emerges, it will be stabilised deliberately — with +its own announcement and a removed `[Experimental]` marker — rather than by this surface quietly +hardening into a contract.