/**
* UDS (Unix Domain Socket) transport for hyper-svc.
*
* Local-only RPC. Trusts the local user. CLIs on the same machine connect
* to ~/.local/share/<name>/svc.sock (or wherever the daemon was told to bind)
* and exchange length-prefixed JSON frames.
*
* Wire protocol per connection:
* client → server: { id, method, params }
* server → client: { id, result } OR { id, error }
*
* Multiple in-flight requests are matched by id. Server is fully async —
* a slow handler doesn't block other requests on the same connection.
*/
import process from '#node:process'
import { unlink, stat } from '#node:fs/promises'
import { encodeFrame, FrameDecoder } from '../frame.js'
import { udsBackend, isAbstract } from '../uds-backend.js'
export class UdsServer {
/**
* @param {object} opts
* @param {string} opts.path - filesystem path for the UNIX socket
* @param {(method: string, params: any, ctx: object) => Promise<any>} opts.dispatch - request handler
*/
constructor (opts = {}) {
if (!opts.path) throw new Error('UdsServer requires opts.path')
if (typeof opts.dispatch !== 'function') throw new Error('UdsServer requires opts.dispatch')
this._path = opts.path
this._dispatch = opts.dispatch
this._server = null
this._connections = new Set()
this._listening = false
// Idle-connection reaper. `allowHalfOpen:false` + the end/error handlers
// below clean up when a client CLOSES or EXITS. But a caller that opens a
// connection and then neither closes it nor exits (a long-lived process
// that forgets Client.close()) leaves the accepted socket ESTAB forever —
// the OS never delivers an end/close the server can react to. Unbounded,
// this grew pgh-sidecar to ~348k ESTAB unix sockets / ~22GB (2026-07-05).
// Sweep and destroy connections idle past _idleTimeout with no request in
// flight. Request/response callers reset the timer on every frame, so only
// genuinely dead connections are reaped. Set idleTimeout:0 to disable.
this._idleTimeout = opts.idleTimeout === undefined ? 120000 : opts.idleTimeout
this._sweepTimer = null
}
get path () { return this._path }
get listening () { return this._listening }
async listen () {
if (this._listening) return
// Select the transport: #node:net for pathname sockets (unchanged), or
// bare-abstract-socket for an '@'-prefixed abstract name. `name` is the
// bind name with the '@' stripped.
const { backend, name, abstract } = await udsBackend(this._path)
// Best-effort cleanup of a stale socket FILE (server crashed without
// unlinking). Abstract sockets have no filesystem entry (the kernel frees
// the name when the socket closes), so this applies to pathname sockets only.
if (!abstract) {
// ONLY remove if it's actually a socket — never touch regular files / dirs.
try {
const s = await stat(this._path)
if (s.isSocket()) {
// Try to connect first — if a daemon is already running, refuse to clobber.
const inUse = await this._probe()
if (inUse) throw new Error(`socket ${this._path} is already in use`)
await unlink(this._path)
}
} catch (err) {
if (err.code !== 'ENOENT' && err.message.indexOf('already in use') === -1) {
// stat failed for some reason other than missing — ignore, let listen() fail naturally
}
if (err.message && err.message.indexOf('already in use') !== -1) throw err
}
}
// allowHalfOpen:false is CRITICAL. bare-net defaults it to true (unlike
// Node), which means when a client disconnects the accepted socket never
// sees 'end' and never releases — the fd AND its 64KB read buffer leak.
// With false, peer disconnect surfaces as 'end', which _onConnection
// turns into a destroy() so the socket fully closes. See the fd-leak that
// grew pgh-sidecar to ~205k open sockets / 14GB RSS (2026-06-09).
// (bare-abstract-socket honours the same allowHalfOpen:false on accept.)
this._server = backend.createServer({ allowHalfOpen: false }, (sock) => this._onConnection(sock))
await new Promise((resolve, reject) => {
this._server.once('error', reject)
this._server.listen(name, () => {
this._server.removeListener('error', reject)
resolve()
})
})
this._listening = true
if (this._idleTimeout > 0) {
const sweepEvery = Math.max(1000, Math.min(30000, Math.floor(this._idleTimeout / 2)))
this._sweepTimer = setInterval(() => this._reapIdle(), sweepEvery)
// Don't let the reaper keep the process alive on its own.
if (this._sweepTimer && typeof this._sweepTimer.unref === 'function') this._sweepTimer.unref()
}
}
// Destroy connections idle past _idleTimeout with no request in flight.
// destroy() fires 'close', which removes the socket from _connections.
_reapIdle () {
const now = Date.now()
for (const sock of this._connections) {
if ((sock._inflight || 0) > 0) continue
if (now - (sock._lastActive || 0) > this._idleTimeout) {
try { sock.destroy() } catch {}
}
}
}
async _probe () {
const { backend, name } = await udsBackend(this._path)
return new Promise((resolve) => {
const sock = backend.createConnection(name)
sock.once('connect', () => {
sock.destroy()
resolve(true)
})
sock.once('error', () => {
sock.destroy()
resolve(false)
})
})
}
_onConnection (sock) {
this._connections.add(sock)
sock._lastActive = Date.now()
sock._inflight = 0
sock.on('close', () => this._connections.delete(sock))
// Peer closed their write side (client finished + disconnected). bare-net
// does NOT emit 'close' on its own here — we must destroy() to release the
// fd and drop the socket (and its read buffer) from _connections. Safe even
// mid-dispatch: a late _handleRequest write is already wrapped in try/catch.
sock.on('end', () => { try { sock.destroy() } catch {} })
const decoder = new FrameDecoder((req) => this._handleRequest(sock, req))
sock.on('data', (chunk) => {
sock._lastActive = Date.now()
try {
decoder.push(chunk)
} catch (err) {
// Bad frame — close the connection rather than try to recover
try { sock.destroy(err) } catch {}
}
})
// Abrupt disconnect (ECONNRESET, or the client process killed / timed out
// mid-request). bare-net does NOT emit 'close' on its own (see the 'end'
// note above) — and unlike a clean FIN there is no 'end' either — so we
// MUST destroy() here to release the fd + 64KB read buffer and drop the
// socket from _connections. Without this, every abrupt client disconnect
// leaks a half-open socket (the 0.1.3 'end'-only fix covered clean closes
// but not this path — the cause of the 6645-socket accumulation seen when
// a client hammers the UDS with timed-out/killed calls).
sock.on('error', () => { try { sock.destroy() } catch {} })
}
async _handleRequest (sock, req) {
const id = req && req.id
if (typeof id !== 'number' && typeof id !== 'string') {
// Malformed — best effort error response, but the client probably can't match it
try {
sock.write(encodeFrame({ id: null, error: 'request missing id' }))
} catch {}
return
}
const ctx = {
transport: 'uds',
identity: { type: 'local', userId: process.env.USER || null, trust: 'local' }
}
// Mark in-flight so the idle reaper never destroys a socket mid-dispatch,
// even if the handler runs longer than _idleTimeout.
sock._inflight = (sock._inflight || 0) + 1
try {
const result = await this._dispatch(req.method, req.params, ctx)
try {
sock.write(encodeFrame({ id, result }))
} catch {}
} catch (err) {
try {
sock.write(encodeFrame({ id, error: err.message || String(err) }))
} catch {}
} finally {
sock._inflight = Math.max(0, (sock._inflight || 1) - 1)
sock._lastActive = Date.now()
}
}
async close () {
if (!this._server) return
if (this._sweepTimer) { clearInterval(this._sweepTimer); this._sweepTimer = null }
for (const sock of this._connections) {
try { sock.destroy() } catch {}
}
this._connections.clear()
await new Promise((resolve) => this._server.close(() => resolve()))
this._server = null
this._listening = false
// Clean up the socket file (best-effort). Abstract sockets have no fs entry.
if (!isAbstract(this._path)) {
try { await unlink(this._path) } catch {}
}
}
}