fix(runtime): keep the native bridge's idle connections open

Merged
K-
13h ago
+118-0

Summary

The native bridge hands Node.js handlers their requests over a private loopback connection. A request could fail with read ECONNRESET when it was sent about 6 s after the previous one, and the runtime then answered it with a 500.

The cause is a keep-alive race between the two ends of that connection:

  • The bridge's Agent keeps a free socket in its pool until the socket closes.
  • Node.js's HTTP server closes an idle keep-alive socket a second after its keepAliveTimeout, which is 6 s after its last answer by default.
  • When the Agent sends a call on that socket just as the server closes it, the call fails with read ECONNRESET.

Both ends belong to this process, and the Agent already bounds its free sockets (maxFreeSockets). So the bridge's server no longer closes idle sockets: its keepAliveTimeout is now 0.

Closing the bridge still ends every connection. server.close() closes the connections that are idle when it is called. A connection that becomes idle later used to close by the keep-alive timeout. With that timeout off, such a connection is now ended as soon as both its answer has finished and its request has been read to the end, in either order. That covers a call still answering when the bridge began to close, and an answer that finished before its upload was read. The connection is ended rather than destroyed, so an answer still in the socket's buffer reaches the caller.

Found in deploy-tests on node (run 41429173463, shard 48/64). In vary-params-base-dynamic, the revalidation request answered 500, and the server logged next-runtime: request failed Error: read ECONNRESET (syscall: 'read').

Verification

  • Reproduced with node:http alone on Node.js 24.22.1, using the bridge's settings: a keep-alive Agent with LIFO scheduling, against a server with the default keep-alive timeout.
    • A second request sent 5,998 or 5,999 ms after the first response failed with read ECONNRESET on the reused socket.
    • From 6,000 ms on, the socket had already closed, and a new one was used.
  • With keepAliveTimeout = 0, the same rounds succeeded on the same socket at gaps of 5,998–6,002 ms, 7 s and 65 s.
  • A new bridge test checks that a call made 6.5 s after the previous one reuses the same socket object. It fails without this change, because the server has closed the socket by then.
  • A second test closes the bridge while a call is still answering. The answer arrives whole, and close() reports a complete drain within 2 s. With the timeout off and no ending on finish, close() waited until its deadline; with Node.js's default timeout, it waited about 6 s.
  • A third test answers a 256 KiB upload before reading it, closes the bridge, and then lets the upload be read. close() reports a complete drain. With a check on the answer alone, it timed out.
  • The runtime project passes in full (2,082 tests). Typecheck, lint, format and vocabulary pass.

Summary by cubic

Keeps the native bridge's idle connections open so a request about 6 s after the previous one no longer fails with a 500 read ECONNRESET.

  • Sets the server's keepAliveTimeout to 0, since Node.js's default closed idle keep-alive sockets a second after their 6 s timeout while the Agent kept reusing them.
  • Ends a connection whose answer finishes after the bridge begins closing, so close() no longer waits until its drain deadline; ending instead of destroying lets the buffered answer reach the caller.
  • Adds tests covering a call 6.5 s later reusing the same socket, and a connection closing as its answer finishes during shutdown.

Summary by CodeRabbit

  • Bug Fixes
    • Native bridge connections can remain reusable after longer idle periods, helping subsequent requests continue over the same connection.
    • Bridge shutdown can complete promptly when an in-flight response finishes, while preserving the response for the caller.
  • Tests
    • Added coverage for connection reuse after an idle period and for shutdown during an in-flight response.