Skip to content

Leave eBPF socket storage to the kernel on close - #426

Merged
vincent10400094 merged 2 commits into
mainfrom
fix/ebpf-conn-close-order
Oct 8, 2026
Merged

vincent10400094 merged 2 commits into
mainfrom
fix/ebpf-conn-close-order

Conversation

@vincent10400094

@vincent10400094 vincent10400094 commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Problem

In eBPF packet-counter mode (enforcement_mode: ebpf), every guest Close of a counted socket failed with close_tcp: delete: bad file descriptor, so the run ended "debuglet failed" although its output had been delivered. Reproduced on v0.3.0-rc.1 on the Docker Desktop kernel for TCP (http_get) and UDP (DNS).

Cause

BpfConn.Close closed the socket and then called DebugletSkMap.Delete(fd) with the descriptor number it had just closed. debuglet_sk_map is a BPF_MAP_TYPE_SK_STORAGE map, and its key is a descriptor that the kernel resolves to a socket at call time. After the close, that number names nothing (EBADF). If it had been reused in between, it would have named another socket, and that socket's entry could have been deleted.

Fix

Close no longer deletes the entry. An SK_STORAGE entry belongs to the socket, not the fd number, and the kernel frees it when the socket is destroyed. Close still closes the socket before calling Detach, runs once (sync.Once) and returns the close error first. BpfConn no longer stores socketID.

Why the explicit delete was removed rather than moved before close

The first version of this PR deleted the entry while the fd was still open, then closed. Review found that this is a rate-limit and accounting bypass. Everything a TCP socket still sends after close(2) (the unsent send buffer, the FIN, retransmissions) no longer has the run's UUID. The egress hook therefore returns TCX_NEXT, so that data is neither throttled nor counted. A guest can fill a send buffer, close, and repeat on fresh sockets. The kernel test below measured it: under a 16 KiB/s limit, the delete-first order delivered 454,999 bytes in the second after Close, with no extra drops.

With the entry left in place, that tail keeps the UUID and is dropped once Detach removes the run's rate entry: 0 bytes delivered after Close, and egress drops rise. This also removes the fd-reuse hazard, because nothing is ever deleted by fd number.

Audit: DebugletSkMap is used only by BpfCount.Attach (Update). Every counted socket goes through hostconn.NewConnection → Attach: dialed TCP and UDP, and TCP accepted by accept_tcp. All of them close through BpfConn.Close. Listening sockets are never attached.

Tests

  • TestBpfConnCloseLeavesSocketStorageToTheKernel (TCP, UDP; unprivileged): runs Close against a counter with no maps, so any map access panics and fails the test. It checks that the socket is closed once, Detach runs once, and a second Close does nothing.
  • TestBpfConnClosePrefersCloseError: Close returns the close error, and a repeated Close returns the first result.
  • TestKernelCounterClosesCountedConns (kernel; added to the required set in scripts/ci-kernel.sh): loads the counter on lo.
    • It attaches a dialed TCP connection, an accepted TCP connection and a dialed UDP socket, and checks their entries. Each must exchange data and close cleanly. Its rate entry must be gone. The other socket's entry must survive. A new socket that reuses the closed fd number must have no entry (ErrKeyNotExist). Counting the map's entries is not possible here, because SK_STORAGE maps cannot be iterated by key.
    • Write-then-close tail: a TCP client with a 16 KiB/s limit and a large send buffer writes for 500 ms, then closes. At most one 64 KiB burst may arrive after Close, and egress drops must increase (the tail stays attributed). The test refuses to pass if fewer than 128 KiB were left unsent at Close.

Results (linux/arm64 test binary, Docker Desktop kernel, container with the kernel lane's capabilities BPF NET_ADMIN NET_RAW PERFMON SYS_RESOURCE, no --privileged):

  • This branch: the whole ratelimit/ebpf package passes with -count=3. The tail check logged written 487740, delivered 32741 before Close, 0 after; egress drops 17 -> 24 on every run.
  • Unprivileged container: unit tests pass; kernel tests skip, as designed.
  • Same tests against main's code: the unit test fails ("Close touched a counter map"). The kernel test fails with delete: bad file descriptor for TCP, UDP and the tail case.
  • Same tests against the delete-before-close order: the tail check fails with 454999 bytes left the closed socket after Close, and egress drops stay at 13 -> 13.
  • go build (darwin and GOOS=linux, excluding the wasm-only examples/), go vet (darwin and GOOS=linux) and go test ./internal/executor/ratelimit/... on darwin are clean.

Fixes #412

🤖 Generated with Claude Code

vincent10400094 and others added 2 commits October 7, 2026 16:48
BpfConn.Close closed the socket and then deleted its debuglet_sk_map entry
by the descriptor number it had just closed. The map is an SK_STORAGE map
whose key is a descriptor resolved at call time, so every delete failed
with EBADF and every guest close in eBPF counting mode failed the run; a
reused descriptor number could also have dropped another socket's entry.

Delete the entry through RawConn.Control while the descriptor is open,
then close the socket and detach the rate limit. A missing entry is not
an error, the socket is closed even if the delete fails, the close error
takes precedence, and Close runs once.

Add unit tests with an injected deleter that check the order and that the
descriptor still names the socket at delete time, and a kernel test,
required by the kernel CI lane, that closes counted dialed and accepted
TCP and UDP connections.

Fixes #412

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the previous commit: deleting the debuglet_sk_map entry before
close(2) lets everything the socket still sends after the close (the
unsent send buffer, FIN, retransmissions) leave without the run's UUID,
so it is neither rate-limited nor counted. Writing a send buffer and
closing, repeated over fresh sockets, bypasses the destination policy;
the kernel test measured 455 KB delivered within a second of Close under
a 16 KiB/s limit.

The entry is SK_STORAGE, owned by the socket and freed by the kernel when
the socket is destroyed, so Close does not delete it at all. The tail
keeps its UUID and is dropped once Detach removes the rate entry, and no
delete addresses a closed or reused descriptor. Close still closes before
detaching, runs once and returns the close error first.

Tests: the unit test runs Close against a counter without maps, so any
map access fails it. The kernel test checks that a reused descriptor
number carries no entry, and that a write-then-close tail under a low
limit is dropped as attributed instead of delivered.

Fixes #412

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vincent10400094 vincent10400094 changed the title Delete eBPF socket storage before closing a counted conn Leave eBPF socket storage to the kernel on close Oct 8, 2026
@vincent10400094
vincent10400094 merged commit ad32eab into main Oct 8, 2026
17 checks passed
@vincent10400094
vincent10400094 deleted the fix/ebpf-conn-close-order branch October 8, 2026 00:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

eBPF mode: BpfConn.Close deletes socket storage after closing the fd, failing every run that closes a socket

1 participant