upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-09-02 17:39:50 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-09-02 17:39:50 +0000
commit0a8c445742ce871c6b9352bf406d2232a6fd3134 (patch)
tree058ed75eee5a1559d8c19f3411bd2ec729e1439c
parent0b566b4869b9e86c90162befd87f9882b6dd143c (diff)
tunnel: give both pumps shared ownership of the sockets they close
PumpIn's wrapper closed pumpEnd and the rendezvous socket the moment it exited, without waiting for PumpOut, which then called shutdown() on both at its own exit. Which of the two got there first decided whether that shutdown addressed a live descriptor or a number the kernel had already handed back out -- in which case it tears down an unrelated connection. The inbound pump wins that race whenever the rendezvous socket dies before the app socket, which is the ordinary shape of a relay restart or a far side hanging up. The failure path added in 0b566b4 has the same hole. Its catch shuts down rv to wake an inbound pump it assumes is parked in RvReadN. PumpIn does not park when the rendezvous connection is already dead: it returns on its first length read and its wrapper closes both descriptors. A refused pthread_create and a dead rv are not independent -- both are symptoms of a loaded process -- so that is the likely case, not the exotic one. Both descriptors now belong to a shared_ptr the two pumps hold for the whole of their bodies, so the close happens after the last pump has stopped touching them and no shutdown can precede it. appEnd is unchanged: the caller gets it on success, StartTunnel closes it on every failure path, no pump ever touches it. 0b566b4's message says of that catch that "closing those descriptors from the failure path instead would race it". True as far as it goes, but it reads as though what the code did instead was safe, and it was not -- the shutdown raced the same close. There was no ordering there to prefer, only a missing owner. Measured against the real code with the relay in a separate process, every close() and shutdown() traced and pthread_create refused on demand: normal teardown, 25 tunnels 29 shutdowns on a closed fd -> 0 refused outbound spawn, 25 25 shutdowns on a closed fd -> 0 Close counts are identical before and after (8 and 5 per tunnel, every one accounted for) and live descriptors return to their starting count, so the defect was only ever the ordering, never a leak or a double close.
-rw-r--r--src/btftunnel.cpp85
1 files changed, 68 insertions, 17 deletions
diff --git a/src/btftunnel.cpp b/src/btftunnel.cpp
index 2b3f818..4acca1a 100644
--- a/src/btftunnel.cpp
+++ b/src/btftunnel.cpp
@@ -26,6 +26,8 @@ typedef int SOCKET;
26#include "sockcount.h" 26#include "sockcount.h"
27#include <cstring> 27#include <cstring>
28#include <cstdio> 28#include <cstdio>
29#include <memory>
30#include <new>
29#include <vector> 31#include <vector>
30#include <thread> 32#include <thread>
31#include <system_error> 33#include <system_error>
@@ -121,9 +123,44 @@ static void PumpIn(SOCKET pumpEnd, RvSocket rv, std::vector<unsigned char> key)
121 shutdown((SOCKET)rv, SD_BOTH); 123 shutdown((SOCKET)rv, SD_BOTH);
122} 124}
123 125
126// The pump end and the rendezvous socket, owned jointly by whoever is still
127// using them. Closed once, by the last reference to go.
128//
129// Joint ownership rather than "the inbound pump owns the cleanup", because
130// either pump can reach its exit first and BOTH touch BOTH descriptors on the
131// way out -- PumpOut ends in shutdown(rv), shutdown(pumpEnd) and PumpIn ends in
132// the mirror image. A single owner therefore has no exit at which the other is
133// guaranteed to be finished. Holding a reference for the whole body of each
134// pump gives it one: the close cannot happen until the last pump has stopped
135// touching the descriptors, so no shutdown() can land on a number that has
136// already been closed and handed back out by the kernel to an unrelated
137// connection.
138struct TunnelEnds
139{
140 SOCKET pumpEnd;
141 RvSocket rv;
142 TunnelEnds(SOCKET p, RvSocket r) : pumpEnd(p), rv(r) {}
143 ~TunnelEnds() { BtfCloseSocket(pumpEnd); RvClose(rv); }
144private:
145 TunnelEnds(const TunnelEnds&);
146 TunnelEnds& operator=(const TunnelEnds&);
147};
148
124// Start the two pumps and hand back the app-facing socket. When both pumps 149// Start the two pumps and hand back the app-facing socket. When both pumps
125// finish, close the pump end and the rendezvous socket. 150// finish, close the pump end and the rendezvous socket.
126// 151//
152// That sentence used to be a comment describing something the code did not do.
153// The inbound pump closed both descriptors as soon as it exited, without
154// waiting for the outbound pump, which then called shutdown() on both at its
155// own exit. Whether that shutdown hit a live descriptor or a closed one came
156// down to which pump got there first, and the rendezvous socket dying before
157// the app socket -- a relay restart, the far side hanging up -- is precisely
158// the order that puts the inbound pump ahead. Measured over 25 such teardowns
159// against the real code: 29 shutdowns landed on an already-closed descriptor.
160//
161// appEnd is not part of this. It is handed to the caller on success and closed
162// here on every failure path; no pump ever touches it.
163//
127// Every tunnel is two threads, and a tunnel is one peer connection (a .btf 164// Every tunnel is two threads, and a tunnel is one peer connection (a .btf
128// dial or accept, a pool miner, a participant's pool session) -- so the 165// dial or accept, a pool miner, a participant's pool session) -- so the
129// thread count scales with peer count, which is attacker-influenced. When 166// thread count scales with peer count, which is attacker-influenced. When
@@ -139,41 +176,55 @@ static btf_socket_t StartTunnel(RvSocket rv, const unsigned char shared[32])
139 176
140 std::vector<unsigned char> key(shared, shared + 32); 177 std::vector<unsigned char> key(shared, shared + 32);
141 178
142 // The inbound pump starts first because it owns the cleanup: it closes 179 // make_shared, not shared_ptr(new ...): it allocates before it constructs,
143 // pumpEnd and rv when it exits. That ownership is what makes the second 180 // so it either comes back owning both descriptors or throws having taken
144 // spawn's failure path below race-free. 181 // ownership of neither. The two-step form can run the deleter and then
182 // throw, which would close both here and again below.
183 std::shared_ptr<TunnelEnds> ends;
184 try
185 {
186 ends = std::make_shared<TunnelEnds>(pumpEnd, rv);
187 }
188 catch (const std::bad_alloc&)
189 {
190 fprintf(stderr, "btf tunnel: out of memory starting tunnel, tunnel dropped\n");
191 BtfCloseSocket(pumpEnd);
192 BtfCloseSocket(appEnd);
193 RvClose(rv);
194 return INVALID_SOCKET;
195 }
196
145 try 197 try
146 { 198 {
147 std::thread([pumpEnd, rv, key]() { 199 std::thread([ends, key]() { PumpIn(ends->pumpEnd, ends->rv, key); }).detach();
148 PumpIn(pumpEnd, rv, key);
149 BtfCloseSocket(pumpEnd);
150 RvClose(rv);
151 }).detach();
152 } 200 }
153 catch (const std::system_error& e) 201 catch (const std::system_error& e)
154 { 202 {
155 // Nothing is running yet, so closing everything here is safe. 203 // Nothing is running yet: `ends` is the only reference, and returning
204 // drops it, which is what closes pumpEnd and rv.
156 fprintf(stderr, "btf tunnel: cannot spawn inbound pump (%s), tunnel dropped\n", 205 fprintf(stderr, "btf tunnel: cannot spawn inbound pump (%s), tunnel dropped\n",
157 e.what()); 206 e.what());
158 BtfCloseSocket(pumpEnd);
159 BtfCloseSocket(appEnd); 207 BtfCloseSocket(appEnd);
160 RvClose(rv);
161 return INVALID_SOCKET; 208 return INVALID_SOCKET;
162 } 209 }
163 210
164 try 211 try
165 { 212 {
166 std::thread(PumpOut, pumpEnd, rv, key).detach(); 213 std::thread([ends, key]() { PumpOut(ends->pumpEnd, ends->rv, key); }).detach();
167 } 214 }
168 catch (const std::system_error& e) 215 catch (const std::system_error& e)
169 { 216 {
170 // The inbound pump is parked in RvReadN(rv). shutdown() wakes it, and 217 // The inbound pump may be parked in RvReadN(rv) or may already have
171 // it closes pumpEnd and rv itself -- closing them here instead would 218 // exited -- when the spawn is refused because the process is loaded,
172 // race exactly that, and a reused descriptor is how an unrelated 219 // the rendezvous socket is often dead for the same reason, and then
173 // socket gets closed. 220 // PumpIn returns on its first length read. Either way this thread
221 // still holds a reference, so both descriptors are still open: the
222 // shutdown wakes the pump if it is parked and is harmless if it is
223 // not. The close belongs to whichever reference goes last.
174 fprintf(stderr, "btf tunnel: cannot spawn outbound pump (%s), tunnel dropped\n", 224 fprintf(stderr, "btf tunnel: cannot spawn outbound pump (%s), tunnel dropped\n",
175 e.what()); 225 e.what());
176 shutdown((SOCKET)rv, SD_BOTH); 226 shutdown((SOCKET)ends->rv, SD_BOTH);
227 shutdown(ends->pumpEnd, SD_BOTH);
177 BtfCloseSocket(appEnd); 228 BtfCloseSocket(appEnd);
178 return INVALID_SOCKET; 229 return INVALID_SOCKET;
179 } 230 }