Skip to content

node:net: make setKeepAlive() take interval, count and an options object - #47331

Draft
robobun wants to merge 5 commits into
mainfrom
robobun/6c4a7c87/net-setkeepalive-interval-count
Draft

robobun wants to merge 5 commits into
mainfrom
robobun/6c4a7c87/net-setkeepalive-interval-count

Conversation

@robobun

@robobun robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Draft until #46522 and #46528 merge: they rewrite the same lines.

Problem

  • net.Socket#setKeepAlive() ignores interval and count, and reads an options object as true. So setKeepAlive({ enable: false }) turns keep-alive on. Node.js added both in v26.4.0 (net: support TCP_KEEPINTVL and TCP_KEEPCNT in setKeepAlive nodejs/node#63825).
  • Cause: setKeepAlive (src/js/node/net.ts:3257) takes two arguments, and bsd_socket_keepalive (packages/bun-usockets/src/bsd.c:563) writes TCP_KEEPINTVL=1 and TCP_KEEPCNT=10.

Fix

  • setKeepAlive follows lib/net.js of v26.12.1: four arguments or an options object, kept in seconds and replayed after connect.
  • us_socket_keepalive_ex takes the interval and the count, on Windows too. Only node:net reaches it, through setKeepAliveEx. Bun.Socket#setKeepAlive, fetch, Postgres and MySQL issue the same setsockopt calls.
  • Verified: node-net-keepalive.test.ts reads the options with getsockopt (main fails 61 of 87). Node's test-net-keepalive-interval-count.js passes unmodified.
  • Self-reviewed: 12 concerns raised, 10 addressed. Notes names the other 2.

Background

  • TCP keep-alive: after TCP_KEEPIDLE idle seconds the kernel probes every TCP_KEEPINTVL seconds and closes the socket after TCP_KEEPCNT unanswered probes.
  • The _handle of a net.Socket is a Bun.Socket, whose setKeepAlive(enable, milliseconds) has no interval. node:net now keeps seconds and calls the private binding, which reverses the node:net half of Bun.Socket: make setKeepAlive honor documented milliseconds and fix setKeepAlive(true) returning false #34269.
  • Considered Bun.Socket#setKeepAlive arguments: nobody asked for that API. Considered net.ts only: interval would still write 1 s.

Downsides

  • setKeepAlive(object) now reads object.enable: any object used to turn keep-alive on. A BigInt interval now throws, as in Node.js.
  • A handle from new net.Socket({ handle }), or a function put over _handle.setKeepAlive, gets Node's seconds, not milliseconds.
  • Cost: +1 host function, +2,809 B text, +0.16% instructions to load node:net. Windows: 4 setsockopt calls per enable, was 2.
Notes

The rebase after #46522 and #46528. Both are open and both rewrite these lines.

  1. net, tls: consolidate the open node:net, node:tls, Bun.connect, Bun.listen and usockets pull requests #46522 folds the three replays into applySocketOptions(self, handle). That function then calls setHandleKeepAlive(self, handle). Its handle.setKeepAlive(true, self[kSetKeepAliveInitialDelay]) would give seconds to a method that takes milliseconds. The tests "before the connection exists" fail if that line stays.
  2. net, tls: consolidate the open node:net, node:tls, Bun.connect, Bun.listen and usockets pull requests #46522 makes server.keepAliveInitialDelay seconds. The two accept seeds then store it as it is, and keepAliveDelayOf() goes. Node's test-net-server-keepalive.js can then run unmodified: on this base its only failure is 1 !== 1000.
  3. http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests #46528 (with node:http: make setKeepAlive() and the keepAlive option act on server sockets #45006) gives node:http server sockets a handle. set_keep_alive_ex in src/uws_sys/socket.rs then needs the UwsHttp arm that set_keep_alive has there, and a node:http row in the test.

Kernel state (linux x64, read from outside the process with pidfd_getfd and getsockopt). Cells are SO_KEEPALIVE / TCP_KEEPIDLE / TCP_KEEPINTVL / TCP_KEEPCNT. 7200 / 75 / 9 are the defaults of the kernel.

call on a connected socket Node.js v26.12.1 main this PR
setKeepAlive({ enable: false }) 0 / 7200 / 75 / 9 1 / 7200 / 75 / 9 0 / 7200 / 75 / 9
setKeepAlive({ enable: true, initialDelay: 5000, interval: 3000, count: 7 }) 1 / 5 / 3 / 7 1 / 7200 / 75 / 9 1 / 5 / 3 / 7
setKeepAlive(true, 5000, 3000, 7) 1 / 5 / 3 / 7 1 / 5 / 1 / 10 1 / 5 / 3 / 7
setKeepAlive(true, 5000) 1 / 5 / 1 / 10 1 / 5 / 1 / 10 1 / 5 / 1 / 10
setKeepAlive(true, 5000, 0, 7) 1 / 7200 / 75 / 9 1 / 5 / 1 / 10 1 / 7200 / 75 / 9
end(), then setKeepAlive(true, 5000, 3000, 7) 1 / 5 / 3 / 7 0 / 7200 / 75 / 9 1 / 5 / 3 / 7

