net: harden TCP net-channel fast path against connection teardown#1402
net: harden TCP net-channel fast path against connection teardown#1402gburd wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens OSv’s TCP net-channel fast-path (tcp_net_channel_packet) to avoid panics during connection teardown/churn by aligning its validation and locking behavior with the existing slow path (tcp_input).
Changes:
- Re-resolve and validate the socket inside the net-channel callback, dropping packets if the inpcb is detached (
inp_socket == NULL). - Acquire the socket/inpcb lock inside the callback when not already owned, then re-validate after locking to close teardown race windows.
- Drop segments for connections in
state <= TCPS_LISTENorstate == TCPS_TIME_WAITbefore callingtcp_do_segment(), matching the slow path’s behavior.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| bool taken = !SOCK_OWNED(so); | ||
| if (taken) { | ||
| SOCK_LOCK(so); | ||
| /* Re-validate after acquiring the lock: detach could have | ||
| * raced with us between the !SOCK_OWNED check and the lock |
| if (taken) { | ||
| SOCK_UNLOCK(so); | ||
| } |
| if (taken) { | ||
| SOCK_UNLOCK(so); | ||
| } |
nyh
left a comment
There was a problem hiding this comment.
Looks good to me, I left only some minor requests.
However, I would like to know how you tested this fix. Do you have a way to reproduce this bug and checked that this fix really fixes it? It would have been nice to add a test in tests/ that reproduces it, but I realize it might be hard to reproduce and maybe you reproduced it in a long manual test, but surely you must have reproduced it somehow (and verified the fix somehow), so please mention in the commit message how.
| #include <bsd/sys/net/netisr.h> | ||
|
|
||
| // INP_LOCK held | ||
| /* |
There was a problem hiding this comment.
The old comment said "INP_LOCK held". Is this still true here? Is it relevant?
There was a problem hiding this comment.
Fixed in 5fb8c3b — removed the stale // INP_LOCK held line. The lock state is now tracked explicitly via the locked_here flag rather than an out-of-date comment.
| * recursive so re-locking when the caller already holds it is a | ||
| * cheap depth bump rather than a deadlock. | ||
| * - The old SOCK_LOCK_ASSERT guarded against a class of bugs that | ||
| * no longer apply once we acquire the lock ourselves; remove it. |
There was a problem hiding this comment.
The last bullet point belongs more in the commit message, less in a comment (a reader of this comment doesn't know that an "old SOCK_LOCK_ASSERT" was even here).
There was a problem hiding this comment.
Fixed in 5fb8c3b — moved this rationale into the commit message. The inline comment is now trimmed to just what a reader at this line needs.
| auto tlen = ip_len - (ip_size + (th->th_off << 2)); | ||
| auto iptos = ip_hdr->ip_tos; | ||
| SOCK_LOCK_ASSERT(so); | ||
| bool taken = !SOCK_OWNED(so); |
There was a problem hiding this comment.
The name "taken" is confusing - you set it to true if the lock is not already taken (by us). Later this makes sense ("taken" means we took the lock and need to release it), but right here, it's a confusing name.
There was a problem hiding this comment.
Fixed in 5fb8c3b — renamed taken to locked_here so the variable reads as 'we acquired the lock in this frame' at each unlock site.
| * been detached (no socket to deliver to). | ||
| * - Acquire SOCK_LOCK if we don't already own it. OSv's mutex is | ||
| * recursive so re-locking when the caller already holds it is a | ||
| * cheap depth bump rather than a deadlock. |
There was a problem hiding this comment.
This paragraph is misleading, because you did NOT make use of the fact that OSv's mutex is recursive, and in fact you try hard to not re-take it if already owned, and remember not to release it if it was already owned. You could have made use of exactly that recusrive locking feature and avoid the "taken" variable, but you didn't. So please update either the comment or the implementation.
There was a problem hiding this comment.
Fixed in 5fb8c3b — rewrote the recursive-mutex paragraph. It no longer claims re-locking is free; it now states plainly that OSv's mutex is recursive so re-entering when the caller already holds it is a depth bump (not a deadlock), and we unlock the same number of times via locked_here.
e3aadd6 to
5fb8c3b
Compare
|
Thanks for the review. To your question about how this was tested: I found this by code inspection while auditing the net-channel path, not from a captured panic, so I don't have a deterministic reproducer to turn into a The crash signature matches #936 — I've rewritten the commit message and the PR description to spell all of this out, and (per the AI's and your comments) the fast path now locks via |
I'm not worried about a "deterministic" reproducer, I'm more worried about whether you were able to ever reproduce this bug - or even more simply - to run a workload that you are sure reaches this code. What scares me is that I'm very rusty in this code - I haven't looked at this networking code in nearly a decade and it wasn't even me who originally wrote it (it was @avikivity) - so it's hard for me to be confident in my review. I didn't even remember how or whether INP_LOCK and SOCK_LOCK are the same thing (I checked now, and it seems they do point to the same thing, I don't remember why). So unless you run some sort of workload that somehow reaches this code, not consistently or reliably but at least reaches it - how do we know the new code works? How do we know it doesn't outright crash? |
| * socket SOCK_LOCK(so) and INP_LOCK(inp) are the same mutex (see | ||
| * in_pcballoc: so->set_mutex(&inp->inp_lock)), but the inpcb lock has | ||
| * stable lifetime while inp_socket does not -- in_pcbdetach() nulls | ||
| * inp_socket and the TCP inpcb can outlive the socket. Taking the |
There was a problem hiding this comment.
I don't understand this lengthy explanation. We still use "so" below, so what is this discussion of a stable lifetime?
If both locks are the same, how can they have different lifetimes?
When I asked why you used SOCK_LOCK instead of INP_LOCK I though those were different, but maybe they are the same, and maybe there is no need to any explanation why you use one and not the other?
There was a problem hiding this comment.
Rewritten in 56c73de. You're right — the two locks are the same underlying mutex, so the 'different lifetimes' framing was wrong and is gone. The comment now says only why we take the lock via the inpcb: tp->t_inpcb is the pointer we know stays valid, whereas inp_socket is what in_pcbdetach() NULLs out. Taking INP_LOCK(inp) lets us then re-read inp_socket and NULL-check it before use. No more stable-lifetime discussion.
| * underneath it. The callback may run without the lock held (IRQ-side | ||
| * classifier path), so acquire it if we don't already own it; OSv's | ||
| * mutex is recursive, so re-locking when the caller already holds it | ||
| * is a cheap depth bump. |
There was a problem hiding this comment.
But... you do not do re-locking in the code below! If the lock is already taken, INP_LOCKED is true so locked_here is false, and you do NOT call INP_LOCK.
So I don't understand why you describe recursive locking in this comment - when you deliberately tried to avoid using recursive locking.
If you wanted to use recursive locking, you could just call INP_LOCK() without checking if it was already locked or not.
There was a problem hiding this comment.
Fixed in 56c73de. Agreed — the code deliberately avoids recursive re-locking (if INP_LOCKED is already true, locked_here is false and we do NOT call INP_LOCK), so describing recursive locking was misleading. That whole paragraph is removed. The comment now just explains the acquire-if-not-owned / release-the-same-number-of-times pattern tracked by locked_here.
| * stable lifetime while inp_socket does not -- in_pcbdetach() nulls | ||
| * inp_socket and the TCP inpcb can outlive the socket. Taking the | ||
| * lock via inp lets us hold a valid lock and re-resolve the socket | ||
| * underneath it. The callback may run without the lock held (IRQ-side |
There was a problem hiding this comment.
I think this line "the callback may run without the lock held (IRQ-side classifier path), so acquire it if we don't already own it" is the most important line in this comment (and perhaps the only one I agreed with ;-)). I think you should explain that almost always we come here with INP_LOCK held (in fact, this is what the old comment assumed) but in this-and-that case we can come here without the lock, and we need to take it before we can continue calling more functions that assume it is taken.
There was a problem hiding this comment.
Done in 56c73de. This is now the lead of the comment: 'Almost always we arrive here with the inpcb lock already held (the old code simply assumed it). The exception is the IRQ-side classifier path, which can drain a queued packet without the lock. So acquire INP_LOCK(inp) only if we don't already own it...'
tcp_net_channel_packet() is the net-channel fast path: a per-connection
callback run from net_channel::process_queue(). It assumed it ran with
the socket lock held and the inpcb still attached to its socket. Neither
holds in general:
- The callback can be drained from the IRQ-side classifier path, which
does not hold the lock.
- in_pcbdetach() nulls inp_socket, and a TCP inpcb can outlive its
socket (in_pcbfree() is deferred), so inp_socket may be NULL by the
time a queued packet is drained.
- A packet can arrive after the tcpcb has transitioned to
CLOSED/LISTEN/TIME_WAIT.
The slow path (tcp_input) drops segments for those states before
reaching tcp_do_segment(); the fast path did not, so under connection
churn a late packet reached tcp_do_segment() against a torn-down
connection and tripped its state KASSERT, panicking the kernel.
Lock the connection through the inpcb rather than the socket. For a TCP
socket SOCK_LOCK(so) and INP_LOCK(inp) are the same underlying mutex
(in_pcballoc does so->set_mutex(&inp->inp_lock)); we take it via the
inpcb because tp->t_inpcb is the pointer we know is valid, whereas
inp_socket is what detach tears away. Holding INP_LOCK(inp) lets us
re-resolve inp_socket underneath it:
- Almost always we arrive with the inpcb lock already held (the old
code assumed this unconditionally). The one exception is the
IRQ-side classifier drain, which runs without it, so we acquire
INP_LOCK(inp) only when INP_LOCKED(inp) is false and release it the
same number of times (tracked by locked_here). This deliberately
does not rely on recursive re-locking.
- Re-resolve inp_socket under the lock; drop the packet if the inpcb
has detached.
- Skip segments for connections at or below LISTEN or in TIME_WAIT,
matching the slow path.
How this was found and tested: the lifetime hazard was found by code
inspection while auditing the net-channel path, not from a captured
panic, so there is no deterministic in-tree reproducer to turn into a
tests/ case -- the race is between net_channel callback drain and socket
detach under connection churn, and reproducing it reliably would require
fault-injection hooks the tree does not have. The crash signature
matches the page_fault on a NULL socket lock reported in cloudius-systems#936
(soabort -> lockfree::mutex::lock(this=0x0) during close), which is the
same inp_socket-outlives-socket window this guards. Verification is
therefore by inspection plus build-qualification rather than a new test:
the guard reproduces the exact CLOSED/LISTEN/TIME_WAIT checks the slow
path already trusts.
Build-qualified on a g++ 14.3.0 host: tcp_input.cc compiles and the
kernel links clean (loader.elf, image=empty).
5fb8c3b to
56c73de
Compare
|
Pushed 56c73de addressing the latest round of review on the Concretely, the inline comment was rewritten to fix the three things you flagged:
Also switched the unlock sites to |
|
On whether the code is actually reached and whether it runs without crashing: yes, this is the default TCP receive hot path, not a rare configuration. tcp_setup_net_channel() is called unconditionally whenever a connection reaches ESTABLISHED, on both the active-open path (tcp_input.cc:1528, the SYN_SENT -> ESTABLISHED transition) and the passive-open path (tcp_input.cc:1936, SYN_RECEIVED -> ESTABLISHED). From then on the interface classifier delivers that connection's packets through the net channel, so tcp_net_channel_packet() is the function that processes essentially all in-order data segments for every established TCP connection. There is no sysctl or feature flag gating it; any TCP traffic exercises it. That means the in-tree TCP tests all run through this code: tst-bsd-tcp1 and its zero-copy variants (tst-bsd-tcp1-zrcv/zsnd/zsndrcv), misc-tcp, misc-tcp-sendonly, misc-tcp-hash-srv, misc-tcp-close-without-reading, plus the httpserver-based tests. If any of these connect, transfer data, and close, they drive the new locking and the added state/socket re-validation on every received segment. The teardown checks (inp_socket re-read under INP_LOCK, and the CLOSED/LISTEN/TIME_WAIT drop) are what protect the close-without-reading and churn cases. To be fully honest about the qualification level: I verified the fix by inspection plus build/link, and by reasoning that the added guards are the same checks tcp_input already trusts on the slow path (so a segment that the guard drops here is one the slow path would also have dropped rather than passed to tcp_do_segment). I have not stood up a high-connection-churn stress workload that deterministically hits the post-detach window. If you would like a concrete run-through before merge, the natural one is 'make check' (which runs the tst-bsd-tcp1* and misc-tcp* suite) on this branch; those all traverse this function for every segment, so a clean pass demonstrates the reworked path does not crash or regress ordinary traffic, even though it does not prove the rare detach race is closed. I can post the results of that run if that would give you enough confidence to merge. |
|
Ran the in-tree TCP suite against this branch (head All of these drive
For the teardown window specifically, I drove To be precise about qualification level: this exercises the modified path under real traffic including the close-without-reading teardown case, so we know the new Build + runs were local QEMU (x86_64, gcc 15.2). Happy to add any specific workload you'd like to see run through it. |
|
Ping on this. Review feedback is addressed in-thread above; the branch is rebased on current master and mergeable. For scheduling context: this is one of three prerequisites (#1398, #1400) under the OpenZFS 2.4.3 cutover replacing the old BSD-ZFS port. Landing these three unblocks a clean ZFS-only PR. Glad to iterate if anything remains. |
Split out of #1399 per review (one PR per subsystem).
Hardens the TCP net-channel fast path (
tcp_net_channel_packet) so itmirrors the protections the slow path (
tcp_input) already has. The slow pathdrops segments for CLOSED/LISTEN/TIME_WAIT connections before reaching
tcp_do_segment(); the fast-path callback did not, so under connection churn apacket could be queued on the channel after the tcpcb transitioned to CLOSED
and trip the
KASSERTintcp_do_segment(), panicking the kernel.Two fixes:
re-resolve
inp_socketunderneath that lock; bail if the inpcb wasdetached (
inp_socket == NULL). For a TCP socketSOCK_LOCK(so)andINP_LOCK(inp)are the same underlying mutex, but the inpcb lock has stablelifetime while
inp_socketis whatin_pcbdetach()tears away. The callbackcan run from the IRQ-side classifier path without the lock held, so it takes
INP_LOCKif it doesn't already own it (OSv's mutex is recursive) andre-validates after acquiring it.
state <= TCPS_LISTEN || state == TCPS_TIME_WAIT,matching the slow path.
This guards the lifetime window where the net channel outlives the socket
detach.
How this was found and tested
The lifetime hazard was found by code inspection while auditing the
net-channel path, not from a captured panic, so there is no deterministic
in-tree reproducer to turn into a
tests/case: the race is betweennet_channelcallback drain and socket detach under connection churn, andreproducing it reliably would need fault-injection hooks the tree does not have.
The crash signature matches the NULL-socket-lock page fault reported in #936
(
soabort->lockfree::mutex::lock(this=0x0)duringsoclose), which is thesame
inp_socket-outlives-socket window this guards. Verification is thereforeby inspection plus build-qualification: the guard applies the exact
CLOSED/LISTEN/TIME_WAIT checks the slow path already trusts.
Build-qualified (kernel compile+link,
image=empty) on a binutils 2.44 /g++ 14.3.0 host.