Skip to content

connectionId is scoped per-connection, not per-request — misroutes/drops later requests on a reused HTTP/1.1 keep-alive connection #16

Description

@Hermander741

Summary

setupMaster's workerId/connectionId are declared once per raw TCP connection (in the closure of httpServer.on("connection", (socket) => { let workerId, connectionId; ... })), not per HTTP request. Once the first request on a connection has a Content-Length or Transfer-Encoding header (mayHaveMultipleChunks), connectionId is set and never reset for the rest of that connection's lifetime — even after that request completes.

Any subsequent request sent over the same reused keep-alive connection then hits:

if (workerId && connectionId) {
  cluster.workers[workerId].send({ type: "sticky:http-chunk", data, connectionId }, sendCallback);
  return;
}

— treated as a continuation chunk of the first request, instead of being routed as a new sticky:connection. On the worker side, if the original socket has already been removed from the sockets map (e.g. because the first request's response already completed and the socket was closed), the later request's entire raw bytes — headers included, not just a body — are silently dropped (sockets.get(connectionId) returns undefined, nothing happens). The client hangs forever: no response, no error, no log output anywhere.

Every browser reuses HTTP/1.1 keep-alive connections by default, so this reproduces reliably against real browser traffic (Chrome) — a client sending a POST/PATCH/PUT with a body, followed by any other request on the same connection, is enough to trigger it. curl mostly avoids it in casual testing only because a fresh CLI invocation typically opens a fresh connection each time — it is not curl-specific, and reproduces the same way with a raw TCP connection carrying two pipelined requests.

Environment

  • @socket.io/sticky: 1.0.4 (latest on npm as of this report)
  • socket.io: 4.8.3
  • Node.js: v24.18.0
  • Confirmed via @socket.io/pm2-orchestrated PM2 cluster mode (2 workers) and independently reproduced with vanilla Node cluster + @socket.io/sticky (no PM2 involved at all), ruling out PM2 as a factor.

Minimal reproduction

// repro.js
const cluster = require('cluster')
const http = require('http')
const { setupMaster, setupWorker } = require('@socket.io/sticky')
const { Server } = require('socket.io')

const PORT = 4001

if (cluster.isMaster) {
  cluster.setupMaster({ windowsHide: true })
  const httpServer = http.createServer()
  setupMaster(httpServer, { loadBalancingMethod: 'least-connection' })
  httpServer.listen(PORT, () => console.log(`listening on ${PORT}`))
  cluster.fork()
  cluster.fork()
} else {
  const httpServer = http.createServer((req, res) => {
    let body = ''
    req.on('data', c => { body += c })
    req.on('end', () => {
      console.log(`[worker ${process.pid}] ${req.method} ${req.url} body=${JSON.stringify(body)}`)
      res.writeHead(200, { 'Content-Type': 'application/json' })
      res.end(JSON.stringify({ ok: true, pid: process.pid, method: req.method }))
    })
  })
  const io = new Server(httpServer)
  setupWorker(io)
}

Steps:

  1. node repro.js
  2. Send a POST with a JSON body, then a second request (any method) reusing the same TCP connection — e.g. two curl requests piped down one raw socket, or via a real browser tab where Chrome's connection pool happens to reuse the socket. A real browser making a POST followed by any other request to the same origin is the easiest way to trigger this in practice — it reproduces reliably against actual application traffic, not just crafted pipelining.
  3. The second request hangs indefinitely if the first request's socket has already closed by the time the second request's bytes arrive as a sticky:http-chunksockets.get(connectionId) returns undefined in setupWorker, and the request is silently dropped with no error anywhere.

Expected behavior

Each HTTP request on a keep-alive connection should be routed as its own logical unit — workerId/connectionId (or equivalent per-request state) should reset once a request/response cycle completes, so a second request on a reused connection is treated as a fresh request rather than a phantom continuation of the first.

Actual behavior

The second (and any later) request on a connection where the first request carried a Content-Length/Transfer-Encoding header is misrouted as a sticky:http-chunk of the first request. If the first request's socket is no longer tracked, the later request is silently dropped — no response, no error, indefinite hang on the client.

Happy to provide the full diagnostic logs from a live-instrumented reproduction (added temporary console.error calls to index.js and captured the exact message sequence) if useful — omitted here for brevity but readily available.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions