diff options
| author | Bitflash <btf01@proton.me> | 2026-08-01 21:27:29 -0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-01 21:27:29 -0300 |
| commit | ca0b6d0c4a3520a87037ba72bb1f1ee10eb504dc (patch) | |
| tree | e0b6ff645e55f42fb754da7945ecfb73d10c10fc | |
| parent | 09007b4a0321c594083fe1b5e67edbc8b257844b (diff) | |
diag: count the socket leak the way that is true, and say what it found (#100)
Windows lists a wildcard-bound socket twice in MSFT_NetTCPConnection, so counting Bound rows makes the number climb whenever the node talks to more peers -- 35 of a node's 38 Bound rows shared a local port with one of its own established connections. Counting orphans instead: a 1.2.10 node at 26h held 1262 and was still taking on 30-90 an hour; nodes on 1.2.11 and 1.2.12 held 3, transient. The leak was real and 1.2.11 ended it. Adds a live-unconn column (getpeername on every tagged socket, reports 1: the listener) and rewrites the notes that said otherwise.
| -rw-r--r-- | src/net.cpp | 38 | ||||
| -rw-r--r-- | src/sockcount.h | 74 |
2 files changed, 83 insertions, 29 deletions
diff --git a/src/net.cpp b/src/net.cpp index 4fe5f15..b56f9f5 100644 --- a/src/net.cpp +++ b/src/net.cpp | |||
| @@ -64,30 +64,38 @@ static string SockAccountingText() | |||
| 64 | long long nOpened[SOCK_SITES], nClosed[SOCK_SITES], nFailed[SOCK_SITES], nUntagged = 0; | 64 | long long nOpened[SOCK_SITES], nClosed[SOCK_SITES], nFailed[SOCK_SITES], nUntagged = 0; |
| 65 | BtfSockSnapshot(nOpened, nClosed, nFailed, &nUntagged); | 65 | BtfSockSnapshot(nOpened, nClosed, nFailed, &nUntagged); |
| 66 | 66 | ||
| 67 | long long nUnconn[SOCK_SITES]; | ||
| 68 | BtfSockLiveUnconnected(nUnconn); | ||
| 69 | |||
| 67 | string str = "\n sockets by where they were created\n"; | 70 | string str = "\n sockets by where they were created\n"; |
| 68 | str += " site opened closed live close failed\n"; | 71 | str += " site opened closed live close failed live unconn\n"; |
| 69 | long long nTotalOpened = 0, nTotalClosed = 0, nTotalFailed = 0; | 72 | long long nTotalOpened = 0, nTotalClosed = 0, nTotalFailed = 0, nTotalUnconn = 0; |
| 70 | for (int i = 0; i < SOCK_SITES; i++) | 73 | for (int i = 0; i < SOCK_SITES; i++) |
| 71 | { | 74 | { |
| 72 | nTotalOpened += nOpened[i]; | 75 | nTotalOpened += nOpened[i]; |
| 73 | nTotalClosed += nClosed[i]; | 76 | nTotalClosed += nClosed[i]; |
| 74 | nTotalFailed += nFailed[i]; | 77 | nTotalFailed += nFailed[i]; |
| 78 | nTotalUnconn += nUnconn[i]; | ||
| 75 | if (nOpened[i] == 0) | 79 | if (nOpened[i] == 0) |
| 76 | continue; | 80 | continue; |
| 77 | str += strprintf(" %-24s %8lld %8lld %8lld %13lld\n", pszSockSite[i], | 81 | str += strprintf(" %-24s %8lld %8lld %8lld %13lld %12lld\n", pszSockSite[i], |
| 78 | nOpened[i], nClosed[i], nOpened[i] - nClosed[i], nFailed[i]); | 82 | nOpened[i], nClosed[i], nOpened[i] - nClosed[i], nFailed[i], nUnconn[i]); |
| 79 | } | 83 | } |
| 80 | str += strprintf(" %-24s %8s %8lld %8s %13s\n", "closed but never tagged", | 84 | str += strprintf(" %-24s %8s %8lld %8s %13s %12s\n", "closed but never tagged", |
| 81 | "-", nUntagged, "-", "-"); | 85 | "-", nUntagged, "-", "-", "-"); |
| 82 | str += strprintf(" %-24s %8lld %8lld %8lld %13lld\n", "TOTAL", | 86 | str += strprintf(" %-24s %8lld %8lld %8lld %13lld %12lld\n", "TOTAL", |
| 83 | nTotalOpened, nTotalClosed, nTotalOpened - nTotalClosed, nTotalFailed); | 87 | nTotalOpened, nTotalClosed, nTotalOpened - nTotalClosed, |
| 84 | 88 | nTotalFailed, nTotalUnconn); | |
| 85 | // The live total above is what this program believes it is holding. Compare | 89 | |
| 86 | // it against what the operating system attributes to the process: on | 90 | // The live total is what this program believes it is holding. Comparing it |
| 87 | // Windows, `Get-NetTCPConnection | ? OwningProcess -eq <pid>`. A gap there | 91 | // against `Get-NetTCPConnection | ? OwningProcess -eq <pid>` needs the note |
| 88 | // is the leak, and the codes below say what the failed closes hit -- 10038 | 92 | // in sockcount.h first: that table lists a wildcard-bound socket twice, so |
| 89 | // (WSAENOTSOCK) means the handle was already gone and this close was a | 93 | // a raw row count always looks larger than this. Count orphans, not rows. |
| 90 | // second one, which is a different bug from a descriptor that stayed. | 94 | // |
| 95 | // "live unconn" is a getpeername on every tagged socket. Anything above 1 | ||
| 96 | // -- the listening socket -- means this program is holding a socket it | ||
| 97 | // never connected, which is the shape the leak hunt was looking for and | ||
| 98 | // never found. | ||
| 91 | if (nTotalFailed > 0) | 99 | if (nTotalFailed > 0) |
| 92 | { | 100 | { |
| 93 | str += " close failures by code\n"; | 101 | str += " close failures by code\n"; |
diff --git a/src/sockcount.h b/src/sockcount.h index a42abb9..ef99414 100644 --- a/src/sockcount.h +++ b/src/sockcount.h | |||
| @@ -4,15 +4,35 @@ | |||
| 4 | // Socket accounting -- which part of the program is holding the sockets. | 4 | // Socket accounting -- which part of the program is holding the sockets. |
| 5 | // | 5 | // |
| 6 | // A node on a 32-core machine was found with 1502 sockets in Windows' "bound" | 6 | // A node on a 32-core machine was found with 1502 sockets in Windows' "bound" |
| 7 | // state: created, never connected, never closed, each holding a distinct | 7 | // state, each apparently holding a distinct ephemeral port. Reading the code |
| 8 | // ephemeral port. That is 9% of the machine's whole dynamic port range gone in | 8 | // did not find it: every connect() failure path in this tree closes its socket. |
| 9 | // nineteen hours, and when the range runs out nothing on that machine can open | 9 | // So count instead -- tag each socket where it is created, untag it where it is |
| 10 | // an outbound connection -- not just this program. | 10 | // closed, report the difference per site. |
| 11 | // | 11 | // |
| 12 | // Reading the code did not find it. Every connect() failure path in this tree | 12 | // The counting is what showed the counting was wrong. Windows lists a socket |
| 13 | // closes its socket. So count instead: tag each socket where it is created, | 13 | // bound to the wildcard address TWICE in MSFT_NetTCPConnection: once for the |
| 14 | // untag it where it is closed, and report the difference per site. Whichever | 14 | // binding (0.0.0.0:port, state "Bound") and once for the connection itself |
| 15 | // site's live count tracks the leak is the answer. | 15 | // (state "Established"). Measured: 35 of a node's 38 "Bound" rows had the same |
| 16 | // local port as one of its established connections. The bound count therefore | ||
| 17 | // rises because the node is talking to more peers, not because it is leaking, | ||
| 18 | // which is why a fix that plainly worked looked like it had done nothing. | ||
| 19 | // | ||
| 20 | // The honest metric is the ORPHAN: a "Bound" row whose port appears nowhere | ||
| 21 | // else in that process's table. | ||
| 22 | // | ||
| 23 | // $b = $c | ? State -eq 'Bound' | ||
| 24 | // $rest = ($c | ? State -ne 'Bound').LocalPort | ||
| 25 | // ($b | ? { $rest -notcontains $_.LocalPort }).Count | ||
| 26 | // | ||
| 27 | // By that metric, measured the same evening on three machines: a node on 1.2.10 | ||
| 28 | // with 26h of uptime had 1262 orphans and was still taking on 30-90 an hour; a | ||
| 29 | // node on 1.2.11 with 4h40m had 3; a node on 1.2.12 had 3, all from its first | ||
| 30 | // half minute. At the 1.2.10 rate the 1.2.11 node would have had ~230. | ||
| 31 | // | ||
| 32 | // So the leak was real and 1.2.11 ended it -- most likely the 60-second reaper | ||
| 33 | // for undeliverable nodes, which was written for a different problem. The | ||
| 34 | // counters below stay: they are how the question got answered, and they are how | ||
| 35 | // it would be answered again. | ||
| 16 | // | 36 | // |
| 17 | // Header-only on purpose. btfrv.cpp is compiled a second time, on its own, into | 37 | // Header-only on purpose. btfrv.cpp is compiled a second time, on its own, into |
| 18 | // the standalone rendezvous relay -- a separate tree that does not link net.cpp. | 38 | // the standalone rendezvous relay -- a separate tree that does not link net.cpp. |
| @@ -53,12 +73,11 @@ struct BtfSockAccount | |||
| 53 | long long nOpened[SOCK_SITES]; | 73 | long long nOpened[SOCK_SITES]; |
| 54 | long long nClosed[SOCK_SITES]; | 74 | long long nClosed[SOCK_SITES]; |
| 55 | long long nCloseFailed[SOCK_SITES]; | 75 | long long nCloseFailed[SOCK_SITES]; |
| 56 | // Why the close failed, counted per code per site. Measured on a node with | 76 | // Why the close failed, counted per code per site. Kept because nothing in |
| 57 | // 3h51m of uptime: the program believed it held 58 sockets and Windows | 77 | // this codebase had ever looked at what closesocket returns, and a close |
| 58 | // attributed 112 to the process, 47 of them bound to an ephemeral port and | 78 | // that fails leaves the descriptor and its port in place. It is not the |
| 59 | // never connected -- against 55 closes that had returned an error. The | 79 | // leak -- see the note at the top of this file -- but it is the one number |
| 60 | // shapes match, and the error code is the piece that says whether the | 80 | // that would say so if it ever became the leak. |
| 61 | // descriptor is still there or was already gone. | ||
| 62 | std::map<int, long long> mapCloseErr[SOCK_SITES]; | 81 | std::map<int, long long> mapCloseErr[SOCK_SITES]; |
| 63 | long long nClosedUntagged; | 82 | long long nClosedUntagged; |
| 64 | BtfSockAccount() : nClosedUntagged(0) | 83 | BtfSockAccount() : nClosedUntagged(0) |
| @@ -145,6 +164,33 @@ inline void BtfSockSnapshot(long long* pOpened, long long* pClosed, long long* p | |||
| 145 | *pUntagged = a.nClosedUntagged; | 164 | *pUntagged = a.nClosedUntagged; |
| 146 | } | 165 | } |
| 147 | 166 | ||
| 167 | // Of the sockets still tagged live, how many have no peer on the other end. | ||
| 168 | // | ||
| 169 | // This is the check that ended the socket hunt, by answering it with a zero. | ||
| 170 | // The suspicion was that some site created sockets, never connected them and | ||
| 171 | // never let go. Run against a live node it reports 1 -- the listening socket, | ||
| 172 | // which has no peer by definition. Every other tagged socket is connected. | ||
| 173 | // | ||
| 174 | // See the note at the top of this file for what the pile actually was. | ||
| 175 | inline void BtfSockLiveUnconnected(long long* pUnconnected) | ||
| 176 | { | ||
| 177 | for (int i = 0; i < SOCK_SITES; i++) | ||
| 178 | pUnconnected[i] = 0; | ||
| 179 | BtfSockAccount& a = BtfSockAccounting(); | ||
| 180 | std::lock_guard<std::mutex> lock(a.mtx); | ||
| 181 | for (std::map<SOCKET, int>::const_iterator mi = a.mapSite.begin(); mi != a.mapSite.end(); ++mi) | ||
| 182 | { | ||
| 183 | struct sockaddr_storage ss; | ||
| 184 | #ifdef _WIN32 | ||
| 185 | int len = sizeof(ss); | ||
| 186 | #else | ||
| 187 | socklen_t len = sizeof(ss); | ||
| 188 | #endif | ||
| 189 | if (getpeername(mi->first, (struct sockaddr*)&ss, &len) != 0) | ||
| 190 | pUnconnected[mi->second]++; | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 148 | // The close-failure codes for one site, newest counts included. Separate call | 194 | // The close-failure codes for one site, newest counts included. Separate call |
| 149 | // because the table above is fixed-width and this is a list of unknown length. | 195 | // because the table above is fixed-width and this is a list of unknown length. |
| 150 | inline void BtfSockCloseErrors(int nSite, std::map<int, long long>& mapOut) | 196 | inline void BtfSockCloseErrors(int nSite, std::map<int, long long>& mapOut) |