Skip to content

fix(vsock): pass half-closes through in both directions - #6349

Open
sorenbs wants to merge 3 commits into
firecracker-microvm:mainfrom
sorenbs:vsock-half-close
Open

sorenbs wants to merge 3 commits into
firecracker-microvm:mainfrom
sorenbs:vsock-half-close

Conversation

@sorenbs

@sorenbs sorenbs commented Oct 28, 2026 •

Copy link
Copy Markdown

Changes

Three commits: one per direction, and one that ends a connection from a single step. Each builds on the one before it.

fix(vsock): pass a guest's send shutdown to the host stream

  • When the guest sends VSOCK_OP_SHUTDOWN with only VSOCK_FLAGS_SHUTDOWN_SEND, the connection now shuts down the write half of the host-side stream, once its TX buffer has drained. That happens both when the shutdown packet arrives and when a later EPOLLOUT flushes the buffer. The host reads EOF, as it would from a TCP half-close, and can still send to the guest.
  • VsockConnectionBackend gains shutdown_write, which UnixStream implements with shutdown(Shutdown::Write).
  • A failed shutdown kills the connection, as a failed write does.
  • The vmm thread's seccomp filter allows shutdown with SHUT_WR only, on x86_64 and aarch64.

fix(vsock): pass a host half-close to the guest as a half-close

  • When the host-side stream returns EOF without a hang-up, the connection now sends the guest only VSOCK_FLAGS_SHUTDOWN_SEND, instead of both flags.
    • It stops polling the host stream for input, and keeps forwarding the guest's data.
    • It terminates the connection once the guest stops sending too.
  • EOF together with EPOLLHUP, or after the guest has already stopped sending, behaves as before: both flags, and the kill timer. A Unix stream socket reports EPOLLHUP once both of its directions are shut. A peer's close does that; a peer's shutdown(SHUT_WR) does not.
  • Until then, the connection stays registered with epoll for EPOLLHUP, which the kernel reports whatever the interest set. A host that closes for good after its half-close, or that closed while the last read was pending, kills the connection at once.

fix(vsock): end a connection from one step once neither end sends

  • A guest half-close followed by a host half-close lost the first: the read-EOF branch replaced the guest's recorded shutdown with the host's. So the write shutdown of the host stream never ran, and the connection could be reset while the TX buffer still held data for the host. The host then read a clean EOF short of what the guest had sent.
  • One step, advance_shutdown(), now decides after every event that can change the shutdown state. It replaces four call sites that each decided when the connection was done.
    • Once the guest has promised to send no more data and the TX buffer has drained, it shuts down the write half of the host stream.
    • Once neither end will send more data, it schedules an RST after the TX buffer drains, and arms the kill timer while it drains.
  • A StreamEnd enum, with the values open, hung up, and half-closed, replaces two booleans beside ConnState that allowed combinations that cannot occur.

Reason

Today a half-close is lost in one direction and turned into a reset in the other.

  • Guest to host. A guest's send-only shutdown is recorded as PeerClosed(false, true) and goes no further. The host never reads EOF until the guest closes the receiving side too. A host application that waits for the end of the guest's request before it answers or closes, such as an HTTP/1.0 server or a proxy that forwards the half-close, waits for data that never comes.
  • Host to guest. The host's shutdown(SHUT_WR) is passed to the guest as a shutdown of both directions. The Linux guest driver then resets the connection whenever it holds no unread data. Data the guest was still about to send is lost. An HTTP server in a guest cannot answer a client that half-closed after its request, if a host proxy forwards that half-close.

We found this running a TCP proxy on the host. The proxy carries each guest's outbound TCP connections over vsock, and other guests' inbound ones. Without the first fix, a guest workload's closed connection never reached the remote end, so the proxy had to guess when to end it. Without the second, forwarding a client's half-close to a guest server reset the server's connection before it answered. Measured between two microVMs on one host, with both fixes:

  • 5,000 connections that the client closed first, 8 at a time, all completed, at about 1,900 a second.
  • A client that half-closes and then reads got its answer whether it came at once or 7 seconds later.

