node:http: send the writes of one task on an upgraded socket together

SO
25d ago
+818-25

What does this PR do?

The socket that a node:http server hands to an 'upgrade' or 'connect' listener made one send() per socket.write(). A server that writes a run of small frames in one task (a WebSocket library that broadcasts, a proxy that forwards the messages it parsed out of one read) spent most of its time in the kernel.

Now:

  • The first write of a task goes to the kernel inside the call, as before.
  • The Buffer writes of at most 16 KB without a callback that follow it, until the task's microtasks have run, are appended to the connection's uWS backpressure buffer. One send() takes them from the deferred task queue, the queue that already flushes res.write(), node:http2 frames and file sinks.
  • That buffer is the one every other writer of the connection already sends ahead of its own bytes (the 101 of ws.handleUpgrade(), what req.destroy() makes the unused response write), so the order on the wire holds without those writers knowing about the batch.
  • end(), destroy(), destroySoon(), resetAndDestroy(), any write that is not held back (one with a callback, a larger one, a string) and a write that would take the batch past 64 KB send what is held first.
  • What the kernel refuses of a batch leaves the uWS buffer and goes back to the stream as an ordinary write that waits for the drain.
  • Nothing is held while the kernel refuses bytes, before the connection is in tunnel mode (an Upgrade request whose body still arrives), on a CONNECT that was pipelined behind other requests, or on a socket that was not handed off.
  • process.exit() already ran the deferred tasks. A signal that the process sends itself with no handler for it, process.abort() and process.execve() now run them too, so what was written before them still reaches the peer. A signal that does nothing to the process (set to be ignored, like SIGPIPE, ignored by default, like SIGWINCH, or a number that kill() refuses) does not.

The uWS cork buffer was not an option: a loop has two cork slots, and a broadcast writes to every client's socket in the same task.

What stays the same

  • The bytes and their order, the return value of write(), the order and the tick of the write callbacks, 'finish', the handle's bytesWritten, half-open behaviour after end().
  • What a completion signal means. A write callback, 'finish', the callback of end() and a writableLength of 0 in a later task still come after the kernel has the bytes, also when it takes them a few at a time, so a destroy() from any of them loses nothing.
  • A task that makes one write per socket makes the same syscalls at the same time.
  • JSNodeHTTPServerSocket keeps its size (two bits in an existing bit-field). The buffer is freed when it drains.

What changes

Node tries every write inside the call (StreamBase::Write). Here the second and later callback-less writes of a task reach the kernel after the task's microtasks:

  • A peer sees them later when the task keeps the thread busy after writing (execSync, Atomics.wait, a long computation, a long chain of microtasks). res.write() on the same server and ServerWebSocket.send() inside a handler already behave this way.
  • Whether the kernel refuses them is known at the end of the task. Until then write() returns true and writableLength does not count them (at most 64 KB).
  • They are lost when something other than the thread that wrote them ends the process before the task is over: a signal from another process, a crash, or process.abort() / process.kill() on another thread, which runs the deferred tasks of its own thread only.

Numbers

Linux x64, release builds of main (408c9374b2) and of this branch, runs interleaved, medians. main is listed twice to show the noise.

bench/snippets/http-upgrade-socket-write.mjs, 7 runs per build (a round ends when the client has read every byte):

mainmainbranch
2 x write(262144 B)84.18 µs83.56 µs83.39 µs1.01x
16 x write(49152 B)141.11 µs138.82 µs140.97 µs1.00x
16 x write(32768 B)94.10 µs92.91 µs94.23 µs1.00x
16 x write(16384 B)64.64 µs63.66 µs58.39 µs1.11x
16 x write(1024 B)38.18 µs38.16 µs10.58 µs3.61x
1 x write(128 B)4.93 µs4.91 µs4.96 µs0.99x
2 x write(128 B)6.95 µs6.93 µs6.94 µs1.00x
16 x write(128 B)35.13 µs35.11 µs8.11 µs4.33x
64 x write(128 B)133.50 µs133.71 µs10.99 µs12.15x
256 x write(128 B)528.99 µs527.75 µs21.48 µs24.63x
64 x write(128 B, callback)136.88 µs136.54 µs137.17 µs1.00x

An application: a chat server on the ws package (its own JavaScript, not Bun's built-in replacement) over node:http, 8 clients, every message broadcast to all of them, 2,568 frames out per round, 120 rounds per process, 8 processes per build. On main the server's main thread was 96% busy, 62% of that in the kernel.

mainmainbranch
first round22.5 ms22.3 ms17.6 ms
mean of the 4 slowest rounds16.8 ms16.5 ms9.4 ms
mean round12.7 ms12.7 ms5.8 ms
sendto per round2,585273

How did you verify your code works?

  • test/js/node/http/node-http-connect.test.ts, several writes to a handed-off socket in one task: 30 tests. the first one is sent in the call, the others in one piece after the task fails on main: its client is a thread that reads at two fixed points, so what each read returns does not depend on timing. Three more cases of it send the process a signal that does nothing to it (ignored by default, set to be ignored, refused by kill()) between the writes and the first read. The other 26 pin what must not change and pass on main too: order across end(), destroy(), destroySoon(), an empty write with a callback, microtasks and ticks (http and https), more than 64 KB, callback order, backpressure and 'drain', ws.handleUpgrade() before and after, req.destroy(), a pipelined CONNECT behind a response in flight, and seven ways to end the process (not on Windows, where the end of a process resets its connections and the client gets ECONNRESET).
  • test/js/node/http/node-http-syscall-fault.test.ts (fault injection, so debug/ASAN builds): a batch under 7-byte sends followed by each completion signal (end() in the same or a later task, destroy() on 'finish', in the callback of end(), in the callback of a later write or of an empty one, once writableLength is 0 with and without an end() before it, after the client's FIN), a write that cork() collects until a later task, and an Upgrade request whose body still arrives. They pass on a debug build of main as well, which is the point: same bytes, same events.
  • Debug + ASAN build: those two files (with BUN_JSC_validateExceptionChecks=1), node-http-with-ws, node-http-backpressure, node-http-server-close-drain, node-http-req-socket-pause, node-http-server-timeouts, test/js/first_party/ws, four regression tests that use tunnels, process.test.js, cpu-prof.test.ts, and 40 of Node's test-http(s)-{upgrade,connect,pipeline}* and test-process-{execve,kill,abort}* files.
  • 30 scripted ways to close or write on a handed-off connection after several writes (upgrade, connect, upgrade with a body × req.destroy(), resetAndDestroy(), destroy(err), destroySoon(), end(), server.close() + closeAllConnections(), an emitted 'close', pause()/resume(), cork()/uncork()) give byte-identical output on main and on the branch.