55 of 59 probed calls equal Node.js v26.12.1 (main: 16).

The 4 calls that still differ. setKeepAlive(true), (true, 0), (true, 500) and (true, 5000, 0, 7), when the call comes after connect() and before the connection exists. Node.js ends at 1 / 60 / 1 / 10: libuv writes 60 / 1 / 10 when it opens a socket whose handle has the keep-alive flag (deps/uv/src/unix/stream.c:417), and Node's own call writes nothing for these values. Bun leaves 7200 / 75 / 9. Main gives the same for the first three and 1 / 5 / 1 / 10 for the fourth. The same four calls before connect() equal Node.js. The test has these calls as test.todo, and one more for a tls.TLSSocket that wraps a socket which still connects.

Invalid values follow the implementation of Node.js, not its documentation.

  • A zero in the delay, the interval or the count (after the division by 1000) writes none of the three: libuv returns UV_EINVAL first. So does null, NaN and Infinity as interval.
  • A count that is not a uint32 (negative, a fraction, a string, -0) becomes 10. A negative interval becomes 1 s. A string interval is a number after the division.
  • Node.js and this PR throw for the same 16 of 23 probed invalid calls. Main throws for 4. The 12 new ones are a BigInt or a Symbol as interval, a conversion that throws, and options whose getter throws or whose initialDelay is a BigInt or a Symbol.

A function over _handle.setKeepAlive. The method of a Bun socket takes milliseconds, so node:net does not call it. A function that replaces it gets Node's call first, then the socket gets the values through the private binding. So a function that passes the call on to the method it replaced still ends with the right values. Three cases differ from Node.js: a function that swallows the call does not stop the write, a function that passes on a zero interval leaves what the method wrote, and one that passes on a negative delay throws.

Measurements (release builds of main at bf80135 and of this PR on that commit, linux x64).

  • size: text 138,012,946 to 138,015,755 B (+2,809), data and bss equal. Keep-alive symbols (nm -S): 1,408 to 2,671 B.
  • Host functions: +1 (setKeepAliveEx), feature-table names: +1, public methods changed: 0.
  • Instructions (user space, perf_event_open, 10 interleaved runs, BUN_JSC_useConcurrentJIT=0, median and range):
    • a call that changes nothing, setKeepAlive(true, 1000): 32.0 (31.7 to 32.0) to 6.0 (5.7 to 6.1) for each call
    • a call that changes the delay: 329.6 (326.7 to 330.5) to 313.3 (311.2 to 314.3)
    • new net.Socket(): 1,923 (1,912 to 1,933) to 1,924 (1,913 to 1,941), inside the range
    • require("node:net"): 22,823,880 (22,812,495 to 22,842,511) to 22,860,082 (22,851,266 to 22,872,621) for the process
  • Objects allocated by 100,000 calls that change nothing: 0. Own symbol keys of a dialled socket after its first setKeepAlive(true, 5000): 18 to 20, as in Node.js.
  • setsockopt calls, main to this PR (Node.js v26.12.1), counted with an LD_PRELOAD logger:
calls main this PR Node.js
(true, 5000) 4 4 4
(true, 5000, 3000, 7) 4 4 4
(true, 5000, 0, 7) 4 1 1
20 identical calls 4 4 4
(true, 5000, 3000, 7) then (true, 5000) 4 8 8
1,000 calls with delays 5000 to 5999 4,000 4 4
20 requests through a keep-alive http.Agent 4 4 4
  • fetch (5 requests) and Bun.connect (setKeepAlive(true, 4000), (true), (true, 0), (false)): the setsockopt logs of main and of this PR have 0 differing lines.
  • Windows, from the source: the two-value entry has 2 setsockopt call sites as before, the four-value entry has 4.

