flybrain/services/flysim/crates/flybus/tests/sol_rereview_regressions.rs
acamilo 7708bf12cd fix(flybus): stop connection teardown being starved by the frame it waits for
The write gate was one mutex the writer held across every synchronous transport
poll, and close_conn took that same mutex before reclaiming a connection's
owners. Under a transport that accepts a byte per poll the writer re-acquired it
hundreds of times while teardown queued behind it, so teardown could be starved
for a whole frame and the delivery completed just before its owner was
reclaimed. bus-v1 section 9 puts release and route-health control out of reach of
telemetry starvation; tests/sol_rereview_regressions.rs's poll-gate regression
failed 6 release runs out of 6 alone, and about one debug run in five.

The gate now separates "teardown has begun" from "a poll is in progress": a
mutex plus a condvar. begin_close sets closing once without waiting, then waits
for the poll already in progress and records whether it left a frame half
written. The writer brackets each poll with enter_poll (refused once closing)
and leave_poll(bytes) instead of holding a lock across it, so teardown's window
is one poll rather than a frame, and no byte follows it. close_conn no longer
holds the gate across reclamation. Every item is pub(crate): no public
signature changed.

The regression test no longer synchronises with a sleep. The shutdown thread
announces itself on a channel, and what follows is structural: teardown cannot
pass the gate until the held poll returns, which only release() allows. Its
delivery is 50 KB now, which a writer resuming a byte per poll cannot finish
inside the window, so the test observes the ordering instead of a coin toss and
asserts the two legal shapes of the stream: cut short with nothing appended, or
never begun with exactly the closing notices following.
2026-09-22 12:01:24 +00:00

904 lines
29 KiB
Rust