Our "before" build is not stock Firecracker, so its numbers do not reproduce against plain Firecracker. It worked around the first bug: the guest-side agent waited until the host had sent nothing for 5 seconds, then closed both directions. That workaround capped the closing client at the proxy's slots, 496 of 5,000 connections, and lost the answer that came 7 seconds later. With only the first fix and without the workaround, the second bug showed: the half-closing client got no answer at all, even an immediate one.

No timer on a half-closed connection. A half-closed connection stays open as long as both ends keep their sockets, as a TCP connection does after shutdown(SHUT_WR). Each end can still end it: the guest by closing, which sends both flags, and the host by closing, which raises EPOLLHUP. A timer would cut connections that legitimately stay half-open for a long time, such as a client that half-closes after its request while the server streams a long answer. How long to wait for the other side after a half-close is the application's policy, so this change adds none.

Tests:

  • Unit tests on the connection state machine, for:
    • the send shutdown;
    • the flush before the write shutdown;
    • a failed write shutdown;
    • a host half-close;
    • a host close for good;
    • a host EOF after the guest stopped sending.
  • A unit test for a host half-close that follows the guest's while data is still buffered for the host.
  • Four muxer tests over a real UnixStream: one per direction, a host close for good after a half-close, and both half-closes, guest first. They depend on the kernel reporting EPOLLHUP for a close and not for a write shutdown.
  • The existing test_local_close and test_muxer_killq, which drop the host stream, still see both flags and the kill timer.

All vsock unit tests pass with --test-threads=1, on each commit. In parallel, test_vsock_basic_metrics and test_muxer_killq fail on main without this change too, because they assert global metric counters.

Checks. I had no Docker host, so I ran the commands devtool checkbuild --all and devtool checkstyle run, with the pinned toolchain (Rust 1.97.0) and the pinned Python tool versions, on each commit:

  • cargo clippy --profile ci --target x86_64-unknown-linux-gnu --target aarch64-unknown-linux-gnu --all --all-targets --all-features -- -D warnings
  • cargo fmt --all -- --check and cargo-sort --workspace --check --grouped
  • gitlint with framework/gitlint_rules.py, and mdformat --check on every Markdown file
  • seccompiler-bin on both edited filter files

The licence, Python, and swagger style checks cover files this change does not touch. cargo-sort reports src/vmm/Cargo.toml as unsorted on main too; no commit here touches it.

Known limitation, not changed here. Once both ends have closed while the device still holds data for the host, the kill timer ends the connection after CONN_SHUTDOWN_TIMEOUT_MS and drops what the device holds, as it does on main. A host that reads slowly can therefore miss up to the device's buffer, and its read still ends in a clean EOF. Removing the timer alone would not help: a Linux guest that closes its socket resets the connection after its own 8 s close timeout, and the device then drops the same bytes. We reproduced both on a KVM guest. A fix would acknowledge the guest's close at once and deliver the held bytes without a timer, which seems better as a separate change.

License Acceptance

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. For more information on following Developer Certificate of Origin and signing off your commits, please check CONTRIBUTING.md.

PR Checklist

  • I have read and understand CONTRIBUTING.md.
  • I have run tools/devtool checkbuild --all to verify that the PR passes build checks on all supported architectures. (Its command, outside the container; see Checks.)
  • I have run tools/devtool checkstyle --no-clippy to verify that the PR passes the automated style checks. (Its commands, outside the container; see Checks.)
  • I have described what is done in these changes, why they are needed, and how they are solving the problem in a clear and encompassing way.
  • I have updated any relevant documentation (both in code and in the docs) in the PR.
  • I have mentioned all user-facing changes in CHANGELOG.md.
  • If a specific issue led to this PR, this PR closes the issue. (No issue led to it.)
  • When making API changes, I have followed the Runbook for Firecracker API changes. (No API changes.)
  • I have tested all new and changed functionalities in unit tests and/or integration tests.
  • I have linked an issue to every new TODO. (No new TODOs.)

  • This functionality cannot be added in rust-vmm.