Tests.

  • test/js/node/net/node-net-keepalive.test.ts: release build 87 pass, 5 todo. Main: 61 fail. Debug build with ASAN: 86 pass, 1 skip (the cluster test starts two processes).
  • test/js/node/net/node-net-keepalive-handle.test.ts uses node:test. Its last test runs the file under Node.js when that is v26.4.0 or later. Node.js v26.12.1: 3 pass. Main: 3 fail.
  • test-net-keepalive-interval-count.js and test-net-connect-keepalive.js are from Node.js v26.12.1, unmodified. Both fail on main. test: prune stale entries from expectations.txt #36780 removed the second one.
  • The tests are in new files. node-net.test.ts is in test/flaky-tests.txt, where a pass on a retry is not reported as new, and the Windows and macOS arms of this change ran nowhere before CI.
  • Also run on the release build: node-net.test.ts (the same 2 failures as main on this machine), server.spec.ts, node-net-server.test.ts, node-net-allowHalfOpen.test.js, fetch-tcp-keepalive.test.ts, socket.test.ts -t setKeepAlive, visible-surface.test.ts, members-of-prototype-objects-for-any-receiver.test.ts, and the 6 older keep-alive files of test/js/node/test/parallel.
  • bun run rust:check-all for x86_64-pc-windows-msvc and aarch64-apple-darwin: ok. The C of the Windows arm was not compiled here.
  • cargo test -p bun_aot: 2,893 and 87 pass. It has the type check that bun build --compile --aot runs on the modules of Bun.
  • test/aot/engine-tests/pipeline/gc/custom-accessor-reached-computed-key.ir: the lines that spell out a set of built-ins now accept either set. The set depends on the ids of the built-ins, and setKeepAliveEx gives each entry behind it another id. Replace picohttpparser with safe Rust #47067 has the same change.

Left as it is.

Self-review. 12 concerns. Two are done in part only.

Other open PRs. #47138 adds a sentence about this gap to docs/runtime/nodejs-compat.mdx. If it merges first, this PR removes the sentence. If this PR merges first, #47138 drops it. #46789 retypes the public setKeepAlive binding, and #45531 adds a neighbour in bsd.c.


no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/net/node-net-keepalive.test.ts

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

How this was reproduced (linux x64)

A script connects a net.Socket to a local server and calls setKeepAlive. A second process reads the options of that socket with pidfd_getfd and getsockopt.

call Node.js v26.12.1 main
setKeepAlive({ enable: false }) SO_KEEPALIVE 0 SO_KEEPALIVE 1
setKeepAlive(true, 5000, 3000, 7) TCP_KEEPINTVL 3, TCP_KEEPCNT 7 TCP_KEEPINTVL 1, TCP_KEEPCNT 10

test/js/node/net/node-net-keepalive.test.ts makes the same reads in the process, through bun:ffi.

This PR is a draft until #46522 and #46528 merge.

CI (commit 8c1b801, build 135206)

  • The new tests pass on every test lane: Linux glibc, Linux musl, Windows and macOS on x64 and aarch64, and the Linux ASAN lane. On macOS node-net-keepalive.test.ts has 80 pass and 5 todo.
  • Two jobs are red. This PR touches neither test.
    • test/aot/workers-within-address-space-limit.test.ts crashed on alpine 3.23 x64. Build 135199 of another pull request has the same crash.
    • test/aot/build-scaling/build-scaling-2.test.ts is over its memory budget on macOS aarch64: 2.54 to 2.68 times in four attempts, and 2.5 is the limit. Build 135281 of main has the same failure.

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:35 AM PT - Oct 29th, 2026

❌ @robobun, your commit 8c1b801 has 2 failures in Build #135206 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47331

That installs a local version of the PR into your bun-47331 executable, so you can run:

bun-47331 --bun

net.Socket.prototype.setKeepAlive follows Node.js v26.12.1: four
arguments or an options object, four stored values in seconds, and the
replay of all four after connect. A node:net-private binding gives them
to us_socket_keepalive_ex, which writes TCP_KEEPINTVL and TCP_KEEPCNT,
also for a socket that sent its FIN. A function put over the handle's
setKeepAlive() gets Node's call first. Bun.Socket#setKeepAlive and
us_socket_keepalive keep 1 second and 10 probes.
… kernel

node-net-keepalive.test.ts reads the socket options back with
getsockopt. node-net-keepalive-handle.test.ts also runs under Node.js.
test-net-keepalive-interval-count.js and test-net-connect-keepalive.js
are Node's, unmodified.
@robobun
robobun force-pushed the robobun/6c4a7c87/net-setkeepalive-interval-count branch from b6eb085 to 052a4e2 Compare October 29, 2026 11:00
The type check that `bun build --compile --aot` runs on the modules of
Bun did not narrow `setKeepAliveEx` after `??= $bindgen(...)`. The call
on the next line failed with TS2722 in
`compile::tests::every_module_of_bun_keeps_its_types`.

The cast gives the assignment a type, as the other lazy bindings of the
file have. The emitted JavaScript is the same.
pipeline/gc/custom-accessor-reached-computed-key.ir spells out the set
that `process.env[key]` can answer. The set depends on the ids of the
built-ins. A new function in the table gives each entry behind it
another id, and the set gains builtin(FunctionPrototype).

The test passes on main. It fails on a branch that adds a function to
the table, as this branch does with setKeepAliveEx. The set is not the
subject of the test, so its lines accept both sets. #47067 has the same
change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant