Skip to content

getRequestListener: uncaught socket.destroySoon is not a function in forceClose when the socket is mocked (e.g. fastify.inject via MCP SDK) #374

Description

@MarketingMaatwerk

What happened

getRequestListener's request-drain logic force-closes the socket after DRAIN_TIMEOUT_MS when the incoming body was not fully read. forceClose assumes the socket is a real net.Socket:

const forceClose = () => {
  cleanup();
  const socket = incoming.socket;
  if (socket && !socket.destroyed) {
    socket.destroySoon();
  }
};

When incoming is not a real http.IncomingMessage (for example a mocked request), socket.destroyed is undefined (so the guard passes) and socket.destroySoon does not exist, producing an uncaught exception on a timer that callers cannot catch:

TypeError: socket.destroySoon is not a function
 ❯ Timeout.forceClose node_modules/@hono/node-server/dist/index.mjs:390:14
 ❯ listOnTimeout node:internal/timers:585:17
 ❯ processTimers node:internal/timers:521:7

How we hit this

The MCP TypeScript SDK (@modelcontextprotocol/sdk, StreamableHTTPServerTransport) uses getRequestListener to bridge Node req/res to fetch Request/Response. We mount that transport inside a Fastify route and integration-test it with fastify.inject() (light-my-request). light-my-request's MockSocket is a plain EventEmitter with neither destroyed nor destroySoon, so every injected MCP request schedules a 500 ms timer that later crashes the (vitest) worker with the unhandled TypeError above. Because it fires on a timer, it is timing-dependent and shows up as flaky "Unhandled Errors" in otherwise green test runs.

Environment

  • @hono/node-server 1.19.14 (via @modelcontextprotocol/sdk 1.29.0)
  • Node v22.23.0
  • light-my-request 6.6.0 (fastify inject)

Suggested fix

Make forceClose defensive about non-standard sockets:

const forceClose = () => {
  cleanup();
  const socket = incoming.socket;
  if (socket && !socket.destroyed) {
    if (typeof socket.destroySoon === 'function') {
      socket.destroySoon();
    } else {
      socket.destroy?.();
    }
  }
};

That keeps the drain behaviour for real sockets and turns the mocked-socket case into a no-op instead of an uncatchable crash. Happy to send a PR if that direction works for you.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions