| … | … | @@ -6,15 +6,20 @@ |
| 6 | 6 | // the INNER socket, and bare-tls never forwards 'timeout' upward — so HTTPAgent's |
| 7 | 7 | // ontimeout -> socket.destroy() can never fire on HTTPS. Plain HTTP is unaffected (nothing wraps). |
| 8 | 8 | // |
| 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). |
| 12 | 14 | // |
| 13 | 15 | // Asserts, against a listener that accepts and never replies: |
| 14 | 16 | // (1) RED — stock fetches never settle and the peer still sees every connection open; |
| 15 | 17 | // (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. |
| 18 | 23 | // Run: `bare test/bounded-fetch.test.mjs` |
| 19 | 24 | import tcp from 'bare-tcp' |
| 20 | 25 | import bareFetch from 'bare-fetch' |
| … | … | @@ -24,7 +29,8 @@ const assert = (cond, msg) => { if (!cond) throw new Error('FAIL: ' + msg); cons |
| 24 | 29 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) |
| 25 | 30 | |
| 26 | 31 | const IDLE = 1500 // short so the test is quick; production default is 30s |
| 27 | | const DEADLINE = 3000 |
| 32 | const DEADLINE = 1000 // < IDLE: the production ordering on the leaking path (handles.js 5s vs 30s) |
| 33 | const LONG_DEADLINE = 3000 // > IDLE: the reap fires first (callers passing timeout above 30s) |
| 28 | 34 | const N = 4 |
| 29 | 35 | |
| 30 | 36 | // 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 |
| 68 | 74 | assert(stillLive === 0, `the peer saw every bounded connection CLOSE (still open=${stillLive})`) |
| 69 | 75 | assert(live === leakedForContrast, 'and the stock leak is still open for contrast — the fix is the agent, not timing') |
| 70 | 76 | |
| 77 | // ---------- (4) the socket OBJECTS are freed, not just the fds ---------- |
| 78 | const held = [...boundedFetch.agents.https.sockets].length |
| 79 | assert(held === 0, `the agent released every reaped socket object (still held=${held} of ${N})`) |
| 80 | |
| 81 | // ---------- (5) reap before deadline ---------- |
| 82 | const lateTimedOut = [] |
| 83 | const lateFetch = createBoundedFetch({ socketIdleMs: IDLE, deadlineMs: LONG_DEADLINE, onTimeout: (d) => lateTimedOut.push(d) }) |
| 84 | const t0 = Date.now() |
| 85 | const late = await Promise.allSettled(Array.from({ length: N }, () => lateFetch(URL_))) |
| 86 | const took = Date.now() - t0 |
| 87 | assert(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. |
| 90 | assert(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') |
| 91 | assert(took < LONG_DEADLINE && lateTimedOut.length === 0, `reap-first: settled at the idle reap, before the deadline (${took}ms, deadlines fired=${lateTimedOut.length})`) |
| 92 | await sleep(500) |
| 93 | const lateHeld = [...lateFetch.agents.https.sockets].length |
| 94 | assert(lateHeld === 0, `reap-first: the agent released every socket object (still held=${lateHeld} of ${N})`) |
| 95 | assert(live === leakedForContrast, `reap-first: the peer saw every connection CLOSE (still open=${live - leakedForContrast})`) |
| 96 | |
| 71 | 97 | for (const s of sockets) { try { s.destroy() } catch {} } |
| 72 | 98 | server.close() |
| 73 | 99 | console.log('\nbounded-fetch: all assertions passed') |