Skip to content

Commit b6ffc0a

Browse files
authored
Issue 32: end the send side on an abrupt local close, not only on EOF (#38)
* tunnel: end the send side on an abrupt local close, not only on EOF Issue 32. A finished outbound session could leave the SERVER counting it as attached for 40 seconds or more on Linux, while macOS released in about 53 ms. It was never a slow path. A dropped local socket reads as a clean zero-length read on macOS and as an error on Linux. Only the EOF branch recorded the send close, and only a recorded send close makes the writer emit Frame::Close. With no such frame the server waited for bytes that could never arrive. A 750x platform gap is a different code path, not a slower one. PR 31 released the recycle guard on this branch so the stall could not pin the endpoint, and deliberately left the teardown semantics alone. This finishes the job: the abrupt branch now calls mark_send_closed, which records the final offset and releases the guard. Only the send side closes, because the session may still be writing queued remote output and a half-close is not a close. Proved without Linux and without CI timing. The platform difference is one decision, EOF against error, so a test injects it directly through the boxed local reader. Before this change the abrupt case left send_closed at None while the clean case set it to Some(5); now they agree. That turns a Linux-only, racy, 40-second failure into a deterministic assertion that runs anywhere in milliseconds. A second test asserts the server-side outcome end to end: after the client drops its local end, the server's active attach count must reach zero. It carries a positive control that the server really counted the attach first, so a server that never counted the session cannot pass it for the wrong reason. That test passes on macOS either way, because macOS always took the branch that worked; it is the Linux regression guard. The recycle-guard release is now asserted for BOTH endings beside the new case, so a later change cannot fix one ending and quietly regress the other. 194 lib tests green. * test: isolate the issue-32 defect with a quiet tunnel, not a shell The first end-to-end test used a shell and passed on Linux CI with the defect present, so it guarded nothing. A shell streams output back, so a dropped local socket fails on the next WRITE and tears the session down by a second route that hides the missing close frame. The original trace was a quiet tunnel. With nothing to write back, the missing frame is the only thing that could end the session, which is what isolates the defect. * test: stop claiming the end-to-end test guards issue 32 It does not, and I presented it as though it did. Twice. Issue 32 lives in the branch where the local read returns an ERROR. Dropping a UnixStream in-process closes it cleanly, so both platforms take the EOF branch in this test and neither reaches the defect. Linux CI confirmed it twice against the pre-fix teardown: the shell version passed, and so did the quiet-tunnel version I wrote to replace it. My masking explanation for the first failure was wrong too. The shell was not hiding the defect behind a write failure; the test never reached the error branch at all. The real guard is the unit test, which injects the ending directly and fails on every platform when the defect is present. This test is renamed to what it actually proves: a CLEAN local close detaches the session on the server. That is worth keeping and is not the issue-32 guard.
1 parent 26ecf6c commit b6ffc0a

2 files changed

Lines changed: 209 additions & 8 deletions

File tree

src/daemon.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6143,4 +6143,106 @@ mod tests {
61436143
server.shutdown().await?;
61446144
Ok(())
61456145
}
6146+
6147+
/// The SERVER must stop counting a session as attached once the client's
6148+
/// local end goes away.
6149+
///
6150+
/// The test above proves the client releases its own gauge. It says nothing
6151+
/// about the far end, and the far end is what pins the server's endpoint.
6152+
/// This asserts the far end, on a quiet tunnel so no remote output can end
6153+
/// the session by a second route.
6154+
///
6155+
/// **This is NOT a regression guard for issue 32, and it was wrong to
6156+
/// present it as one.** Issue 32 lives in the branch where the local read
6157+
/// returns an ERROR, and dropping a `UnixStream` in-process closes it
6158+
/// cleanly, so both platforms take the EOF branch here. I confirmed that on
6159+
/// Linux CI twice: with the pre-fix teardown restored, this test passed,
6160+
/// first with a shell and then with this quiet tunnel. Reaching the error
6161+
/// branch needs an abrupt close such as a killed process or an RST, which is
6162+
/// how the original trace produced it.
6163+
///
6164+
/// The real guard for issue 32 is
6165+
/// `tunnel::tests::an_abrupt_local_close_reports_the_end_just_like_a_clean_eof`,
6166+
/// which injects the ending directly and therefore fails on every platform
6167+
/// when the defect is present. What this test still earns is the healthy
6168+
/// path: a clean local close must detach the session on the server promptly,
6169+
/// and that must not regress.
6170+
#[tokio::test]
6171+
async fn a_clean_local_close_detaches_a_quiet_session_on_the_server_too() -> Result<()> {
6172+
let server_dir = tempfile::tempdir()?;
6173+
let client_dir = tempfile::tempdir()?;
6174+
let server_home = FabricHome::new(server_dir.path());
6175+
let client_home = FabricHome::new(client_dir.path());
6176+
let server = FabricNode::start(server_home.clone()).await?;
6177+
let client = FabricNode::start(client_home.clone()).await?;
6178+
trust_test_peer(&server_home, &server, client.id(), "client", client.addr()).await?;
6179+
trust_test_peer(&client_home, &client, server.id(), "server", server.addr()).await?;
6180+
6181+
// A sink: it reads and never writes back. Anything it echoed would give
6182+
// the teardown a second route and mask exactly what this test isolates.
6183+
let sink_socket = server_dir.path().join("sink.sock");
6184+
let sink_listener = UnixListener::bind(&sink_socket)?;
6185+
tokio::spawn(async move {
6186+
while let Ok((mut stream, _)) = sink_listener.accept().await {
6187+
tokio::spawn(async move {
6188+
let mut buf = [0u8; 64];
6189+
while let Ok(read) = stream.read(&mut buf).await {
6190+
if read == 0 {
6191+
break;
6192+
}
6193+
}
6194+
});
6195+
}
6196+
});
6197+
server.expose("audit/sink", sink_socket.clone()).await?;
6198+
6199+
let client_state = client.state();
6200+
let server_state = server.state();
6201+
let socket = client_state
6202+
.dial_alpn("server", "audit/sink", b"audit/sink".to_vec(), false)
6203+
.await?;
6204+
let mut dial = UnixStream::connect(&socket).await?;
6205+
dial.write_all(b"open-the-session").await?;
6206+
6207+
// POSITIVE CONTROL. Prove the server really is holding an attach before
6208+
// asserting that it lets one go, or a server that never counted the
6209+
// session would pass the real check for the wrong reason.
6210+
let attached = tokio::time::timeout(Duration::from_secs(20), async {
6211+
loop {
6212+
if server_state.tunnel_sessions.stats().await.active_attaches > 0 {
6213+
return;
6214+
}
6215+
tokio::time::sleep(Duration::from_millis(50)).await;
6216+
}
6217+
})
6218+
.await;
6219+
assert!(
6220+
attached.is_ok(),
6221+
"the server never counted the quiet session as attached, so this test \
6222+
could not observe it detaching either"
6223+
);
6224+
6225+
drop(dial);
6226+
6227+
// Bounded well under the 40s stall and well over the ~53ms healthy case,
6228+
// so this fails on the defect and does not flake on a slow machine.
6229+
let detached = tokio::time::timeout(Duration::from_secs(20), async {
6230+
loop {
6231+
if server_state.tunnel_sessions.stats().await.active_attaches == 0 {
6232+
return;
6233+
}
6234+
tokio::time::sleep(Duration::from_millis(50)).await;
6235+
}
6236+
})
6237+
.await;
6238+
assert!(
6239+
detached.is_ok(),
6240+
"the server still counts the session as attached 20s after the client \
6241+
dropped its local end; a finished session pins the server's endpoint"
6242+
);
6243+
6244+
client.shutdown().await?;
6245+
server.shutdown().await?;
6246+
Ok(())
6247+
}
61466248
}

