Three-commit plan to unblock api.alk.dev (standard TCP+TLS HTTP), alknet-ssh (per-channel dispatch over russh), and alknet-blobs (iroh 1.0 prerequisite): 1. Drop dead irpc/irpc-derive workspace deps (never imported by any .rs file) 2. Migrate alknet-core iroh 0.35 -> 1.0 (3 edits in build_iroh_endpoint + Cargo.toml) 3. Add Connection::from_stream / from_bidi — a generic single-stream connection kind that makes every ProtocolHandler work over TCP+TLS, SSH channels, WebTransport streams, and wasm without handler code changes. Rename the existing stream-level Mock variants to Stream (they were already generic stream holders, just misnamed). The yield-once accept_bi contract: QUIC yields many streams, everything else yields one. Handlers that loop (tty) get one iteration; handlers that call once (http) get the stream directly. Both correct, no branching on transport. No ProtocolHandler trait shape change (one-way door per ADR-009). SSH's per-channel dispatch is handler-internal, same pattern as TtyAdapter's per-stream dispatch. Incorporates ../iroh-update/findings.md by reference.
30 KiB
Transport Generalization — from_stream + iroh 1.0 + dead irpc
Status: Findings complete. Three-step clean sweep planned; not yet applied. Two free wins (dead irpc dep, iroh 0.35→1.0 migration) and one structural unlock (Connection::from_stream — a generic single-stream connection kind that makes every ProtocolHandler work over TCP+TLS, SSH channels, WebTransport streams, and wasm without handler code changes).
Date: 2026-07-08
Scope: Resolves the QUIC-welding problem in alknet-core/src/types.rs that blocks api.alk.dev (needs standard TCP+TLS HTTP) and alknet-ssh (needs per-channel dispatch over russh). Incorporates the iroh 1.0 migration from ../iroh-update/findings.md so the workspace lands on a single consistent iroh version in the same sweep. Traces the exact Connection / SendStream / RecvStream edits and the downstream test call-site renames so the sweep is one concrete PR series, not a TODO.
TL;DR
-
Connection,SendStream, andRecvStreamare welded to QUIC.ConnectionKind::{Quinn, Iroh, Mock}— both real variants are QUIC.HttpAdapter::handle(alknet-http/src/server/adapter.rs:232) callsconnection.accept_bi().awaitto get one bidi stream and serves HTTP over it. That's "HTTP over QUIC," not HTTP over TCP+TLS. There is no way to serveapi.alk.dev's required standard HTTP interface without either bypassing theHandlerRegistry(a parallel listener) or generalizingConnectionto accept a non-QUIC stream. -
The generalization is small and additive. Add
ConnectionKind::Stream— a single read/write pair behind aMutex<Option<...>>thataccept_biyields once (thenConnectionClosed). AddConnection::from_stream(send, recv, alpn, remote_addr)andConnection::from_bidi(stream, alpn, remote_addr). Rename the existing stream-levelMockvariants toStream(they're alreadyBox<dyn AsyncRead/Write>— the wrong name;from_mock→from_stream). ~60 lines added totypes.rs, no new deps, noCargo.tomlchange. Every existingProtocolHandler(HttpAdapter,TtyAdapter, call handler) works over the new kind unchanged. -
The yield-once contract composes.
accept_bion QUIC returns a new stream per call (many).accept_bionStreamreturns the underlying stream on the first call, thenConnectionClosed. Handlers that loopaccept_bi(tty) get one session per single-stream connection; handlers that call once (http) get the stream directly. Both correct, no branching on transport. This is the contract that makes SSH channels, TCP+TLS, WebTransport streams, and wasm streams all look the same to handlers. -
alknet-sshfalls out for free. SSH is aProtocolHandleronalknet/sshthat parses SSH bytes via russh and dispatches channels — exactly the patternTtyAdapteralready establishes (alknet-tty/src/adapter.rs:120-133loopsaccept_bi, dispatches each stream todrive_session). The SSH handler parses channels, wraps each channel's read/write as aConnectionviafrom_stream, and dispatches through the sameHandlerRegistryby channel-type string (channel-type == ALPN string). russh is a byte parser — it doesn't care whether the bytes come from QUIC, TCP+TLS, or wasm. Zero core changes beyondfrom_stream. -
The iroh 0.35→1.0.2 migration is a prerequisite for
alknet-blobsand fits in the same sweep.alknet-core'siroh 0.35dep is load-bearing (endpoint.rs:676-704uses ~15 iroh APIs; 3 broke between 0.35 and 1.0). The migration is 3 edits inbuild_iroh_endpoint+ aCargo.tomlbump. The deadirpc 0.16workspace dep (Cargo.toml:19-20, never imported by any.rsfile) dissolves the other half of the version gap with zero code impact. Both are standalone commits that should land beforefrom_streamso the workspace is on a single consistent iroh version when the structural change lands. -
No trait shape change.
ProtocolHandler::handle(&self, connection: Connection, &AuthContext)stays. The multiplexing loop stays in the handler (asTtyAdapteralready does). The endpoint does not own the loop. SSH's per-channel dispatch happens inside the SSH handler, not in the endpoint — same as tty's per-stream dispatch happens inside the tty handler.
1. The QUIC-welding problem
1.1 Connection is QUIC-only
crates/alknet-core/src/types.rs:362-368:
enum ConnectionKind {
#[cfg(feature = "quinn")]
Quinn(quinn::Connection),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::Connection),
Mock(Arc<dyn MockConnection + Send + Sync>),
}
Both real variants are QUIC. Mock is a test-only trait stub whose accept_bi returns StreamClosed (types.rs:430) — it cannot serve a real stream.
SendStreamKind / RecvStreamKind (types.rs:228-242) are the same shape — Quinn / Iroh / Mock, where Mock is Box<dyn AsyncWrite> / Box<dyn AsyncRead>. The stream-level Mock variants are generic stream holders, just misnamed. They're the seed of the generalization.
1.2 HttpAdapter inherits the welding
crates/alknet-http/src/server/adapter.rs:227-238:
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
if let Some(identity) = auth.identity.clone() {
let _ = connection.set_identity(identity);
}
let (send, recv) = connection
.accept_bi()
.await
.map_err(stream_error_to_handler)?;
let io = QuicStream::new(send, recv);
self.serve_io(io).await
}
serve_io (adapter.rs:241-264) is already generic over AsyncRead + AsyncWrite + Send + Unpin + 'static — the only QUIC-coupled line in the whole HTTP adapter is connection.accept_bi().await. QuicStream (adapter.rs:271-314) just forwards poll_read/poll_write/poll_flush/poll_shutdown to the SendStream/RecvStream pair. It's a thin adapter, not a QUIC dependency.
So HttpAdapter is one accept_bi call away from being transport-agnostic. The problem is that accept_bi only works when the Connection is QUIC-kind.
1.3 api.alk.dev needs standard HTTP
The api.alk.dev hub requires:
- A standard TCP+TLS HTTP interface (h2/http1.1) for browser clients, the OpenAI-compatible proxy, the streamable MCP endpoint, and WebSocket upgrade
- A QUIC endpoint for worker/spoke connections (vast.ai GPU instances registering with per-instance tokens, proxying Ollama cloud access without leaking keys)
- Eventually, SSH channel access over TCP/TLS/QUIC for remote terminal sessions
The first bullet is blocked today. The reverse-proxy at /workspace/@alkdev/reverse-proxy/src/server.rs:56-136 (serve_https_listener) is the exact template for what's needed: tokio_rustls::TlsAcceptor → check alpn_protocol() → hyper::server::conn::http2::Builder or hyper_util::server::conn::auto::Builder over TokioIo::new(tls_stream). But wiring it through the HandlerRegistry/ALPN router (so it's not a parallel listener bypassing the core) requires Connection to accept a TlsStream<TcpStream>.
1.4 alknet-ssh is blocked on the same thing
alknet-tty already shows the pattern. TtyAdapter::handle (alknet-tty/src/adapter.rs:116-135):
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
if let Some(identity) = auth.identity.clone() {
let _ = connection.set_identity(identity);
}
loop {
let (send, recv) = match connection.accept_bi().await {
Ok(pair) => pair,
Err(StreamError::ConnectionClosed) => break,
Err(StreamError::StreamClosed) => break,
Err(e) => return Err(HandlerError::from(e)),
};
let backends = self.backends.clone();
let ownership = self.ownership.clone();
let identity = auth.identity.clone();
tokio::spawn(async move {
let _ = drive_session(send, recv, backends, ownership, identity).await;
});
}
Ok(())
}
And drive_session (adapter.rs:175-187) is generic over AsyncRead + AsyncWrite:
pub async fn drive_session(
client_send: impl AsyncWrite + Send + Unpin + 'static,
client_recv: impl AsyncRead + Send + Unpin + 'static,
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
ownership: Option<Arc<dyn OwnershipProvider>>,
identity: Option<Identity>,
)
The session driver is already transport-agnostic. Only handle() is QUIC-coupled (it loops accept_bi).
SSH is the same pattern, richer: SshHandler::handle parses SSH bytes, channels open with a type string, and each channel's read/write is handed to the service for that channel-type. If channel-type == ALPN string, the SSH handler dispatches through the same HandlerRegistry every other transport uses — one SSH connection carries heterogeneous channels (alknet/tty, alknet/call, h2, ...). This is SSH's real multiplexing power, and it's the thing QUIC doesn't give you natively (QUIC's ALPN is per-connection, so all bidi streams share one handler).
For the SSH handler to dispatch channels through HandlerRegistry, it needs to wrap each channel as a Connection. That's from_stream.
2. The generalization: Connection::from_stream
2.1 The new ConnectionKind::Stream
Add a variant that holds a single read/write pair behind a Mutex<Option<...>> for the yield-once semantic:
struct StreamConn {
stream: Mutex<Option<(SendStream, RecvStream)>>,
remote_addr: Option<SocketAddr>,
}
enum ConnectionKind {
#[cfg(feature = "quinn")]
Quinn(quinn::Connection),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::Connection),
Mock(Arc<dyn MockConnection + Send + Sync>),
Stream(StreamConn),
}
StreamConn is always available (no feature gate) — it's generic, no transport deps.
2.2 The constructors
impl Connection {
/// Construct a Connection from a pre-split read/write pair.
/// `accept_bi()` yields this pair once, then returns `ConnectionClosed`.
/// `open_bi()` returns `StreamClosed` (a single stream can't open new streams).
pub fn from_stream(
send: impl AsyncWrite + Send + Unpin + 'static,
recv: impl AsyncRead + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self {
Self {
kind: ConnectionKind::Stream(StreamConn {
stream: Mutex::new(Some((
SendStream::from_stream(send),
RecvStream::from_stream(recv),
))),
remote_addr,
}),
alpn,
identity: OnceLock::new(),
}
}
/// Convenience for a single bidirectional stream (e.g. TlsStream<TcpStream>).
/// Splits internally via tokio::io::split.
pub fn from_bidi(
stream: impl AsyncRead + AsyncWrite + Send + Unpin + 'static,
alpn: Vec<u8>,
remote_addr: Option<SocketAddr>,
) -> Self {
let (recv, send) = tokio::io::split(stream);
Self::from_stream(send, recv, alpn, remote_addr)
}
}
Note: tokio::io::split returns (ReadHalf, WriteHalf) — the tuple is (recv, send).
2.3 The accept_bi contract (made explicit)
/// Yield the next bidirectional stream this connection provides.
///
/// # Transport semantics
///
/// - **QUIC (quinn/iroh)**: returns a new bidi stream on each call.
/// `ConnectionClosed` when the underlying connection closes.
/// - **TCP+TLS / single-stream**: yields the underlying stream on the
/// first call, then `ConnectionClosed` on all subsequent calls.
/// A single transport stream cannot open new application streams.
///
/// Handlers that loop `accept_bi` (e.g. `TtyAdapter`) get one session
/// per single-stream connection; handlers that call once (e.g.
/// `HttpAdapter`) get the stream directly. Both are correct.
pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> { ... }
This is the contract that makes the abstraction compose: QUIC is "many streams," everything else is "one stream," and the handler code doesn't branch on which.
2.4 The method arms
impl Connection {
pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
match &self.kind {
#[cfg(feature = "quinn")]
ConnectionKind::Quinn(c) => { /* unchanged */ }
#[cfg(feature = "iroh")]
ConnectionKind::Iroh(c) => { /* unchanged */ }
ConnectionKind::Mock(_) => Err(StreamError::StreamClosed),
ConnectionKind::Stream(sc) => {
let mut guard = sc.stream.lock().expect("stream mutex poisoned");
match guard.take() {
Some(pair) => Ok(pair),
None => Err(StreamError::ConnectionClosed),
}
}
}
}
pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
match &self.kind {
#[cfg(feature = "quinn")]
ConnectionKind::Quinn(c) => { /* unchanged */ }
#[cfg(feature = "iroh")]
ConnectionKind::Iroh(c) => { /* unchanged */ }
ConnectionKind::Mock(_) => Err(StreamError::StreamClosed),
ConnectionKind::Stream(_) => Err(StreamError::StreamClosed),
}
}
pub fn remote_addr(&self) -> Option<SocketAddr> {
match &self.kind {
#[cfg(feature = "quinn")]
ConnectionKind::Quinn(c) => Some(c.remote_address()),
#[cfg(feature = "iroh")]
ConnectionKind::Iroh(_) => None,
ConnectionKind::Mock(m) => m.remote_addr(),
ConnectionKind::Stream(sc) => sc.remote_addr,
}
}
pub fn close(&self, _code: u32, _reason: &str) {
match &self.kind {
#[cfg(feature = "quinn")]
ConnectionKind::Quinn(c) => { /* unchanged */ }
#[cfg(feature = "iroh")]
ConnectionKind::Iroh(c) => { /* unchanged */ }
ConnectionKind::Mock(m) => m.close(_code, _reason),
ConnectionKind::Stream(sc) => {
// Drop the stream if still held (accept_bi never called).
// If already taken, no-op — the holder owns the lifecycle.
let _ = sc.stream.lock().expect("stream mutex poisoned").take();
}
}
}
}
The code/reason args to close are QUIC-specific (application-level close codes). For a raw stream they're ignored — the drop is the close. This is the same best-effort semantic close already has for Mock.
2.5 The rename: stream-level Mock → Stream
The existing SendStreamKind::Mock(Box<dyn AsyncWrite>) and RecvStreamKind::Mock(Box<dyn AsyncRead>) (types.rs:228-242) are already generic stream holders — the name is wrong. Rename to Stream:
enum SendStreamKind {
#[cfg(feature = "quinn")]
Quinn(quinn::SendStream),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::SendStream),
Stream(Box<dyn AsyncWrite + Send + Unpin>),
}
enum RecvStreamKind {
#[cfg(feature = "quinn")]
Quinn(quinn::RecvStream),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::RecvStream),
Stream(Box<dyn AsyncRead + Send + Unpin>),
}
Constructors: SendStream::from_mock → from_stream, RecvStream::from_mock → from_stream. Drop the #[allow(dead_code)] — from_stream is now load-bearing (called by Connection::from_stream).
The poll_* match arms: SendStreamKind::Mock(s) → SendStreamKind::Stream(s) (3 sites in SendStream's AsyncWrite impl at types.rs:298-342), RecvStreamKind::Mock(s) → RecvStreamKind::Stream(s) (1 site in RecvStream's AsyncRead impl at types.rs:344-360).
The connection-level ConnectionKind::Mock / MockConnection trait (types.rs:370-375) is a different thing — a test-only full-connection mock. It stays untouched. Only the stream-level Mock variants are renamed.
2.6 Downstream call-site renames
The stream-level from_mock is used by tests in alknet-call:
crates/alknet-call/src/protocol/dispatch.rs:1200-1201, 1239-1240, 1275-1276, 1306-1307, 1366-1367—SendStream::from_mock/RecvStream::from_mockcrates/alknet-call/src/protocol/adapter.rs:1213-1214, 1253-1254— same
These are mechanical renames (s/from_mock/from_stream/g for the stream-level calls). The Connection::from_mock(Arc<StubConnection>) call sites (dispatch.rs:483, connection.rs:484, adapter.rs:312, from_call.rs:461, call_client.rs:602) stay unchanged — those use the connection-level Mock which is not renamed.
No backwards-compatibility shims. No #[deprecated] aliases. Clean rename — no one is using the develop branch yet, and the project that would build against it couldn't proceed because of the HTTP/TCP issue this sweep resolves.
2.7 What's NOT changing
ProtocolHandlertrait shape — unchanged.handle(&self, connection: Connection, &AuthContext)stays.HandlerRegistry— unchanged.AlknetEndpoint— unchanged in this commit. The TCP+TLS accept loop that usesfrom_streamis a separate follow-up commit.ConnectionKind::Mock/MockConnectiontrait — stays for test-only full-connection mocks.- All handler code (
HttpAdapter,TtyAdapter, call handler) — unchanged. - No
Cargo.tomlchanges —from_streamis pure code, no new deps.tokio::io::splitis already available via the existingtokiodep.
2.8 What this unlocks
Every existing ProtocolHandler works over:
| Substrate | How from_stream is used |
Multiplexing |
|---|---|---|
| QUIC (quinn/iroh) | unchanged — ConnectionKind::Quinn/Iroh |
many bidi streams per connection (accept_bi loops) |
| TCP+TLS | from_bidi(TlsStream<TcpStream>, alpn, remote_addr) in a TLS accept loop |
one stream per connection (accept_bi yields once) |
| SSH channels | SSH handler wraps each russh channel via from_stream, dispatches by channel-type through HandlerRegistry |
one connection, many channels, heterogeneous handlers |
| WebTransport streams | WT handler wraps each WT stream via from_stream |
one session, many streams |
| Wasm | from_stream takes any AsyncRead + AsyncWrite, including wasm-compatible streams |
per-stream |
No handler code changes. The accept_bi contract is: "yield the stream(s) the transport provides, then ConnectionClosed." QUIC provides many; everything else provides one. Handlers that loop (tty) get one iteration; handlers that call once (http) get the stream directly. Both correct.
3. The iroh 0.35 → 1.0.2 migration (in the same sweep)
This section incorporates ../iroh-update/findings.md by reference. The full trace is there; this section summarizes what lands in the sweep.
3.1 Drop the dead irpc dep (free, standalone commit)
Cargo.toml:19-20 declares irpc = "0.16" / irpc-derive = "0.16" as workspace deps. No .rs file in the workspace imports irpc — alknet-call's wire protocol (src/protocol/wire.rs) is hand-rolled length-prefixed JSON. The dep is dead.
Remove:
Cargo.toml:19-20— deleteirpc/irpc-deriveworkspace depscrates/alknet-call/Cargo.toml:18— deleteirpc = { workspace = true }
Re-add as 0.17 when alknet-blobs lands (it depends on iroh-blobs 0.103 which pulls irpc 0.17 transitively). Zero code impact.
3.2 Bump alknet-core iroh to 1.0 (3 edits + Cargo.toml)
crates/alknet-core/Cargo.toml:21:
# from:
iroh = { version = "0.35", optional = true }
# to:
iroh = { version = "1.0", optional = true, default-features = false, features = ["tls-aws-lc-rs"] }
default-features = false drops tls-ring so presets::Minimal selects aws-lc-rs (matching the quinn path's existing rustls::crypto::aws_lc_rs::default_provider() at endpoint.rs:551,631).
crates/alknet-core/src/endpoint.rs:676-704 — three edits in build_iroh_endpoint:
iroh::Endpoint::builder()→iroh::Endpoint::builder(iroh::endpoint::presets::Minimal)— supplies the now-mandatorycrypto_provideriroh::SecretKey::from_bytes(...)now returnsResult<Self, KeyParsingError>→ add?+.map_err(|e| EndpointError::TlsConfig(io::Error::other(e)))?iroh::SecretKey::generate(&mut csprng)→iroh::SecretKey::generate()— drop the&mut csprngarg and thelet mut csprng = rand::rngs::OsRng;line
See ../iroh-update/findings.md §4-7 for the full trace and diff.
3.3 Post-migration checks
cargo build --features iroh— verify the 3 edits compilecargo build --all-features— verify quinn+iroh+acme together- Check
ConnectionErrormatch arms intypes.rs:824-856for exhaustiveness (new variants in 1.0 would surface as compile errors if the match is exhaustive without_) - Run iroh-feature tests (
endpoint.rs:1107-1150, 1326-1364) - Survey
randusage —endpoint.rs:687'sOsRngmay go dead, buted25519-dalekstill needsrand_core, so check before removing fromCargo.toml
4. Recommended execution order
Three commits, one sweep. Each is standalone and ships value.
Commit 1: drop dead irpc dep
- Delete
irpc/irpc-derivefrom workspaceCargo.toml:19-20 - Delete
irpc = { workspace = true }fromcrates/alknet-call/Cargo.toml:18 cargo build— verify nothing broke (nothing importsirpc)- Standalone, free, dissolves the
irpchalf of the version gap
Commit 2: iroh 1.0 migration
crates/alknet-core/Cargo.toml:21— bump toiroh = { version = "1.0", optional = true, default-features = false, features = ["tls-aws-lc-rs"] }crates/alknet-core/src/endpoint.rs:676-704— 3 edits inbuild_iroh_endpoint(see §3.2)cargo update -p iroh -p iroh-base -p iroh-relaycargo build --features irohandcargo build --all-features- Run iroh-feature tests
- Check
types.rs:824-856ConnectionErrormatch arms - Survey
rand— remove if dead - Standalone, 3-edit migration, stable target (iroh 1.0.2)
Commit 3: from_stream generalization
crates/alknet-core/src/types.rs:- Add
use std::sync::Mutexto the existinguse std::sync::{Arc, OnceLock};(line 9) - Add
StreamConnstruct - Add
ConnectionKind::Stream(StreamConn)variant - Add
Connection::from_streamandConnection::from_bidiconstructors - Add
accept_bi/open_bi/remote_addr/closearms forStream - Add
accept_bidoc comment (the yield-once contract) - Rename
SendStreamKind::Mock→::Stream,RecvStreamKind::Mock→::Stream - Rename
SendStream::from_mock→from_stream,RecvStream::from_mock→from_stream - Update the 4
poll_*match arms (3 inSendStream'sAsyncWrite, 1 inRecvStream'sAsyncRead) - Drop
#[allow(dead_code)]on the renamed constructors
- Add
crates/alknet-call/src/protocol/dispatch.rs:1200-1201, 1239-1240, 1275-1276, 1306-1307, 1366-1367— renamefrom_mock→from_stream(stream-level only)crates/alknet-call/src/protocol/adapter.rs:1213-1214, 1253-1254— same renamecargo build(no features) —from_streamavailable without quinn/irohcargo build --features quinn— unchanged behaviorcargo build --features iroh— unchanged behaviorcargo build --all-features— unchanged behaviorcargo test --workspace— existing tests pass
~60 lines added to types.rs, ~10 mechanical renames in alknet-call tests. No Cargo.toml change. No handler code changes.
5. What this closes and what remains
Closes:
- The QUIC-welding problem in
Connection/SendStream/RecvStream.from_streammakes anyAsyncRead + AsyncWritepair a first-classConnection, dispatchable through the sameHandlerRegistryas QUIC connections. - The
api.alk.devHTTP blocker. After this sweep, a TCP+TLS accept loop can callConnection::from_bidi(tls_stream, alpn, remote_addr)and dispatch throughHandlerRegistry—HttpAdapterworks unchanged. (The accept loop itself is a follow-up commit, but the primitive it needs is now in place.) - The
alknet-sshblocker. The SSH handler can wrap each channel viafrom_streamand dispatch by channel-type throughHandlerRegistry. Zero core changes beyond what this sweep lands. - The iroh version gap. Workspace is on a single consistent iroh 1.0.x, unblocking
alknet-blobs(which pullsiroh 1.0+irpc 0.17transitively viairoh-blobs 0.103). - The dead
irpc 0.16dep — removed, not bumped. Re-added as0.17whenalknet-blobsneeds it.
Does not close (follow-up commits, unblocked by this sweep):
- The TCP+TLS accept loop on
AlknetEndpoint(usesfrom_stream— the primitive exists after this sweep; the loop is ~40 lines modeled on/workspace/@alkdev/reverse-proxy/src/server.rs:56-136). alknet-sshcrate (usesfrom_streamto wrap channels asConnections; russh lives where its transport dep lives, same pattern asalknet-tty).alknet-blobscrate (depends on iroh 1.0, which this sweep lands; the~20-lineiroh-blobs fork is the separatealknet-blobseffort per../alknet-filesystem/alknet-blobs-external-store-probe.md).- WebTransport relay/proxy layer (uses
from_streamto wrap WT streams — the primitive exists; WT itself is parked per ADR-044). - The
api.alk.devhub assembly (wiresalknet-httpwith OAI-compatible custom routes viaHttpAdapter::with_extra_routesatadapter.rs:143, MCP gateway viato_mcp_serviceatadapter.rs:177, WebSocket viaws_upgrade_handleratadapter.rs:189; worker registration + proxying viaalknet-call+IdentityProvider::resolve_from_token). This is assembly-layer wiring of existing pieces, not new core abstractions.
6. Why no trait shape change
An earlier analysis proposed changing ProtocolHandler::handle to take a single Channel instead of a Connection, moving the multiplexing loop from the handler to the endpoint. That is not what this sweep does. Reasons:
-
TtyAdapteralready establishes the pattern. The handler loopsaccept_biand dispatches each stream internally. SSH does the same — parse channels, dispatch each. The multiplexing loop belongs in the handler, not the endpoint. -
SSH's per-channel dispatch is a handler-internal concern. The SSH handler reads channel-type strings and dispatches through
HandlerRegistryby treating channel-type as the ALPN. The endpoint doesn't know about channel-types — it just handed the SSH handler aConnection(over QUIC, TCP+TLS, or wasm) and the SSH handler does the rest. This matches howTtyAdapterreads thebackendstring from the negotiation frame and dispatches toTtyBackend— sub-dispatch is handler-internal. -
The trait shape is a one-way door (ADR-009). Changing
handle(Connection)→handle(Channel)would require migrating every handler and would lock in a specific multiplexing model. Thefrom_streamapproach is additive — it extendsConnectionto accept non-QUIC streams without touching the trait. If a trait change is ever warranted, it can come later;from_streamdoesn't preclude it. -
QUIC's per-connection ALPN limitation is real but acceptable. Over QUIC, all bidi streams on one connection share one handler (the connection's ALPN). Over SSH, channels can have different types (per-channel dispatch). This asymmetry is inherent to the transports, not a flaw in the abstraction. Handlers that want per-stream dispatch over QUIC can implement their own sub-dispatch (as
TtyAdapterdoes with thebackendstring). The abstraction doesn't force uniformity where the transports differ.
References
- iroh migration:
../iroh-update/findings.md— full trace of the 3 breaking APIs, thePresetselection (presets::Minimal), the Cargo.toml changes, and the execution order. This sweep incorporates that migration as commit 2. alknet-blobsprerequisite:../alknet-filesystem/alknet-blobs-external-store-probe.md§"Versioning reality" — the version gap this sweep's commits 1-2 resolve.Connection/SendStream/RecvStream:crates/alknet-core/src/types.rs:228-489— the QUIC-welded types this sweep generalizes.AlknetEndpoint:crates/alknet-core/src/endpoint.rs:118-277— the QUIC-only endpoint (unchanged in this sweep; the TCP+TLS loop that usesfrom_streamis a follow-up).build_iroh_endpoint:crates/alknet-core/src/endpoint.rs:676-704— the 3 edits in commit 2.HttpAdapter::handle:crates/alknet-http/src/server/adapter.rs:227-238— theaccept_bicall that works unchanged overfrom_stream.HttpAdapter::serve_io:crates/alknet-http/src/server/adapter.rs:241-264— already generic overAsyncRead + AsyncWrite.TtyAdapter::handle:crates/alknet-tty/src/adapter.rs:116-135— theaccept_biloop that works unchanged (yields once over single-stream, loops over QUIC).drive_session:crates/alknet-tty/src/adapter.rs:175-187— already generic overAsyncRead + AsyncWrite.- TCP+TLS listener template:
/workspace/@alkdev/reverse-proxy/src/server.rs:56-136(serve_https_listener) — the model for the follow-up TCP+TLS accept loop. - SSH reference (russh):
/workspace/russh/— the byte parseralknet-sshwill wrap as aProtocolHandler. - ADR-010:
docs/architecture/decisions/010-alpn-router-and-endpoint.md— the ALPN router and endpoint design (TCP is not an endpoint concern; ALPN dispatch replaces byte-peeking).from_streamis the primitive that lets TCP+TLS participate in ALPN dispatch without changing the endpoint design. - ADR-009:
docs/architecture/decisions/009-one-way-door-decision-framework.md— why theProtocolHandlertrait shape is not changed (one-way door;from_streamis additive, not a trait change). - Dead
irpcdep:Cargo.toml:19-20(workspace),crates/alknet-call/Cargo.toml:18(consumer),crates/alknet-call/src/protocol/wire.rs(hand-rolled JSON framing, noirpc). - Downstream
from_mockcall sites:crates/alknet-call/src/protocol/dispatch.rs:1200-1201, 1239-1240, 1275-1276, 1306-1307, 1366-1367,crates/alknet-call/src/protocol/adapter.rs:1213-1214, 1253-1254— mechanical renames in commit 3.