mod common;
use std::io;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::task::{Context, Poll, Waker};
use std::time::Duration;
use common::{Raw, Via, env, env_with, obj, sealed, within};
use flybus::wire::parse_serial_id;
use flybus::{
Client, ClientConfig, Dispatch, ErrorCode, Limits, Policy, Retained, Router, RouterConfig,
ServiceConfig, SubscriptionConfig, Transport,
};
use serde_json::json;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::sync::Notify;
struct WriteProbe<S> {
inner: S,
state: Arc<Mutex<ProbeState>>,
first: Arc<Notify>,
}
#[derive(Default)]
struct ProbeState {
armed: bool,
released: bool,
first_written: bool,
writer: Option<Waker>,
bytes: Vec<u8>,
ops: Vec<String>,
}
impl<S> WriteProbe<S> {
fn new(inner: S) -> (WriteProbe<S>, WriteProbeHandle) {
let state = Arc::new(Mutex::new(ProbeState::default()));
let first = Arc::new(Notify::new());
(
WriteProbe {
inner,
state: state.clone(),
first: first.clone(),
},
WriteProbeHandle { state, first },
)
}
}
#[derive(Clone)]
struct WriteProbeHandle {
state: Arc<Mutex<ProbeState>>,
first: Arc<Notify>,
}
impl WriteProbeHandle {
fn arm(&self) {
let mut state = self.state.lock().unwrap();
state.armed = true;
state.released = false;
state.first_written = false;
state.writer = None;
state.bytes.clear();
state.ops.clear();
}
fn release(&self) {
let mut state = self.state.lock().unwrap();
state.released = true;
if let Some(waker) = state.writer.take() {
waker.wake();
}
}
async fn wait_first(&self) {
loop {
let notified = self.first.notified();
if self.state.lock().unwrap().first_written {
return;
}
tokio::time::timeout(Duration::from_secs(2), notified)
.await
.expect("router never wrote the first byte");
}
}
fn ops(&self) -> Vec<String> {
self.state.lock().unwrap().ops.clone()
}
}
impl<S: AsyncRead + Unpin> AsyncRead for WriteProbe<S> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl<S: AsyncWrite + Unpin> AsyncWrite for WriteProbe<S> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
{
let mut state = self.state.lock().unwrap();
if state.armed && !state.released && state.first_written {
state.writer = Some(cx.waker().clone());
return Poll::Pending;
}
}
let limit = if self.state.lock().unwrap().armed {
1
} else {
buf.len()
};
match Pin::new(&mut self.inner).poll_write(cx, &buf[..limit]) {
Poll::Ready(Ok(n)) => {
let mut state = self.state.lock().unwrap();
state.bytes.extend_from_slice(&buf[..n]);
while state.bytes.len() >= 4 {
let len = u32::from_le_bytes(state.bytes[..4].try_into().unwrap()) as usize;
if state.bytes.len() < len + 4 {
break;
}
let frame = state.bytes.drain(..len + 4).collect::<Vec<_>>();
let envelope = flybus::wire::Envelope::decode(&frame[4..]).unwrap();
state.ops.push(envelope.op);
}
if state.armed && !state.first_written && n > 0 {
state.first_written = true;
self.first.notify_waiters();
}
Poll::Ready(Ok(n))
}
other => other,
}
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
struct PollHold<S> {
inner: S,
state: Arc<(Mutex<PollHoldState>, Condvar)>,
entered: Arc<Notify>,
}
#[derive(Default)]
struct PollHoldState {
armed: bool,
entered: bool,
released: bool,
}
#[derive(Clone)]
struct PollHoldHandle {
state: Arc<(Mutex<PollHoldState>, Condvar)>,
entered: Arc<Notify>,
}
impl<S> PollHold<S> {
fn new(inner: S) -> (PollHold<S>, PollHoldHandle) {
let state = Arc::new((Mutex::new(PollHoldState::default()), Condvar::new()));
let entered = Arc::new(Notify::new());
(
PollHold {
inner,
state: state.clone(),
entered: entered.clone(),
},
PollHoldHandle { state, entered },
)
}
}
impl PollHoldHandle {
fn arm(&self) {
self.state.0.lock().unwrap().armed = true;
}
async fn wait_entered(&self) {
loop {
let notified = self.entered.notified();
if self.state.0.lock().unwrap().entered {
return;
}
tokio::time::timeout(Duration::from_secs(2), notified)
.await
.expect("transport poll_write was never entered");
}
}
fn release(&self) {
let mut state = self.state.0.lock().unwrap();
state.released = true;
self.state.1.notify_all();
}
}
impl<S: AsyncRead + Unpin> AsyncRead for PollHold<S> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl<S: AsyncWrite + Unpin> AsyncWrite for PollHold<S> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let should_hold = {
let mut state = self.state.0.lock().unwrap();
if state.armed && !state.entered {
state.entered = true;
self.entered.notify_waiters();
true
} else {
false
}
};
if should_hold {
let mut state = self.state.0.lock().unwrap();
while !state.released {
state = self.state.1.wait(state).unwrap();
}
cx.waker().wake_by_ref();
return Poll::Pending;
}
Pin::new(&mut self.inner).poll_write(cx, &buf[..1])
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
async fn wait_for_single_delivery_owner(router: &Router) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
let stats = router.stats();
if stats.owners == 1 && stats.artifact_roots == 1 {
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"source hold was not released before teardown: {stats:?}"
);
tokio::task::yield_now().await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_last_attached_responder_settles_the_call() {
let e = env(Via::Memory).await;
let server = e.client("server").await;
let caller = e.client("caller").await;
let mut service = server
.register("example.abandoned", ServiceConfig::default())
.await
.unwrap();
let mut pending = caller
.call("example.abandoned", None, "Work", obj(json!({})), &[])
.await
.unwrap();
let request = within("request", service.next()).await.unwrap();
drop(request);
tokio::time::sleep(Duration::from_millis(100)).await;
let stats = e.stats();
assert_eq!(stats.reply_capabilities, 0);
assert_eq!(stats.owners, 0);
assert_eq!(stats.calls, 0, "the last reply capability was released");
assert_eq!(stats.active_calls, 0, "the caller slot was not retired");
let error = tokio::time::timeout(Duration::from_millis(250), pending.result())
.await
.expect("the caller was left waiting after all reply authority was gone")
.unwrap_err();
assert_eq!(
(error.code, error.dispatch),
(ErrorCode::CallGone, Dispatch::Dispatched)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_last_attached_responder_releases_request_attachments() {
let e = env(Via::Memory).await;
let server = e.client("server").await;
let caller = e.client("caller").await;
let mut service = server
.register("example.abandoned-artifact", ServiceConfig::default())
.await
.unwrap();
let artifact = sealed(&caller, b"request", "application/octet-stream").await;
let mut pending = caller
.call(
"example.abandoned-artifact",
None,
"Work",
obj(json!({})),
&[("data", &artifact)],
)
.await
.unwrap();
drop(artifact);
let request = within("artifact request", service.next()).await.unwrap();
drop(request);
let error = within("abandoned artifact call", pending.result())
.await
.unwrap_err();
assert_eq!(
(error.code, error.dispatch),
(ErrorCode::CallGone, Dispatch::Dispatched)
);
e.settle("abandoned artifact released", |stats| {
stats.calls == 0
&& stats.active_calls == 0
&& stats.reply_capabilities == 0
&& stats.owners == 0
&& stats.artifacts == 0
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_last_responder_clone_settles_the_call() {
let e = env(Via::Memory).await;
let server = e.client("server").await;
let caller = e.client("caller").await;
let mut service = server
.register("example.abandoned-clone", ServiceConfig::default())
.await
.unwrap();
let mut pending = caller
.call("example.abandoned-clone", None, "Work", obj(json!({})), &[])
.await
.unwrap();
let request = within("clone request", service.next()).await.unwrap();
let responder = request.responder();
drop(request);
e.settle("clone retains reply capability", |stats| {
stats.calls == 1
&& stats.active_calls == 1
&& stats.reply_capabilities == 1
&& stats.owners == 0
})
.await;
drop(responder);
let error = within("clone release failure", pending.result())
.await
.unwrap_err();
assert_eq!(
(error.code, error.dispatch),
(ErrorCode::CallGone, Dispatch::Dispatched)
);
e.settle("clone release retired call", |stats| {
stats.calls == 0 && stats.active_calls == 0 && stats.reply_capabilities == 0
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_last_responder_clone_with_attachment_settles_the_call() {
let e = env(Via::Memory).await;
let server = e.client("server").await;
let caller = e.client("caller").await;
let mut service = server
.register("example.abandoned-clone-artifact", ServiceConfig::default())
.await
.unwrap();
let artifact = sealed(&caller, b"request", "application/octet-stream").await;
let mut pending = caller
.call(
"example.abandoned-clone-artifact",
None,
"Work",
obj(json!({})),
&[("data", &artifact)],
)
.await
.unwrap();
drop(artifact);
let request = within("clone artifact request", service.next())
.await
.unwrap();
let responder = request.responder();
drop(request);
e.settle("clone keeps only reply capability", |stats| {
stats.calls == 1
&& stats.active_calls == 1
&& stats.reply_capabilities == 1
&& stats.owners == 0
&& stats.artifacts == 0
})
.await;
drop(responder);
let error = within("clone artifact release failure", pending.result())
.await
.unwrap_err();
assert_eq!(
(error.code, error.dispatch),
(ErrorCode::CallGone, Dispatch::Dispatched)
);
e.settle("clone artifact release retired call", |stats| {
stats.calls == 0 && stats.active_calls == 0 && stats.reply_capabilities == 0
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fair_topic_insertion_preserves_router_envelope_order() {
let limits = Limits {
max_control_frames: 4096,
max_control_bytes: 16 << 20,
..Limits::default()
};
let e = env_with(Via::Memory, limits, Policy::open()).await;
let publisher = e.client("publisher").await;
publisher
.declare_topic("t.order", Retained::None)
.await
.unwrap();
let mut raw = e.raw_hello("reader").await;
raw.call(
"subscribe",
json!({"topic": "t.order", "mode": "latest", "maxQueued": 1, "maxInFlight": 1, "replayLatest": false}),
)
.await
.unwrap();
let mut writer = raw.take_writer();
let flood = tokio::spawn(async move {
for n in 3..3000u64 {
let envelope = json!({
"protocol": "flybus", "major": 1, "minor": 0,
"id": format!("msg-{n}"), "replyTo": null, "kind": "command",
"op": "no.such.op", "body": {}, "attachments": []
});
if !writer.send(&serde_json::to_vec(&envelope).unwrap()).await {
break;
}
}
});
tokio::time::sleep(Duration::from_millis(200)).await;
publisher
.publish("t.order", obj(json!({"ready": true})), &[])
.await
.unwrap();
let mut last = 0;
let mut saw_delivery = false;
for _ in 0..2500 {
let envelope = within("router frame", raw.recv()).await.unwrap();
let serial = parse_serial_id("bus", &envelope.id).unwrap();
assert!(
serial > last,
"router emitted {} after bus-{last}",
envelope.id
);
last = serial;
saw_delivery |= envelope.op == "topic.message";
if saw_delivery && envelope.kind == flybus::wire::Kind::Reply {
break;
}
}
assert!(
saw_delivery,
"fair scheduling never inserted the topic message"
);
flood.abort();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sdk_accepts_fair_topic_insertion_through_saturated_control_backlog() {
let dir = tempfile::tempdir().unwrap();
let mut config = RouterConfig::new(dir.path());
config.policy = Policy::open();
config.limits.max_control_frames = 4096;
config.limits.max_control_bytes = 16 << 20;
let router = Router::new(config).unwrap();
let publisher = Client::connect(
router.connect_in_memory_as("publisher"),
ClientConfig::new("publisher", dir.path()),
)
.await
.unwrap();
publisher
.declare_topic("t.sdk-order", Retained::None)
.await
.unwrap();
let (client_stream, router_stream) = tokio::io::duplex(64 * 1024);
let (probed, probe) = WriteProbe::new(router_stream);
router.serve_as(probed, "reader");
let reader = Client::connect(
Transport::from_stream(client_stream),
ClientConfig::new("reader", dir.path()),
)
.await
.unwrap();
let mut subscription = reader
.subscribe("t.sdk-order", SubscriptionConfig::latest().in_flight(1))
.await
.unwrap();
probe.arm();
let mut backlog = Vec::new();
let completed = Arc::new(AtomicUsize::new(0));
for n in 0..256 {
let client = reader.clone();
let completed = completed.clone();
backlog.push(tokio::spawn(async move {
let result = client
.declare_topic(&format!("t.backlog-{n}"), Retained::None)
.await;
completed.fetch_add(1, Ordering::SeqCst);
result
}));
}
probe.wait_first().await;
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while router.stats().topics < 257 {
assert!(
tokio::time::Instant::now() < deadline,
"control backlog was not admitted"
);
tokio::task::yield_now().await;
}
assert_eq!(
completed.load(Ordering::SeqCst),
0,
"writer was not blocked"
);
publisher
.publish("t.sdk-order", obj(json!({"ready": true})), &[])
.await
.unwrap();
assert_eq!(router.stats().queued, 1, "topic delivery was not queued");
probe.release();
let message = within("SDK fair delivery", subscription.next())
.await
.expect("subscription closed on router envelope ordering");
assert_eq!(message.payload()["ready"], true);
assert!(
reader.closed().is_none(),
"strict SDK rejected router output"
);
for task in backlog {
task.await.unwrap().unwrap();
}
let ops = probe.ops();
let topic = ops
.iter()
.position(|op| op == "topic.message")
.expect("instrumented stream did not carry the topic delivery");
assert!(
ops[topic + 1..].iter().any(|op| op == "topic.declare"),
"topic delivery was not fairly inserted ahead of remaining controls: {ops:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shutdown_does_not_deliver_a_frame_after_releasing_its_owner() {
let dir = tempfile::tempdir().unwrap();
let mut config = RouterConfig::new(dir.path());
config.policy = Policy::open();
let router = Router::new(config).unwrap();
let publisher = Client::connect(
router.connect_in_memory_as("publisher"),
ClientConfig::new("publisher", dir.path()),
)
.await
.unwrap();
publisher
.declare_topic("t.shutdown", Retained::None)
.await
.unwrap();
let (client_stream, router_stream) = tokio::io::duplex(64 * 1024);
let (probed, probe) = WriteProbe::new(router_stream);
router.serve_as(probed, "reader");
let mut raw = Raw::over(Transport::from_stream(client_stream), dir.path());
raw.hello("reader").await.unwrap();
raw.call(
"subscribe",
json!({"topic": "t.shutdown", "mode": "latest", "maxQueued": 1, "maxInFlight": 1, "replayLatest": false}),
)
.await
.unwrap();
probe.arm();
let artifact = sealed(&publisher, b"still-owned", "application/octet-stream").await;
publisher
.publish("t.shutdown", obj(json!({})), &[("data", &artifact)])
.await
.unwrap();
drop(artifact);
probe.wait_first().await;
wait_for_single_delivery_owner(&router).await;
let before = router.stats();
assert_eq!((before.owners, before.artifact_roots), (1, 1));
router.shutdown();
assert_eq!(
router.stats().owners,
0,
"shutdown released the selected owner"
);
assert_eq!(
router.stats().artifacts,
0,
"shutdown collected its attachment"
);
assert!(
within("truncated topic frame", raw.recv()).await.is_none(),
"a complete frame was appended after a partial topic delivery"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn protocol_close_cancels_partial_topic_frame_before_reclaiming_attachment() {
let dir = tempfile::tempdir().unwrap();
let mut config = RouterConfig::new(dir.path());
config.policy = Policy::open();
let router = Router::new(config).unwrap();
let publisher = Client::connect(
router.connect_in_memory_as("publisher"),
ClientConfig::new("publisher", dir.path()),
)
.await
.unwrap();
publisher
.declare_topic("t.close", Retained::None)
.await
.unwrap();
let (client_stream, router_stream) = tokio::io::duplex(64 * 1024);
let (probed, probe) = WriteProbe::new(router_stream);
router.serve_as(probed, "reader");
let mut raw = Raw::over(Transport::from_stream(client_stream), dir.path());
raw.hello("reader").await.unwrap();
raw.call(
"subscribe",
json!({"topic": "t.close", "mode": "latest", "maxQueued": 1, "maxInFlight": 1, "replayLatest": false}),
)
.await
.unwrap();
probe.arm();
let artifact = sealed(&publisher, b"close", "application/octet-stream").await;
publisher
.publish("t.close", obj(json!({})), &[("data", &artifact)])
.await
.unwrap();
drop(artifact);
probe.wait_first().await;
wait_for_single_delivery_owner(&router).await;
raw.next = 0;
raw.command("topic.declare", json!({}), json!([])).await;
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while router.stats().connections != 1 {
assert!(
tokio::time::Instant::now() < deadline,
"protocol close did not finish"
);
tokio::task::yield_now().await;
}
assert_eq!(router.stats().owners, 0);
assert_eq!(router.stats().artifacts, 0);
assert!(
within("protocol-close truncated frame", raw.recv())
.await
.is_none(),
"a final notice was appended after a partial frame"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn teardown_waits_for_an_active_transport_poll_before_reclaiming() {
let dir = tempfile::tempdir().unwrap();
let mut config = RouterConfig::new(dir.path());
config.policy = Policy::open();
let router = Router::new(config).unwrap();
let publisher = Client::connect(
router.connect_in_memory_as("publisher"),
ClientConfig::new("publisher", dir.path()),
)
.await
.unwrap();
publisher
.declare_topic("t.poll-gate", Retained::None)
.await
.unwrap();
let (client_stream, router_stream) = tokio::io::duplex(64 * 1024);
let (held, hold) = PollHold::new(router_stream);
router.serve_as(held, "reader");
let mut raw = Raw::over(Transport::from_stream(client_stream), dir.path());
raw.hello("reader").await.unwrap();
raw.call(
"subscribe",
json!({"topic": "t.poll-gate", "mode": "latest", "maxQueued": 1, "maxInFlight": 1, "replayLatest": false}),
)
.await
.unwrap();
hold.arm();
let artifact = sealed(&publisher, b"poll", "application/octet-stream").await;
let sealed_path = router
.store_dir()
.join("sealed")
.join(&artifact.reference().artifact_id);
// A deliberately large delivery: the transport under the gate writes one byte per poll,
// so a writer that resumes cannot possibly finish this frame inside the window between
// releasing the held poll and teardown marking the stream closing. Without that, a short
// frame sometimes completes first, which is teardown's other legal arm and would make the
// assertions below a coin toss rather than a test of the ordering.
publisher
.publish(
"t.poll-gate",
obj(json!({"blob": "p".repeat(50_000)})),
&[("data", &artifact)],
)
.await
.unwrap();
drop(artifact);
hold.wait_entered().await;
wait_for_single_delivery_owner(&router).await;
let done = Arc::new(AtomicBool::new(false));
let shutdown_done = done.clone();
let shutdown_router = router.clone();
// The thread announces itself before calling shutdown, so the assertions below need no
// sleep: teardown cannot get past the write gate until the held poll returns, which only
// `hold.release()` allows.
let (started_tx, started_rx) = std::sync::mpsc::channel();
let shutdown = std::thread::spawn(move || {
started_tx.send(()).expect("the test is waiting");
shutdown_router.shutdown();
shutdown_done.store(true, Ordering::SeqCst);
});
started_rx.recv().expect("shutdown thread started");
for _ in 0..64 {
assert!(
!done.load(Ordering::SeqCst),
"teardown completed while poll_write was active"
);
assert!(
sealed_path.exists(),
"artifact was reclaimed while poll_write was active"
);
tokio::task::yield_now().await;
}
hold.release();
shutdown.join().unwrap();
assert!(done.load(Ordering::SeqCst));
assert_eq!(router.stats().owners, 0);
assert_eq!(router.stats().artifacts, 0);
assert!(!sealed_path.exists());
let mut ops = Vec::new();
while let Some(envelope) = within("poll-gate close", raw.recv()).await {
assert_ne!(
envelope.op, "topic.message",
"delivery completed after teardown reclaimed its owner"
);
ops.push(envelope.op);
}
// The delivery never completes, so only teardown's two shapes are legal: the frame was
// cut short and nothing whatever follows it, or it never began and the stream is still
// frame aligned, in which case the closing notices are all that follow.
assert!(
ops.is_empty()
|| ops == ["subscription.closed".to_owned(), "connection.closing".to_owned()],
"a cut stream carries nothing more and an aligned one exactly the closing notices: \
{ops:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shutdown_cancels_partial_rpc_request_before_reclaiming_attachment() {
let dir = tempfile::tempdir().unwrap();
let mut config = RouterConfig::new(dir.path());
config.policy = Policy::open();
let router = Router::new(config).unwrap();
let caller = Client::connect(
router.connect_in_memory_as("caller"),
ClientConfig::new("caller", dir.path()),
)
.await
.unwrap();
let (client_stream, router_stream) = tokio::io::duplex(64 * 1024);
let (probed, probe) = WriteProbe::new(router_stream);
router.serve_as(probed, "server");
let mut raw = Raw::over(Transport::from_stream(client_stream), dir.path());
raw.hello("server").await.unwrap();
raw.call(
"service.register",
json!({"name": "example.partial-request", "maxQueued": 1, "maxInFlight": 1}),
)
.await
.unwrap();
probe.arm();
let artifact = sealed(&caller, b"request", "application/octet-stream").await;
let _pending = caller
.call(
"example.partial-request",
None,
"Work",
obj(json!({})),
&[("data", &artifact)],
)
.await
.unwrap();
drop(artifact);
probe.wait_first().await;
wait_for_single_delivery_owner(&router).await;
let before = router.stats();
assert_eq!((before.owners, before.artifact_roots), (1, 1));
router.shutdown();
assert_eq!(router.stats().owners, 0);
assert_eq!(router.stats().artifacts, 0);
assert!(
within("truncated request frame", raw.recv())
.await
.is_none(),
"a complete frame was appended after a partial RPC request"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shutdown_cancels_partial_rpc_result_before_reclaiming_attachment() {
let dir = tempfile::tempdir().unwrap();
let mut config = RouterConfig::new(dir.path());
config.policy = Policy::open();
let router = Router::new(config).unwrap();
let server = Client::connect(
router.connect_in_memory_as("server"),
ClientConfig::new("server", dir.path()),
)
.await
.unwrap();
let mut service = server
.register("example.partial-result", ServiceConfig::default())
.await
.unwrap();
let (client_stream, router_stream) = tokio::io::duplex(64 * 1024);
let (probed, probe) = WriteProbe::new(router_stream);
router.serve_as(probed, "caller");
let mut raw = Raw::over(Transport::from_stream(client_stream), dir.path());
raw.hello("caller").await.unwrap();
raw.call(
"rpc.call",
json!({"callId": "call-1", "target": "example.partial-result", "expectedIncarnation": null, "method": "Work", "payload": {}}),
)
.await
.unwrap();
probe.arm();
let request = within("result request", service.next()).await.unwrap();
let artifact = sealed(&server, b"result", "application/octet-stream").await;
request
.reply(obj(json!({})), &[("data", &artifact)])
.await
.unwrap();
drop((artifact, request));
probe.wait_first().await;
wait_for_single_delivery_owner(&router).await;
let before = router.stats();
assert_eq!((before.owners, before.artifact_roots), (1, 1));
router.shutdown();
assert_eq!(router.stats().owners, 0);
assert_eq!(router.stats().artifacts, 0);
assert!(
within("truncated result frame", raw.recv()).await.is_none(),
"a complete frame was appended after a partial RPC result"
);
}