src/tunnel.rs

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -535,16 +535,24 @@ impl TunnelSession {
535535
Err(error) => {
536536
// An abrupt local close ends local input just as surely as a
537537
// clean EOF does: no further bytes can arrive on this session.
538-
// Release the recycle guard so a stalled remote teardown cannot
539-
// pin the endpoint. Deliberately ONLY the guard — send_closed
540-
// and every other teardown semantic stay exactly as they were,
541-
// because this error path is not the place to change them.
538+
// So end the send side the same way, which both releases the
539+
// recycle guard and records the final offset.
540+
//
541+
// Recording the offset is the part that matters remotely. Only
542+
// a recorded close makes the writer emit `Frame::Close`, and
543+
// only that frame lets the server stop counting this session
544+
// as attached. Without it the server waits for bytes that can
545+
// never arrive.
542546
//
543-
// This path is why the first version of the fix was incomplete.
544547
// Dropping a local socket yields a clean zero-length read on
545-
// macOS and an error on Linux, so releasing on clean EOF alone
546-
// left the Linux case pinned and the CI failure unchanged.
547-
self.release_attach_gauge().await;
548+
// macOS and an error on Linux, so this path used to run only
549+
// on Linux. That is the whole of the platform difference: the
550+
// 40-second stall in issue 32 was never a slow path, it was a
551+
// message the server never received.
552+
//
553+
// Only the send side closes. This session may still be writing
554+
// queued remote output, and a half-close is not a close.
555+
self.mark_send_closed().await;
548556
return Err(error.into());
549557
}
550558
};
@@ -2075,4 +2083,95 @@ mod tests {
20752083

20762084
assert!(kill.is_cancelled());
20772085
}
2086+
2087+
/// A local reader that hands over `bytes`, then ends the way the test asks.
2088+
///
2089+
/// This is the whole platform difference in issue 32, made explicit. Dropping
2090+
/// a local socket gives a clean zero-length read on macOS and an error on
2091+
/// Linux. Injecting the ending directly turns a Linux-only, racy, 40-second
2092+
/// CI failure into a decision this test makes on any operating system.
2093+
struct EndingReader {
2094+
bytes: Vec<u8>,
2095+
fail_at_end: bool,
2096+
}
2097+
2098+
impl AsyncRead for EndingReader {
2099+
fn poll_read(
2100+
mut self: std::pin::Pin<&mut Self>,
2101+
_cx: &mut std::task::Context<'_>,
2102+
buf: &mut tokio::io::ReadBuf<'_>,
2103+
) -> std::task::Poll<std::io::Result<()>> {
2104+
if !self.bytes.is_empty() {
2105+
let take = self.bytes.len().min(buf.remaining());
2106+
let chunk: Vec<u8> = self.bytes.drain(..take).collect();
2107+
buf.put_slice(&chunk);
2108+
return std::task::Poll::Ready(Ok(()));
2109+
}
2110+
if self.fail_at_end {
2111+
// What Linux reports when the local peer drops the socket.
2112+
return std::task::Poll::Ready(Err(std::io::Error::new(
2113+
std::io::ErrorKind::ConnectionReset,
2114+
"connection reset by peer",
2115+
)));
2116+
}
2117+
// What macOS reports for the same event: a clean EOF.
2118+
std::task::Poll::Ready(Ok(()))
2119+
}
2120+
}
2121+
2122+
async fn session_after_local_input_ends(fail_at_end: bool) -> Arc<TunnelSession> {
2123+
let (_write_peer, write) = duplex(64);
2124+
let reader = EndingReader {
2125+
bytes: b"hello".to_vec(),
2126+
fail_at_end,
2127+
};
2128+
let (session, local_read) =
2129+
TunnelSession::new_parts(session_id(9), peer_id(), Box::new(reader), Box::new(write));
2130+
// Returns Ok on EOF and Err on an abrupt close. Either way the local
2131+
// input is over, which is the only thing that matters here.
2132+
let _ = session.clone().run_local_reader(local_read).await;
2133+
session
2134+
}
2135+
2136+
/// Both endings must tell the remote that local input is over.
2137+
///
2138+
/// Only a recorded `send_closed` makes the writer emit `Frame::Close`, and
2139+
/// only that frame lets the server stop counting the session as attached.
2140+
/// Without it the server waits for bytes that can never arrive, which is the
2141+
/// 40-second Linux stall in issue 32: not a slow path, a missing message.
2142+
#[tokio::test]
2143+
async fn an_abrupt_local_close_reports_the_end_just_like_a_clean_eof() {
2144+
let clean = session_after_local_input_ends(false).await;
2145+
let abrupt = session_after_local_input_ends(true).await;
2146+
2147+
let clean_closed = clean.state.lock().await.send_closed;
2148+
let abrupt_closed = abrupt.state.lock().await.send_closed;
2149+
2150+
assert_eq!(
2151+
clean_closed,
2152+
Some(5),
2153+
"a clean EOF must close the send side after the 5 bytes it read"
2154+
);
2155+
assert_eq!(
2156+
abrupt_closed, clean_closed,
2157+
"an abrupt local close must end the send side exactly like a clean EOF; \
2158+
leaving it open is what makes the server hold the session attached"
2159+
);
2160+
}
2161+
2162+
/// The recycle guard must come off on both endings too.
2163+
///
2164+
/// This part already worked. It is asserted beside the case above so a later
2165+
/// change cannot fix one ending and quietly regress the other.
2166+
#[tokio::test]
2167+
async fn both_endings_release_the_recycle_guard() {
2168+
for fail_at_end in [false, true] {
2169+
let session = session_after_local_input_ends(fail_at_end).await;
2170+
assert!(
2171+
session.state.lock().await.attach_gauge.is_none(),
2172+
"the recycle guard must be released when local input ends \
2173+
(fail_at_end={fail_at_end})"
2174+
);
2175+
}
2176+
}
20782177
}

0 commit comments

Comments
 (0)