| … | … | @@ -33,6 +33,18 @@ export class UdsServer { |
| 33 | 33 | this._server = null |
| 34 | 34 | this._connections = new Set() |
| 35 | 35 | this._listening = false |
| 36 | |
| 37 | // Idle-connection reaper. `allowHalfOpen:false` + the end/error handlers |
| 38 | // below clean up when a client CLOSES or EXITS. But a caller that opens a |
| 39 | // connection and then neither closes it nor exits (a long-lived process |
| 40 | // that forgets Client.close()) leaves the accepted socket ESTAB forever — |
| 41 | // the OS never delivers an end/close the server can react to. Unbounded, |
| 42 | // this grew pgh-sidecar to ~348k ESTAB unix sockets / ~22GB (2026-07-05). |
| 43 | // Sweep and destroy connections idle past _idleTimeout with no request in |
| 44 | // flight. Request/response callers reset the timer on every frame, so only |
| 45 | // genuinely dead connections are reaped. Set idleTimeout:0 to disable. |
| 46 | this._idleTimeout = opts.idleTimeout === undefined ? 120000 : opts.idleTimeout |
| 47 | this._sweepTimer = null |
| 36 | 48 | } |
| 37 | 49 | |
| 38 | 50 | get path () { return this._path } |
| … | … | @@ -75,6 +87,25 @@ export class UdsServer { |
| 75 | 87 | }) |
| 76 | 88 | |
| 77 | 89 | this._listening = true |
| 90 | |
| 91 | if (this._idleTimeout > 0) { |
| 92 | const sweepEvery = Math.max(1000, Math.min(30000, Math.floor(this._idleTimeout / 2))) |
| 93 | this._sweepTimer = setInterval(() => this._reapIdle(), sweepEvery) |
| 94 | // Don't let the reaper keep the process alive on its own. |
| 95 | if (this._sweepTimer && typeof this._sweepTimer.unref === 'function') this._sweepTimer.unref() |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // Destroy connections idle past _idleTimeout with no request in flight. |
| 100 | // destroy() fires 'close', which removes the socket from _connections. |
| 101 | _reapIdle () { |
| 102 | const now = Date.now() |
| 103 | for (const sock of this._connections) { |
| 104 | if ((sock._inflight || 0) > 0) continue |
| 105 | if (now - (sock._lastActive || 0) > this._idleTimeout) { |
| 106 | try { sock.destroy() } catch {} |
| 107 | } |
| 108 | } |
| 78 | 109 | } |
| 79 | 110 | |
| 80 | 111 | _probe () { |
| … | … | @@ -93,6 +124,8 @@ export class UdsServer { |
| 93 | 124 | |
| 94 | 125 | _onConnection (sock) { |
| 95 | 126 | this._connections.add(sock) |
| 127 | sock._lastActive = Date.now() |
| 128 | sock._inflight = 0 |
| 96 | 129 | sock.on('close', () => this._connections.delete(sock)) |
| 97 | 130 | |
| 98 | 131 | // Peer closed their write side (client finished + disconnected). bare-net |
| … | … | @@ -104,6 +137,7 @@ export class UdsServer { |
| 104 | 137 | const decoder = new FrameDecoder((req) => this._handleRequest(sock, req)) |
| 105 | 138 | |
| 106 | 139 | sock.on('data', (chunk) => { |
| 140 | sock._lastActive = Date.now() |
| 107 | 141 | try { |
| 108 | 142 | decoder.push(chunk) |
| 109 | 143 | } catch (err) { |
| … | … | @@ -138,6 +172,9 @@ export class UdsServer { |
| 138 | 172 | identity: { type: 'local', userId: process.env.USER || null, trust: 'local' } |
| 139 | 173 | } |
| 140 | 174 | |
| 175 | // Mark in-flight so the idle reaper never destroys a socket mid-dispatch, |
| 176 | // even if the handler runs longer than _idleTimeout. |
| 177 | sock._inflight = (sock._inflight || 0) + 1 |
| 141 | 178 | try { |
| 142 | 179 | const result = await this._dispatch(req.method, req.params, ctx) |
| 143 | 180 | try { |
| … | … | @@ -147,11 +184,15 @@ export class UdsServer { |
| 147 | 184 | try { |
| 148 | 185 | sock.write(encodeFrame({ id, error: err.message || String(err) })) |
| 149 | 186 | } catch {} |
| 187 | } finally { |
| 188 | sock._inflight = Math.max(0, (sock._inflight || 1) - 1) |
| 189 | sock._lastActive = Date.now() |
| 150 | 190 | } |
| 151 | 191 | } |
| 152 | 192 | |
| 153 | 193 | async close () { |
| 154 | 194 | if (!this._server) return |
| 195 | if (this._sweepTimer) { clearInterval(this._sweepTimer); this._sweepTimer = null } |
| 155 | 196 | for (const sock of this._connections) { |
| 156 | 197 | try { sock.destroy() } catch {} |
| 157 | 198 | } |