🤖 Generated with Claude Code

When the guest shuts down only the sending side of a connection, it
sends VSOCK_OP_SHUTDOWN with VSOCK_FLAGS_SHUTDOWN_SEND. The connection
recorded it as PeerClosed(false, true) but left the host-side stream
open for writing, so the host peer never read EOF. A host application
that waits for the end of the guest's data before it answers, or before
it closes, waited for data that would not come, until the guest closed
the receiving side too.

Shut down the write half of the host stream once the peer has promised
to send no more data and the TX buffer has drained, both when the
indication arrives and when a later EPOLLOUT flushes the buffer. The
host peer then reads EOF as it would from a TCP half-close, and can
keep sending: data from the host still reaches the guest in
PeerClosed(false, true), as before. A failed shutdown kills the
connection, as a failed write does.

VsockConnectionBackend gains a shutdown_write method for this, which
UnixStream implements with shutdown(Shutdown::Write). The vmm thread's
seccomp filter allows shutdown with SHUT_WR only.

Signed-off-by: Søren Bramer Schmidt <[email protected]>
When the host-side stream returned EOF, the connection sent the guest
VSOCK_OP_SHUTDOWN with both VSOCK_FLAGS_SHUTDOWN_RCV and
VSOCK_FLAGS_SHUTDOWN_SEND, and armed the kill timer. A 0-length read
only means the host will send no more data, as after
shutdown(SHUT_WR). The guest kernel, told that the host would receive
nothing either, reset the connection, and data the guest still had to
send was lost: an HTTP server behind vsock could not answer a client
that half-closed after its request.

Tell the two apart by epoll's hang-up. A Unix stream socket reports
EPOLLHUP once both of its directions are shut, which a peer's close
does and a peer's shutdown(SHUT_WR) does not. On EOF with a hang-up,
or after the guest already stopped sending, nothing changes: the guest
is told that neither end will send, and the kill timer is armed. On
EOF without one, the guest is told only that the host will send no
more data (VSOCK_FLAGS_SHUTDOWN_SEND). The connection stops polling
the host stream for input, keeps forwarding the guest's data, and is
terminated once the guest stops sending too. It stays registered with
epoll for a hang-up meanwhile, so a host that then closes for good, or
had closed while the last read was pending, terminates it at once.

Signed-off-by: Søren Bramer Schmidt <[email protected]>
When the guest half-closed first and the host second, the read-EOF
branch replaced the guest's recorded shutdown with the host's, so the
write shutdown of the host stream never ran, and the connection could
be reset while the TX buffer still held data for the host. The host
then read a clean EOF short of what the guest sent.

Four call sites each decided when the connection was done, from
booleans beside ConnState that allowed combinations that cannot occur.
Replace them with one step, advance_shutdown(), called after every
event that can change the shutdown state. Once the peer has promised
to send no more data and the TX buffer has drained, it shuts down the
write half of the host stream. Once neither end will send any more
data, it schedules an RST after the TX buffer has drained, and arms
the kill timer while it drains. A StreamEnd enum records how far the
host stream has ended: open, hung up, or half-closed.

A new unit test covers a host half-close that follows the guest's
while data is still buffered for the host, and a muxer test covers
both half-closes in that order over a real Unix socket.

Signed-off-by: Søren Bramer Schmidt <[email protected]>
@zulinx86 zulinx86 added the Status: Awaiting assignee Indicates that an issue or pull request is awaiting action from its assignee. label Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Awaiting assignee Indicates that an issue or pull request is awaiting action from its assignee.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants