consumer: AssociateSession — the framed datagram stage + e2e suite
The UDP ASSOCIATE consumer session (POC #2's shape, consumer.md §The associate session, ADR-003): - `AssociateSession::associate` — open → no-auth → ASSOCIATE → reply; the sentinel BND.ADDR is received (`relay_addr()`, RFC fidelity) and never used for addressing (the handle is the channel). The ASSOCIATE DST.ADDR is the vanilla-mirror unspecified address. - `send_datagram` — frames exactly once via the crate's wire `frame()` (the framing-once mirror; the u16 joint bound enforced at frame time). Takes `impl ToTargetAddr` (ADR-012 posture); a `ToTargetAddr for &TargetAddr` impl was added to the forked codec so a `TargetAddr` value survives across sends. - `recv_datagram` — the incremental `DatagramReader` + batched- datagram pending queue, state persisting across calls (the POC's reader-state finding); `Ok(None)` on channel EOF; fail-loud on a stream end mid-frame and on undecodable frames (`WireError` → the forked `AddrError`/`UdpHeaderError` RFC classes — the consumer mirror of the producer stage's `UndecodableFrame` exit). - `shutdown` — the half-close; the mux's implicit-EOF sentinel reaches the producer's stage as EOF (association lifetime = channel lifetime). `close()`/`Drop` own the teardown (sync reap + structural EOF, the CONNECT/BIND posture); teardown-at-birth on in-stream refusals; no `Clone` (compile-fail asserted). - Typed error surface: open failures are `SocksSessionError::Open` (ADR-049); the missing-egress 0x07 and factory-failure codes surface from `associate` as `Rfc`. E2E suite (`tests/consumer-associate.rs`, 8 tests over the harness): round-trips (multiple in-order datagrams, one association, sentinel relay address asserted), the split-datagram persistence test (a 60 KiB datagram reassembling across the call boundary + the pending- queue drain — both halves of the persistence invariant), the empty- payload round-trip (F-2, never EOF), shutdown → a fresh associate succeeds immediately (the POC's test 4), Drop teardown + connection health, the missing-egress refusal (typed 0x07, no phantom channel), the egress-factory failure mapping, and the CONNECT+ASSOCIATE regression guard (interleaved on one registration). Unit tests (4) pin the reader internals over a duplex (`skip_auth`'s handshake-free path). Verification: cargo test 305 (7 suites, all green), clippy --all-targets -D warnings, fmt, cargo doc --no-deps, and both wasm32-unknown-unknown checks clean.
This commit is contained in:
1 parent
776844e2fb
commit
c57451545d
5 files changed
+1095
-18
No files matched your search
@@ -0,0 +1,479 @@
|
||||
//! The consumer UDP ASSOCIATE integration tests — `AssociateSession`
|
||||
//! end to end through the harness's consumer-side topology (the
|
||||
//! producer's real command-dispatch path over channels; the framed
|
||||
//! datagram stage, ADR-003, driven by the wrapper-aware client, POC
|
||||
//! #2's shape).
|
||||
//!
|
||||
//! Covered: associate → sentinel reply (received, never used for
|
||||
//! addressing) → send/recv round-trips (multiple in-order datagrams,
|
||||
//! one association); the split-datagram persistence test (reader
|
||||
//! state persists across `recv_datagram` calls — the POC's
|
||||
//! 2-byte-prefix chunk finding); empty-payload datagram round-trip
|
||||
//! (F-2, never EOF); shutdown → the producer's stage ends and a
|
||||
//! fresh associate succeeds immediately; Drop teardown (drop
|
||||
//! mid-stream → the producer's stage sees EOF, the connection stays
|
||||
//! healthy); the ASSOCIATE refusal surfaced as a typed error with no
|
||||
//! phantom channel; CONNECT + ASSOCIATE on the same registration
|
||||
//! (the regression guard extended).
|
||||
|
||||
mod harness;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use alksocks::consumer::{AssociateSession, Socks5ChannelSession};
|
||||
use alksocks::egress::EgressError;
|
||||
use alksocks::error::SocksSessionError;
|
||||
use alksocks::rfc1928::consts;
|
||||
use alksocks::rfc1928::util::target_addr::{TargetAddr, ToTargetAddr};
|
||||
use alksocks::SocksError as SocksErrorKind;
|
||||
|
||||
use harness::{echo_dial, echo_egress, refusing_egress, wire_consumer_side_with};
|
||||
|
||||
/// The no-leaked-channels probe (the connect/bind suites' shape,
|
||||
/// re-pinned for the ASSOCIATE session): both managers hold only
|
||||
/// channel 0 — the producer's entry reaps by its open-op wrapper;
|
||||
/// the probe polls past the reap race.
|
||||
async fn no_leaked_channels(topo: &harness::ConsumerSideTopology) {
|
||||
let deadline = std::time::Duration::from_secs(5);
|
||||
let consumer_ids = topo.consumer.manager().channel_ids();
|
||||
assert!(
|
||||
consumer_ids.iter().all(|&id| id == 0),
|
||||
"consumer leaked channel entries: {consumer_ids:?}"
|
||||
);
|
||||
tokio::time::timeout(deadline, async {
|
||||
loop {
|
||||
let producer_ids = topo.producer_manager.channel_ids();
|
||||
if producer_ids.iter().all(|&id| id == 0) {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the producer's handler-exit teardown reaps the entry");
|
||||
}
|
||||
|
||||
/// The happy path: `associate` → the reply's sentinel `BND.ADDR` is
|
||||
/// received (RFC fidelity) and never used for addressing → send/recv
|
||||
/// round-trips through the echo egress — multiple datagrams, in
|
||||
/// order, on one association; the establisher witnessed the opener
|
||||
/// identity (CF-006).
|
||||
#[tokio::test]
|
||||
async fn associate_round_trips_multiple_datagrams_one_association() {
|
||||
let topo = wire_consumer_side_with(
|
||||
echo_dial(),
|
||||
Some(echo_egress()),
|
||||
None,
|
||||
Some(harness::consumer_identity()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
||||
.await
|
||||
.expect("the association associates");
|
||||
|
||||
// The reply's BND.ADDR is the producer's sentinel (127.0.0.1:0 —
|
||||
// ADR-003): received for RFC fidelity, never used for addressing.
|
||||
let relay = assoc
|
||||
.relay_addr()
|
||||
.expect("the reply carried BND.ADDR")
|
||||
.into_string_and_port();
|
||||
assert_eq!(relay.0, "127.0.0.1", "the in-band sentinel BND.ADDR");
|
||||
assert_eq!(relay.1, 0);
|
||||
|
||||
let target = TargetAddr::Ip("10.0.0.1:53".parse().unwrap());
|
||||
for i in 0..5u8 {
|
||||
let payload = vec![i; 10];
|
||||
assoc
|
||||
.send_datagram(&target, &payload)
|
||||
.await
|
||||
.expect("send datagram");
|
||||
}
|
||||
for i in 0..5u8 {
|
||||
let (src, payload) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv within timeout")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(src, target, "the echo reports the destination");
|
||||
assert_eq!(payload, bytes::Bytes::from(vec![i; 10]), "in-order echo");
|
||||
}
|
||||
|
||||
// The association is alive: another datagram round-trips.
|
||||
assoc.send_datagram(&target, b"alive").await.expect("send");
|
||||
let (_, payload) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(&payload[..], b"alive");
|
||||
|
||||
assert!(
|
||||
topo.consumer.manager().has_channel(assoc.channel_id()),
|
||||
"the adopted channel entry exists while associated"
|
||||
);
|
||||
|
||||
let reaped = assoc.shutdown().await.expect("half-close");
|
||||
assert!(reaped, "the adopted channel entry existed");
|
||||
no_leaked_channels(&topo).await;
|
||||
}
|
||||
|
||||
/// The split-datagram persistence test (POC #2's finding — the
|
||||
/// reader state must persist across `recv_datagram` calls): a large
|
||||
/// datagram's frame (under the u16 joint bound but well over the 16
|
||||
/// KB read buffer) spans multiple channel reads AND multiple
|
||||
/// `recv_datagram` calls — the first call returns the earlier small
|
||||
/// datagram while the large one is mid-flight, the next call
|
||||
/// reassembles it across its internal reads — and a trailing small
|
||||
/// datagram batched in the same read batch drains through the
|
||||
/// pending queue.
|
||||
#[tokio::test]
|
||||
async fn recv_datagram_state_persists_across_calls() {
|
||||
let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
||||
|
||||
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
||||
.await
|
||||
.expect("the association associates");
|
||||
let target = TargetAddr::Ip("10.0.0.2:53".parse().unwrap());
|
||||
|
||||
// The order tells the story: "first" completes early; the 60 KiB
|
||||
// datagram (60007 inner bytes — legal, but over the 16 KB read
|
||||
// buffer) is mid-flight when the first call returns; "third"
|
||||
// batches after it.
|
||||
assoc
|
||||
.send_datagram(&target, b"first")
|
||||
.await
|
||||
.expect("send the first datagram");
|
||||
let big = vec![7u8; 60 * 1024];
|
||||
assoc
|
||||
.send_datagram(&target, &big)
|
||||
.await
|
||||
.expect("send the big datagram");
|
||||
assoc
|
||||
.send_datagram(&target, b"third")
|
||||
.await
|
||||
.expect("send the third datagram");
|
||||
|
||||
let (src, first) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(src, target);
|
||||
assert_eq!(&first[..], b"first", "the first datagram, in order");
|
||||
|
||||
// The large datagram reassembles across the call boundary (its
|
||||
// mid-frame state persisted into this call) — the POC finding's
|
||||
// shape.
|
||||
let (src, second) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(src, target);
|
||||
assert_eq!(&second[..], &big[..], "the split datagram reassembled");
|
||||
|
||||
// The trailing datagram rode the same read batches: the pending
|
||||
// queue carries it to this call.
|
||||
let (_, third) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(&third[..], b"third", "the batched datagram drained");
|
||||
|
||||
let reaped = assoc.shutdown().await.expect("half-close");
|
||||
assert!(reaped);
|
||||
no_leaked_channels(&topo).await;
|
||||
}
|
||||
|
||||
/// An empty-payload datagram is a legal datagram — never EOF (the
|
||||
/// F-2 invariant, end to end through the echo egress), and the
|
||||
/// association survives it (pre → empty → post, in order).
|
||||
#[tokio::test]
|
||||
async fn empty_payload_datagram_round_trips_and_survives() {
|
||||
let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
||||
|
||||
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
||||
.await
|
||||
.expect("the association associates");
|
||||
let target = TargetAddr::Ip("10.0.0.3:53".parse().unwrap());
|
||||
|
||||
assoc
|
||||
.send_datagram(&target, b"pre")
|
||||
.await
|
||||
.expect("send pre");
|
||||
assoc
|
||||
.send_datagram(&target, b"")
|
||||
.await
|
||||
.expect("an empty payload is a legal datagram");
|
||||
assoc
|
||||
.send_datagram(&target, b"post")
|
||||
.await
|
||||
.expect("send post");
|
||||
|
||||
let (_, pre) = tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(&pre[..], b"pre");
|
||||
|
||||
let (src, empty) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(src, target);
|
||||
assert!(
|
||||
empty.is_empty(),
|
||||
"the empty-payload datagram round-trips (never EOF)"
|
||||
);
|
||||
|
||||
let (_, post) = tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(&post[..], b"post");
|
||||
|
||||
let reaped = assoc.shutdown().await.expect("half-close");
|
||||
assert!(reaped);
|
||||
no_leaked_channels(&topo).await;
|
||||
}
|
||||
|
||||
/// Shutdown half-closes the stream: the mux's implicit-EOF sentinel
|
||||
/// reaches the producer's stage as EOF and the association ends
|
||||
/// (association lifetime = channel lifetime) — and a fresh associate
|
||||
/// succeeds immediately after (the POC's test 4, from the consumer
|
||||
/// side; the same registration is unpoisoned).
|
||||
#[tokio::test]
|
||||
async fn shutdown_ends_the_association_and_a_fresh_associate_succeeds() {
|
||||
let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
||||
|
||||
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
||||
.await
|
||||
.expect("the association associates");
|
||||
let channel_id = assoc.channel_id();
|
||||
let target = TargetAddr::Ip("10.0.0.4:53".parse().unwrap());
|
||||
assoc
|
||||
.send_datagram(&target, b"before")
|
||||
.await
|
||||
.expect("send before shutdown");
|
||||
let (_, echoed) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(&echoed[..], b"before");
|
||||
|
||||
// Half-close: the producer's stage exits on the EOF sentinel.
|
||||
assoc.shutdown().await.expect("shutdown");
|
||||
assert!(
|
||||
!topo.consumer.manager().has_channel(channel_id),
|
||||
"the adopted channel entry was reaped"
|
||||
);
|
||||
|
||||
// The connection is healthy: a fresh associate round-trips.
|
||||
let mut fresh = AssociateSession::associate(topo.consumer.as_ref())
|
||||
.await
|
||||
.expect("fresh associate after the shutdown");
|
||||
fresh
|
||||
.send_datagram(&target, b"fresh")
|
||||
.await
|
||||
.expect("send after fresh associate");
|
||||
let (_, echoed) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), fresh.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(&echoed[..], b"fresh");
|
||||
let reaped = fresh.shutdown().await.expect("shutdown");
|
||||
assert!(reaped);
|
||||
no_leaked_channels(&topo).await;
|
||||
}
|
||||
|
||||
/// Drop teardown: dropping a live association mid-stream reaps the
|
||||
/// adopted channel entry (no leak), the producer's stage sees the
|
||||
/// structural EOF (the mux's implicit-EOF sentinel on the dropped
|
||||
/// write half), and the connection stays healthy — a fresh associate
|
||||
/// succeeds immediately after.
|
||||
#[tokio::test]
|
||||
async fn drop_teardown_reaps_and_connection_stays_healthy() {
|
||||
let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
||||
|
||||
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
||||
.await
|
||||
.expect("the association associates");
|
||||
let channel_id = assoc.channel_id();
|
||||
assoc
|
||||
.send_datagram(TargetAddr::Ip("10.0.0.5:53".parse().unwrap()), b"mid")
|
||||
.await
|
||||
.expect("send before the drop");
|
||||
let (_, echoed) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(&echoed[..], b"mid");
|
||||
|
||||
// Drop mid-stream (the teardown-ownership posture).
|
||||
drop(assoc);
|
||||
assert!(
|
||||
!topo.consumer.manager().has_channel(channel_id),
|
||||
"the adopted channel entry was reaped on Drop"
|
||||
);
|
||||
no_leaked_channels(&topo).await;
|
||||
|
||||
// The connection is healthy: a fresh associate round-trips.
|
||||
let mut fresh = AssociateSession::associate(topo.consumer.as_ref())
|
||||
.await
|
||||
.expect("fresh associate after the drop");
|
||||
let target = TargetAddr::Ip("10.0.0.5:53".parse().unwrap());
|
||||
fresh
|
||||
.send_datagram(&target, b"again")
|
||||
.await
|
||||
.expect("send after the drop");
|
||||
let (_, echoed) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), fresh.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(&echoed[..], b"again");
|
||||
fresh.shutdown().await.expect("shutdown");
|
||||
no_leaked_channels(&topo).await;
|
||||
}
|
||||
|
||||
/// A missing egress seam: the ASSOCIATE refusal surfaces the RFC
|
||||
/// reply code through the session type (`CommandNotSupported` 0x07 —
|
||||
/// ADR-008's missing-callback class), and no phantom channel remains
|
||||
/// on either side (teardown-at-birth).
|
||||
#[tokio::test]
|
||||
async fn missing_egress_refusal_surfaces_rfc_reply_code_no_phantom_channel() {
|
||||
let topo = wire_consumer_side_with(echo_dial(), None, None, None).await;
|
||||
|
||||
let err = AssociateSession::associate(topo.consumer.as_ref())
|
||||
.await
|
||||
.expect_err("no egress wired — the ASSOCIATE must refuse");
|
||||
|
||||
match &err {
|
||||
SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => {
|
||||
assert_eq!(code.as_u8(), consts::SOCKS5_REPLY_COMMAND_NOT_SUPPORTED);
|
||||
}
|
||||
other => panic!("expected an Rfc reply-code refusal, got {other:?}"),
|
||||
}
|
||||
|
||||
no_leaked_channels(&topo).await;
|
||||
|
||||
// The connection is healthy: wiring the egress serves ASSOCIATE.
|
||||
let topo2 = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
||||
let mut assoc = AssociateSession::associate(topo2.consumer.as_ref())
|
||||
.await
|
||||
.expect("fresh associate after the refusal");
|
||||
assoc
|
||||
.send_datagram(TargetAddr::Ip("10.0.0.6:53".parse().unwrap()), b"fresh")
|
||||
.await
|
||||
.expect("send");
|
||||
let (_, echoed) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(&echoed[..], b"fresh");
|
||||
assoc.shutdown().await.expect("shutdown");
|
||||
no_leaked_channels(&topo2).await;
|
||||
}
|
||||
|
||||
/// An egress-factory failure: the mapped RFC reply code surfaces from
|
||||
/// `associate` (reply#1's code — the factory refused before the
|
||||
/// reply), and no phantom channel remains.
|
||||
#[tokio::test]
|
||||
async fn egress_factory_refusal_surfaces_the_mapped_reply_code() {
|
||||
let topo = wire_consumer_side_with(
|
||||
echo_dial(),
|
||||
Some(refusing_egress(EgressError::Unreachable(
|
||||
"no egress".into(),
|
||||
))),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let err = AssociateSession::associate(topo.consumer.as_ref())
|
||||
.await
|
||||
.expect_err("the egress factory refused");
|
||||
match &err {
|
||||
SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => {
|
||||
assert_eq!(code.as_u8(), consts::SOCKS5_REPLY_NETWORK_UNREACHABLE);
|
||||
}
|
||||
other => panic!("expected an Rfc reply-code refusal, got {other:?}"),
|
||||
}
|
||||
no_leaked_channels(&topo).await;
|
||||
}
|
||||
|
||||
/// CONNECT + ASSOCIATE on the same registration (the regression
|
||||
/// guard extended): one ALPN, both commands, interleaved — an
|
||||
/// association and a CONNECT session live concurrently on the same
|
||||
/// registration, both serving.
|
||||
#[tokio::test]
|
||||
async fn connect_and_associate_served_on_the_same_registration() {
|
||||
let topo = wire_consumer_side_with(echo_dial(), Some(echo_egress()), None, None).await;
|
||||
|
||||
let mut assoc = AssociateSession::associate(topo.consumer.as_ref())
|
||||
.await
|
||||
.expect("the association associates");
|
||||
let mut connect = Socks5ChannelSession::connect(
|
||||
topo.consumer.as_ref(),
|
||||
("echo.test", 443u16).to_target_addr().unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("the CONNECT session connects");
|
||||
|
||||
// The association round-trips while the CONNECT pipe is open.
|
||||
let target = TargetAddr::Ip("10.0.0.7:53".parse().unwrap());
|
||||
assoc
|
||||
.send_datagram(&target, b"udp")
|
||||
.await
|
||||
.expect("send datagram");
|
||||
let (_, echoed) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), assoc.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("a datagram (not eof)")
|
||||
.expect("a datagram");
|
||||
assert_eq!(&echoed[..], b"udp");
|
||||
|
||||
// The CONNECT pipe round-trips while the association is open.
|
||||
connect
|
||||
.split()
|
||||
.expect("pipe")
|
||||
.1
|
||||
.write_all(b"tcp")
|
||||
.await
|
||||
.expect("write");
|
||||
let (mut read, _) = connect.split().expect("pipe");
|
||||
let mut buf = [0u8; 3];
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
read.read_exact(&mut buf),
|
||||
)
|
||||
.await
|
||||
.expect("echo timed out")
|
||||
.expect("read the echo");
|
||||
assert_eq!(&buf, b"tcp");
|
||||
|
||||
assoc.shutdown().await.expect("shutdown");
|
||||
connect.close().await;
|
||||
no_leaked_channels(&topo).await;
|
||||
}
|
||||
Reference in new issue
Block a user