OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc / test / uds-backpressure.test.js

85 lines · javascript ref:
// CHARACTERIZATION — does the UDS server bound its write buffering when a
// client pipelines many large-response requests without reading?
//
// The write path (_handleRequest) calls sock.write(encodeFrame(...)) and ignores
// the return value. Each response frame is capped at 16 MiB, but nothing bounds
// the SUM across many pipelined/unread responses. This test measures the
// server-side socket's buffered bytes (writableLength) after a non-reading client
// fires N large-response requests, to see whether buffering grows unbounded.
//
// This is a probe, not a pass/fail regression yet — it prints the numbers so we
// can decide whether a fix (serialized write queue awaiting 'drain') is warranted
// and whether bare-net exposes the hooks it would need.

import test from 'brittle'
import process from '#node:process'
import net from '#node:net'
import { mkdtempSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { UdsServer } from '../lib/transports/uds.js'
import { encodeFrame } from '../lib/frame.js'

function tmpSockPath () {
  return join(mkdtempSync(join(tmpdir(), 'hyper-svc-bp-')), 'svc.sock')
}
async function waitFor (pred, ms = 5000) {
  const t0 = Date.now()
  while (Date.now() - t0 < ms) { if (pred()) return true; await new Promise((r) => setTimeout(r, 20)) }
  return false
}

test('probe: server-side write buffering under a non-reading pipelining client', async (t) => {
  const RESP_SIZE = 256 * 1024 // 256 KiB per response
  const N = 100
  let dispatched = 0

  const server = new UdsServer({
    path: tmpSockPath(),
    dispatch: async () => { dispatched++; return { blob: 'x'.repeat(RESP_SIZE) } }
  })
  await server.listen()
  t.teardown(async () => { try { await server.close() } catch {} })

  // Client that NEVER reads its responses (pause the readable side). Its OS
  // receive buffer fills, then flow-control backpressures the server's writes.
  const client = net.createConnection(server.path)
  t.teardown(() => { try { client.destroy() } catch {} })
  await new Promise((resolve) => client.once('connect', resolve))
  if (typeof client.pause === 'function') client.pause()

  // Fire N pipelined requests in one burst.
  for (let i = 0; i < N; i++) {
    client.write(encodeFrame({ id: i, method: 'noop', params: {} }))
  }

  const rss0 = process.memoryUsage().rss

  // Give the server time to decode + dispatch + attempt to write responses.
  await waitFor(() => dispatched >= N, 6000)
  await new Promise((r) => setTimeout(r, 300)) // let writes settle into buffers

  const rss1 = process.memoryUsage().rss
  const rssGrowth = (rss1 - rss0) / 1024 / 1024
  const produced = N * RESP_SIZE / 1024 / 1024

  const [serverSock] = server._connections
  // bare-net doesn't expose writableLength; hunt for any internal buffered-bytes signal.
  const internalKeys = serverSock ? Object.keys(serverSock).filter(k => /buffer|writ|queue|length/i.test(k)) : []

  t.comment(`dispatched=${dispatched}/${N} (all fired from ONE decode burst → no per-request bound)`)
  t.comment(`total produced=${produced.toFixed(1)} MiB to a client that read nothing`)
  t.comment(`process RSS growth=${rssGrowth.toFixed(1)} MiB (unbounded ≈ tracks produced)`)
  t.comment(`serverSock internal buffer-ish keys: ${JSON.stringify(internalKeys)}`)

  // Characterization only (no gap-assert — that would become a false regression
  // once flow control lands). Observed 2026-07-02: RSS grew ~2x the produced
  // bytes with ZERO flow control — a non-reading/pipelining local client can
  // balloon server memory. Fix direction: a cooperative decode->dispatch->write
  // pump that bounds in-flight concurrency and gates on write()'s return + 'drain'
  // (a naive pause-on-drain is insufficient — all N requests dispatch from one
  // synchronous decode burst, as `dispatched=N/N` above shows). Low urgency:
  // real clients are well-behaved, and this is a local same-user UDS.
  t.ok(dispatched === N, 'probe completed — see comments for the buffering numbers')
})