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.
This commit is contained in:
acamilo 2026-09-22 12:01:24 +00:00
parent af7a009395
commit 7708bf12cd
3 changed files with 135 additions and 87 deletions

View file

@ -364,75 +364,58 @@ async fn write_selected<W: AsyncWrite + Unpin>(
let mut frame = Vec::with_capacity(bytes.len() + 4); let mut frame = Vec::with_capacity(bytes.len() + 4);
frame.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); frame.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
frame.extend_from_slice(bytes); frame.extend_from_slice(bytes);
{ let closing = || SelectedWrite::Closing {
let mut gate = signals.write_gate.lock().unwrap_or_else(|e| e.into_inner()); partial: signals.write_gate.cut_partial(),
if !gate.begin_frame(frame.len()) { };
return SelectedWrite::Closing { let interrupted = || io::Error::new(io::ErrorKind::Interrupted, "connection closing");
partial: gate.cut_partial(), if !signals.write_gate.begin_frame(frame.len()) {
}; return closing();
}
} }
let mut written = 0; let mut written = 0;
while written < frame.len() { while written < frame.len() {
let polled = std::future::poll_fn(|cx| { let polled = std::future::poll_fn(|cx| {
let mut gate = signals.write_gate.lock().unwrap_or_else(|e| e.into_inner()); if !signals.write_gate.enter_poll() {
if gate.closing() { return std::task::Poll::Ready(Err(interrupted()));
return std::task::Poll::Ready(Err(io::Error::new(
io::ErrorKind::Interrupted,
"connection closing",
)));
} }
match std::pin::Pin::new(&mut *wr).poll_write(cx, &frame[written..]) { let polled = std::pin::Pin::new(&mut *wr).poll_write(cx, &frame[written..]);
let wrote = match polled {
std::task::Poll::Ready(Ok(n)) => n,
_ => 0,
};
signals.write_gate.leave_poll(wrote);
match polled {
std::task::Poll::Ready(Ok(0)) => std::task::Poll::Ready(Err(io::Error::new( std::task::Poll::Ready(Ok(0)) => std::task::Poll::Ready(Err(io::Error::new(
io::ErrorKind::WriteZero, io::ErrorKind::WriteZero,
"failed to write router frame", "failed to write router frame",
))), ))),
std::task::Poll::Ready(Ok(n)) => {
gate.wrote(n);
std::task::Poll::Ready(Ok(n))
}
other => other, other => other,
} }
}) })
.await; .await;
match polled { match polled {
Ok(n) => written += n, Ok(n) => written += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => { Err(e) if e.kind() == io::ErrorKind::Interrupted => return closing(),
let gate = signals.write_gate.lock().unwrap_or_else(|e| e.into_inner());
return SelectedWrite::Closing {
partial: gate.cut_partial(),
};
}
Err(_) => return SelectedWrite::Failed, Err(_) => return SelectedWrite::Failed,
} }
} }
let flushed = std::future::poll_fn(|cx| { let flushed = std::future::poll_fn(|cx| {
let gate = signals.write_gate.lock().unwrap_or_else(|e| e.into_inner()); if !signals.write_gate.enter_poll() {
if gate.closing() { return std::task::Poll::Ready(Err(interrupted()));
return std::task::Poll::Ready(Err(io::Error::new(
io::ErrorKind::Interrupted,
"connection closing",
)));
} }
std::pin::Pin::new(&mut *wr).poll_flush(cx) let polled = std::pin::Pin::new(&mut *wr).poll_flush(cx);
signals.write_gate.leave_poll(0);
polled
}) })
.await; .await;
if let Err(e) = flushed { if let Err(e) = flushed {
if e.kind() == io::ErrorKind::Interrupted { if e.kind() == io::ErrorKind::Interrupted {
let gate = signals.write_gate.lock().unwrap_or_else(|e| e.into_inner()); return closing();
return SelectedWrite::Closing {
partial: gate.cut_partial(),
};
} }
return SelectedWrite::Failed; return SelectedWrite::Failed;
} }
signals signals.write_gate.finish_frame();
.write_gate
.lock()
.unwrap_or_else(|e| e.into_inner())
.finish_frame();
SelectedWrite::Complete SelectedWrite::Complete
} }
@ -452,13 +435,9 @@ async fn write_loop<W: AsyncWrite + Unpin>(
tokio::pin!(write); tokio::pin!(write);
let selected = tokio::select! { let selected = tokio::select! {
result = &mut write => result, result = &mut write => result,
_ = stopped(&mut shutdown) => { _ = stopped(&mut shutdown) => SelectedWrite::Closing {
let gate = signals partial: signals.write_gate.cut_partial(),
.write_gate },
.lock()
.unwrap_or_else(|e| e.into_inner());
SelectedWrite::Closing { partial: gate.cut_partial() }
}
}; };
match selected { match selected {
SelectedWrite::Complete => {} SelectedWrite::Complete => {}

View file

@ -42,56 +42,98 @@ pub(crate) struct ConnSignals {
pub wake: Notify, pub wake: Notify,
/// Flips to true when the connection is closed; both tasks watch it. /// Flips to true when the connection is closed; both tasks watch it.
pub shutdown: watch::Sender<bool>, pub shutdown: watch::Sender<bool>,
/// Serializes synchronous transport polls with connection teardown. It is never held /// Orders connection teardown against the synchronous transport polls of the writer.
/// across an await. pub write_gate: WriteGate,
pub write_gate: std::sync::Mutex<WriteGate>,
/// The last frames to write before closing: a refusal or `connection.closing` notice, /// The last frames to write before closing: a refusal or `connection.closing` notice,
/// preceded on router shutdown by `subscription.closed` notices. /// preceded on router shutdown by `subscription.closed` notices.
pub final_frames: std::sync::Mutex<Vec<Vec<u8>>>, pub final_frames: std::sync::Mutex<Vec<Vec<u8>>>,
} }
/// Orders teardown against the writer's synchronous transport polls.
///
/// Teardown marks the stream closing *before* it waits for a poll already in progress, so at
/// most that one poll can still write and every later one is refused, whichever task reaches
/// the lock first. The earlier design held one mutex across each poll instead, which a writer
/// sending a frame a byte per poll re-acquired hundreds of times while teardown waited for it:
/// teardown could be starved for a whole frame and the frame completed just before its
/// delivery owner was reclaimed. The lock is held only for these bookkeeping steps, never
/// across an await.
#[derive(Default)] #[derive(Default)]
pub(crate) struct WriteGate { pub(crate) struct WriteGate {
state: std::sync::Mutex<GateState>,
/// Signalled when a poll leaves the transport.
idle: std::sync::Condvar,
}
#[derive(Default)]
struct GateState {
closing: bool, closing: bool,
/// A writer is inside a synchronous transport poll right now.
polling: bool,
frame_len: usize, frame_len: usize,
written: usize, written: usize,
cut_partial: bool, cut_partial: bool,
} }
impl WriteGate { impl WriteGate {
pub(crate) fn begin_frame(&mut self, len: usize) -> bool { fn lock(&self) -> std::sync::MutexGuard<'_, GateState> {
if self.closing { self.state.lock().unwrap_or_else(|e| e.into_inner())
}
/// Starts one frame. False once teardown has begun.
pub(crate) fn begin_frame(&self, len: usize) -> bool {
let mut g = self.lock();
if g.closing {
return false; return false;
} }
self.frame_len = len; g.frame_len = len;
self.written = 0; g.written = 0;
true true
} }
pub(crate) fn wrote(&mut self, len: usize) { /// Claims the transport for one synchronous poll. False once teardown has begun.
self.written += len; pub(crate) fn enter_poll(&self) -> bool {
debug_assert!(self.written <= self.frame_len); let mut g = self.lock();
if g.closing {
return false;
}
debug_assert!(!g.polling, "one writer task polls one connection");
g.polling = true;
true
} }
pub(crate) fn finish_frame(&mut self) { /// Releases the transport, accounting for what that poll wrote.
if !self.closing { pub(crate) fn leave_poll(&self, wrote: usize) {
debug_assert_eq!(self.written, self.frame_len); let mut g = self.lock();
self.frame_len = 0; g.polling = false;
self.written = 0; g.written += wrote;
debug_assert!(g.written <= g.frame_len);
drop(g);
self.idle.notify_all();
}
pub(crate) fn finish_frame(&self) {
let mut g = self.lock();
if !g.closing {
debug_assert_eq!(g.written, g.frame_len);
g.frame_len = 0;
g.written = 0;
} }
} }
fn begin_close(&mut self) { /// Refuses every later poll, then waits for one already in progress and records whether it
self.closing = true; /// left a frame half written. The caller may reclaim owners once this returns.
self.cut_partial = self.written > 0 && self.written < self.frame_len; fn begin_close(&self) {
} let mut g = self.lock();
g.closing = true;
pub(crate) fn closing(&self) -> bool { while g.polling {
self.closing g = self.idle.wait(g).unwrap_or_else(|e| e.into_inner());
}
g.cut_partial = g.written > 0 && g.written < g.frame_len;
} }
pub(crate) fn cut_partial(&self) -> bool { pub(crate) fn cut_partial(&self) -> bool {
self.cut_partial self.lock().cut_partial
} }
} }
@ -100,7 +142,7 @@ impl ConnSignals {
ConnSignals { ConnSignals {
wake: Notify::new(), wake: Notify::new(),
shutdown: watch::Sender::new(false), shutdown: watch::Sender::new(false),
write_gate: std::sync::Mutex::new(WriteGate::default()), write_gate: WriteGate::default(),
final_frames: std::sync::Mutex::new(Vec::new()), final_frames: std::sync::Mutex::new(Vec::new()),
} }
} }
@ -705,15 +747,14 @@ impl State {
.lock() .lock()
.unwrap_or_else(|e| e.into_inner()) .unwrap_or_else(|e| e.into_inner())
.extend(final_frames); .extend(final_frames);
// A transport poll that is already in progress finishes before this lock is acquired. // Teardown refuses every later transport poll first, then waits for a poll already in
// Once acquired, teardown marks the stream closing before reclaiming any owner, and no // progress, and only then reclaims what the connection owned. So a delivery frame is
// later normal-frame poll is allowed through. // either complete before its owner is reclaimed, or left truncated with nothing more
let mut write_gate = signals.write_gate.lock().unwrap_or_else(|e| e.into_inner()); // appended to the stream; the writer can never finish it afterwards.
write_gate.begin_close(); signals.write_gate.begin_close();
self.disconnect(c); self.disconnect(c);
signals.shutdown.send_replace(true); signals.shutdown.send_replace(true);
signals.wake.notify_one(); signals.wake.notify_one();
drop(write_gate);
self.flush_notices(); self.flush_notices();
} }

