OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web commits / 2d0ea87

0.3.90: a TLS socket reaped mid-handshake is never released — settle its open
favela-web was back at 20.3 GB on stortron with only ~1.2k fds open, and the two runs before died SIGTRAP after ~7.5 days: a weekly OOM. The 0.3.88 reaper closes the fd, but bare-tls (<= 3.1.11) settles only a pending destroy on inner close, never a pending open. streamx holds _destroy until _open calls back, so the wrapper never emits 'close', HTTPAgent's onremove never runs, and agent._sockets keeps every wrapper (~150 KB) for ever. ~239k timed-out handle probes in the last run account for the 20 GB. ReapingHTTPSAgent now settles the stuck open on inner close. Offline, 8000 timed-out fetches: 1232 MB and 8000 held before, 227 MB and 0 held after. The drill only asserted the peer saw a FIN; it now asserts the agent releases every socket object (red without this fix), and covers the reap-first ordering, where the reap now settles the caller with NETWORK_ERROR instead of leaving bare-fetch's promise pending for ever. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2d0ea8712f1b54d6cd2996e985c2d468fb685206
by plankatron <fleeky@gmail.com> · 2026-09-29T23:37:19.000Z

Changed files (3) +45 -7

lib/bounded-fetch.js +12 -0
……@@ -49,6 +49,18 @@ class ReapingHTTPSAgent extends https.Agent {
4949 // HTTPAgent.addRequest's ontimeout -> socket.destroy() can run.
5050 if (inner && typeof inner.on === 'function') {
5151 inner.on('timeout', () => socket.emit('timeout'))
52 // The second line, found 2026-09-29 when favela-web was back at 20.3 GB with only ~1.2k fds open.
53 // Destroying the wrapper mid-handshake closes the inner socket, but bare-tls (<= 3.1.11) settles
54 // only a pending DESTROY on inner close, never a pending OPEN. streamx will not run _destroy until
55 // _open calls back, so the wrapper never emits 'close', HTTPAgent's onremove never runs, and
56 // agent._sockets holds the wrapper (~150 KB: 64 KB read buffer, TLS state, fetch closures) for
57 // ever. Settle the open with an error so the destroy the agent asked for can finish.
58 inner.on('close', () => {
59 const cb = socket._pendingOpen
60 if (typeof cb !== 'function') return
61 socket._pendingOpen = null
62 cb(new Error('socket closed during TLS handshake'))
63 })
5264 }
5365 return socket
5466 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-web",
3 "version": "0.3.89",
3 "version": "0.3.90",
44 "description": "favela-web \u2014 the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.",
55 "type": "module",
66 "main": "bin/favela-web.js",
test/bounded-fetch.test.mjs +32 -6
……@@ -6,15 +6,20 @@
66// the INNER socket, and bare-tls never forwards 'timeout' upward — so HTTPAgent's
77// ontimeout -> socket.destroy() can never fire on HTTPS. Plain HTTP is unaffected (nothing wraps).
88//
9// Ground truth is the PEER, not the agent's bookkeeping: HTTPAgent.ontimeout deletes only from
10// _freeSockets, so `agent.sockets` still lists sockets it has destroyed. The blackhole server sees a FIN
11// when — and only when — the client socket is really gone, so that is what this asserts.
9// Ground truth for the FD is the PEER: the blackhole server sees a FIN when — and only when — the client
10// socket is really gone. But a closed fd is not a freed socket. 2026-09-29 stortron was back at 20.3 GB
11// with only ~1.2k fds open: bare-tls never settles a pending _open when destroyed mid-handshake, so the
12// wrapper never emits 'close', HTTPAgent's onremove never runs, and `agent.sockets` keeps every wrapper
13// (~150 KB each) for ever. So this ALSO asserts the agent lets go of them — (4).
1214//
1315// Asserts, against a listener that accepts and never replies:
1416// (1) RED — stock fetches never settle and the peer still sees every connection open;
1517// (2) GREEN — bounded fetches settle, and the peer sees every connection closed;
16// (3) the deadline is a SEPARATE, required mechanism: destroying the socket under bare-fetch does not
17// reject its promise, and AbortController does not exist in Bare to do it for us.
18// (3) the deadline is a SEPARATE, required mechanism: it is what settles a caller whose deadline is
19// SHORTER than the idle reap (handles.js: 5s vs 30s), and AbortController does not exist in Bare;
20// (4) the agent RELEASES every reaped socket object — the fd closing is not enough;
21// (5) when the idle reap fires FIRST (deadline > idle), the reap itself now settles the caller —
22// completing the stuck TLS open lets bare-fetch's request error out instead of hanging for ever.
1823// Run: `bare test/bounded-fetch.test.mjs`
1924import tcp from 'bare-tcp'
2025import bareFetch from 'bare-fetch'
……@@ -24,7 +29,8 @@ const assert = (cond, msg) => { if (!cond) throw new Error('FAIL: ' + msg); cons
2429const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
2530
2631const IDLE = 1500 // short so the test is quick; production default is 30s
27const DEADLINE = 3000
32const DEADLINE = 1000 // < IDLE: the production ordering on the leaking path (handles.js 5s vs 30s)
33const LONG_DEADLINE = 3000 // > IDLE: the reap fires first (callers passing timeout above 30s)
2834const N = 4
2935
3036// A blackhole: accept, then say nothing, for ever. `live` counts peers that have not hung up.
……@@ -68,6 +74,26 @@ assert(accepted === N * 2, `the ${N} bounded connections were actually opened (a
6874assert(stillLive === 0, `the peer saw every bounded connection CLOSE (still open=${stillLive})`)
6975assert(live === leakedForContrast, 'and the stock leak is still open for contrast — the fix is the agent, not timing')
7076
77// ---------- (4) the socket OBJECTS are freed, not just the fds ----------
78const held = [...boundedFetch.agents.https.sockets].length
79assert(held === 0, `the agent released every reaped socket object (still held=${held} of ${N})`)
80
81// ---------- (5) reap before deadline ----------
82const lateTimedOut = []
83const lateFetch = createBoundedFetch({ socketIdleMs: IDLE, deadlineMs: LONG_DEADLINE, onTimeout: (d) => lateTimedOut.push(d) })
84const t0 = Date.now()
85const late = await Promise.allSettled(Array.from({ length: N }, () => lateFetch(URL_)))
86const took = Date.now() - t0
87assert(late.every((r) => r.status === 'rejected'), 'reap-first: every fetch settles (rejected)')
88// bare-fetch wraps any socket failure as NETWORK_ERROR, so the reap shows up under that code, not the
89// message bounded-fetch passes to the stuck open.
90assert(late.every((r) => r.reason.code === 'NETWORK_ERROR' && !/deadline exceeded/.test(r.reason.message)), 'reap-first: the rejection is the reap (NETWORK_ERROR), not the deadline')
91assert(took < LONG_DEADLINE && lateTimedOut.length === 0, `reap-first: settled at the idle reap, before the deadline (${took}ms, deadlines fired=${lateTimedOut.length})`)
92await sleep(500)
93const lateHeld = [...lateFetch.agents.https.sockets].length
94assert(lateHeld === 0, `reap-first: the agent released every socket object (still held=${lateHeld} of ${N})`)
95assert(live === leakedForContrast, `reap-first: the peer saw every connection CLOSE (still open=${live - leakedForContrast})`)
96
7197for (const s of sockets) { try { s.destroy() } catch {} }
7298server.close()
7399console.log('\nbounded-fetch: all assertions passed')