OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc / lib / transports / uds.js

221 lines · javascript ref:
/**
 * 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 {}
    }
  }
}