View file

@ -734,8 +734,17 @@ async fn teardown_waits_for_an_active_transport_poll_before_reclaiming() {
.store_dir() .store_dir()
.join("sealed") .join("sealed")
.join(&artifact.reference().artifact_id); .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 publisher
.publish("t.poll-gate", obj(json!({})), &[("data", &artifact)]) .publish(
"t.poll-gate",
obj(json!({"blob": "p".repeat(50_000)})),
&[("data", &artifact)],
)
.await .await
.unwrap(); .unwrap();
drop(artifact); drop(artifact);
@ -745,19 +754,27 @@ async fn teardown_waits_for_an_active_transport_poll_before_reclaiming() {
let done = Arc::new(AtomicBool::new(false)); let done = Arc::new(AtomicBool::new(false));
let shutdown_done = done.clone(); let shutdown_done = done.clone();
let shutdown_router = router.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 || { let shutdown = std::thread::spawn(move || {
started_tx.send(()).expect("the test is waiting");
shutdown_router.shutdown(); shutdown_router.shutdown();
shutdown_done.store(true, Ordering::SeqCst); shutdown_done.store(true, Ordering::SeqCst);
}); });
tokio::time::sleep(Duration::from_millis(50)).await; started_rx.recv().expect("shutdown thread started");
assert!( for _ in 0..64 {
!done.load(Ordering::SeqCst), assert!(
"teardown completed while poll_write was active" !done.load(Ordering::SeqCst),
); "teardown completed while poll_write was active"
assert!( );
sealed_path.exists(), assert!(
"artifact was reclaimed while poll_write was active" sealed_path.exists(),
); "artifact was reclaimed while poll_write was active"
);
tokio::task::yield_now().await;
}
hold.release(); hold.release();
shutdown.join().unwrap(); shutdown.join().unwrap();
@ -765,12 +782,23 @@ async fn teardown_waits_for_an_active_transport_poll_before_reclaiming() {
assert_eq!(router.stats().owners, 0); assert_eq!(router.stats().owners, 0);
assert_eq!(router.stats().artifacts, 0); assert_eq!(router.stats().artifacts, 0);
assert!(!sealed_path.exists()); assert!(!sealed_path.exists());
let mut ops = Vec::new();
while let Some(envelope) = within("poll-gate close", raw.recv()).await { while let Some(envelope) = within("poll-gate close", raw.recv()).await {
assert_ne!( assert_ne!(
envelope.op, "topic.message", envelope.op, "topic.message",
"delivery completed after teardown reclaimed its owner" "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)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]