Issue 32: end the send side on an abrupt local close, not only on EOF - #38
Merged
Conversation
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.
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.
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.
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #32.
It was never a slow path
A finished outbound session could leave the server counting it as attached for 40s+ on Linux, while macOS released in ~53ms. A 750x gap is a different code path, not a slower one.
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.PR #31 released the recycle guard on the error branch so the stall could not pin the endpoint, and deliberately left 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. 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 a single decision — EOF vs error — so a test injects it directly through the boxed local reader. Before the change the abrupt case left
send_closedatNonewhile the clean case setSome(5); now they agree.That turns a Linux-only, racy, 40-second failure into a deterministic assertion that runs anywhere in milliseconds. This unit test is the regression guard, confirmed failing on Linux CI against the pre-fix teardown in #37.
A correction I owe the reviewer
I first wrote an end-to-end test and claimed it was the Linux regression guard. It is not, and I was wrong about it twice.
The actual reason: dropping a
UnixStreamin-process closes it cleanly, so both platforms take the EOF branch and neither reaches the error branch where issue 32 lives. Reaching it needs an abrupt close — a killed process or an RST — which is how the original trace produced it.That test is renamed to what it really proves: a clean local close detaches the session on the server. Worth keeping, not the issue-32 guard.
I found this only because I ran the pre-fix code against the new tests on Linux instead of assuming they worked. A test that guards nothing while claiming to is worse than no test.
What CI proves and what it does not
Green Linux CI proves the teardown converges in a CI container. It is not Droppy or Hetz, and this repo has twice called a defect fixed on green CI while a real device still failed. Whether a host check is wanted is root's call, not mine.
Tests
194 lib tests green. Changed files pass rustfmt.