Repository navigation
Leave eBPF socket storage to the kernel on close - #426
Merged
Merged
Conversation
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>
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.
Problem
In eBPF packet-counter mode (
enforcement_mode: ebpf), every guestCloseof a counted socket failed withclose_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.Closeclosed the socket and then calledDebugletSkMap.Delete(fd)with the descriptor number it had just closed.debuglet_sk_mapis aBPF_MAP_TYPE_SK_STORAGEmap, 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
Closeno 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.Closestill closes the socket before callingDetach, runs once (sync.Once) and returns the close error first.BpfConnno longer storessocketID.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 returnsTCX_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 afterClose, with no extra drops.With the entry left in place, that tail keeps the UUID and is dropped once
Detachremoves the run's rate entry: 0 bytes delivered afterClose, and egress drops rise. This also removes the fd-reuse hazard, because nothing is ever deleted by fd number.Audit:
DebugletSkMapis used only byBpfCount.Attach(Update). Every counted socket goes throughhostconn.NewConnection→Attach: dialed TCP and UDP, and TCP accepted byaccept_tcp. All of them close throughBpfConn.Close. Listening sockets are never attached.Tests
TestBpfConnCloseLeavesSocketStorageToTheKernel(TCP, UDP; unprivileged): runsCloseagainst a counter with no maps, so any map access panics and fails the test. It checks that the socket is closed once,Detachruns once, and a secondClosedoes nothing.TestBpfConnClosePrefersCloseError:Closereturns the close error, and a repeatedClosereturns the first result.TestKernelCounterClosesCountedConns(kernel; added to the required set inscripts/ci-kernel.sh): loads the counter onlo.ErrKeyNotExist). Counting the map's entries is not possible here, because SK_STORAGE maps cannot be iterated by key.Close, and egress drops must increase (the tail stays attributed). The test refuses to pass if fewer than 128 KiB were left unsent atClose.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):ratelimit/ebpfpackage passes with-count=3. The tail check loggedwritten 487740, delivered 32741 before Close, 0 after; egress drops 17 -> 24on every run.main's code: the unit test fails ("Close touched a counter map"). The kernel test fails withdelete: bad file descriptorfor TCP, UDP and the tail case.454999 bytes left the closed socket after Close, and egress drops stay at 13 -> 13.go build(darwin andGOOS=linux, excluding the wasm-onlyexamples/),go vet(darwin andGOOS=linux) andgo test ./internal/executor/ratelimit/...on darwin are clean.Fixes #412
🤖 Generated with Claude Code