Conversation
addContext() on a listening tls.Server only updated the live native listener. The entry never reached the server's own list, so the listener that a later close() + listen() creates did not get it: the name fell back to the default certificate, or to a stale context that an earlier addContext() registered before listen(). The server now records every entry, in call order, and also pushes it to the live listener when there is one. The list exists from construction, so a cluster worker's pending listen() sees an entry added before the primary answers.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review. Walkthrough
ChangesaddContext lifecycle
Suggested reviewers: Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to The TLS context lifecycle change has no identified actionable merge risk in the supplied evidence. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Status Reproduced on bun 1.4.3 canary and on a debug build of main, on Linux x64 and on Windows x64. const server = tls.createServer(agent1, s => s.end());
server.listen(0, "127.0.0.1"); // wait for 'listening'
server.addContext("a.example", agent2); // "a.example" gets agent2
server.close(); // wait for 'close'
server.listen(0, "127.0.0.1"); // "a.example" gets agent1 (node v26.3.0: agent2)
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked whether re-adding a hostname on a live listener or replaying the map on the next listen() could double-register or leak an SSL_CTX ref — Listener.add_server_name (src/runtime/socket/Listener.rs:798-799) removes the existing entry before adding, and close() tears down the old SNI tree, so each listener ends up with one entry per name.
Extended reasoning...
The inline findings cover the intrinsic-call convention and the re-listen replay failure path. Separately, the native add_server_name path in src/runtime/socket/Listener.rs does a remove_server_name before add_server_name, so a repeated addContext() for the same name while listening replaces rather than appends and the previous SSL_CTX reference is released; the contexts replay in net.ts:4010-4014 runs against a freshly created handle, so entries added while listening are not registered twice on the same SNI tree. Nothing in this run contradicts the PR's ordering claim (delete-then-set keeps last-write-wins order matching the native tree's last-add-wins).
A script that replaces Map.prototype.set or Map.prototype.delete must not reach the server's SNI entries.
There was a problem hiding this comment.
LGTM — the follow-up commit switches to the $delete/$set intrinsics, and the runtime change is a small, self-contained bookkeeping fix that matches Node's _contexts semantics.
What was reviewed:
- The always-present
contextsMap against its only consumer (kRealListenin net.ts iterates it after_handleis set) — an empty map is a no-op there, so servers that never calladdContext()are unaffected. - Ordering of
addServerName()before the map write, so a name the live listener rejects is not replayed on the nextlisten(); the delete-then-set keeps call order for a re-added name. - The five new tests assert exact CNs via
toEqualon a single object, drain subprocess pipes concurrently, and the cluster fixture covers the listen()-before-handle window.
Extended reasoning...
Overview
The PR changes ~5 lines of src/js/node/tls.ts inside Server's addContext: contexts becomes an always-present Map created at construction, and every addContext() call forwards to the live handle via addServerName() (if one exists) and then records the entry with contexts.$delete(hostname); contexts.$set(hostname, context). The map is handed to net.ts through this[buntls]() and replayed into each new native listener in kRealListen. The rest of the diff is a new describe block in test/js/node/tls/node-tls-server.test.ts with five tests using the agent1/2/3 fixture certificates.
Security risks
None introduced. The change only affects which SNI certificate the server presents for a name the application itself registered; no trust decisions, verification flags, or key material handling change. Calling the native addServerName() before recording an entry means a rejected name is never persisted, which is strictly safer than the previous ordering for names added before listen(). The nit from the prior review (public Map.prototype methods in a builtin) was addressed in the second commit by using the $-intrinsics.
Level of scrutiny
Low-to-moderate. The runtime diff is tiny and the intent is clear: mirror Node's server._contexts so entries survive close()/listen() and land in a cluster worker's deferred listener. I traced contexts to its single consumer in net.ts (if (contexts) for (const [name, context] of contexts) addServerName(...)), which handles an empty map identically to the old null. The native add_server_name already replaces an existing entry for the same name, so the live listener and the map agree that the last call wins. No CODEOWNERS entry covers either file.
Other factors
The tests assert exact certificate CNs with toEqual on a single object, wire cleanup through finally/await using, use port: 0, and cover the four behavioral clauses the PR claims (survive relisten, replacement survives relisten, rejected entry not kept, call order preserved) plus the cluster-worker window. The previously noted pre-existing issue (a replay failure in kRealListen leaving the handle open for names with >10 labels) predates this PR and is not made worse in any realistic case. The bug hunt exited on dry_streak with no findings, and the only change since the prior review is the intrinsic fix that resolved the earlier inline nit.
Still open from earlier reviews (1):
- Unresolved: 1 minor or pre-existing.
|
Updated 5:02 PM PT - Sep 21st, 2026
✅ @robobun, your commit c6cd8133f8db8f84ca84fcc7125c3fef50b465f3 passed in 🧪 To try this PR locally: bunx bun-pr 43080That installs a local version of the PR into your bun-43080 --bun |
There was a problem hiding this comment.
LGTM — the $delete/$set intrinsics nit from the earlier pass is addressed, and the remaining change is a small, well-tested bookkeeping fix in addContext().
What was reviewed:
- The write order in
addContext():addServerNameon the live handle runs before the map write, so a natively rejected name (empty hostname) never reaches the nextlisten()replay. - The
contextsconsumer in net.ts (setupListenHandle, ~line 4010): it already tolerates a Map and theif (contexts)guard is still fine with an always-present, possibly empty, Map. - The five new tests: each awaits
listening/close/secureConnectevents rather than sleeping, the cluster test drains stdout/stderr concurrently, and every server is closed infinally.
Extended reasoning...
Overview
The PR changes nine lines in src/js/node/tls.ts so that tls.Server#addContext() always records the entry in the per-server contexts Map (created at construction instead of lazily), after first pushing it to a live native listener when one exists. net.ts already replays that Map into each new native handle on listen(), so the fix makes entries added while listening survive close() + listen() and reach a cluster worker whose handle is created asynchronously. The test file adds a describe block with four in-process relisten tests and one cluster-worker subprocess test.
Security risks
No new attack surface. The change only affects which already-configured certificate is served for an SNI name; it does not touch TLS verification, credentials, or the native SNI matching. The map is closure-private and, after the follow-up commit, is mutated only through $delete/$set intrinsics, so Map.prototype tampering cannot affect it. The delete-before-set ordering is what makes the replay honour node's last-call-wins semantics for names the native tree collapses (a.example vs a.example.), and the test for call order covers that.
Level of scrutiny
Low to moderate. The code change is small and self-contained, the consumer side in net.ts was inspected and needs no change, and the behavior matches node's _contexts handling. The one previously noted pre-existing concern (a throw during listen() replay leaving the handle bound) is unchanged in scope by this PR and was already marked non-blocking; it does not warrant holding this fix.
Other factors
Both prior review comments led to a follow-up commit: the intrinsics nit was fixed and the comments were shortened. The tests await observable events rather than timers, drain subprocess pipes concurrently, use tempDir/bunExe/bunEnv, and close resources in finally. The bug-hunting run exited on a dry streak with no findings, and the diff stat confirms only the two listed files changed.
|
@robobun On Node v26.3.0 |
…addcontext-relisten
…ERVER_NAME Node rejects a falsy servername at the call. Bun kept an empty name that was added before listen() and then failed listen() with "hostname pattern cannot be empty". On a live listener it threw that message from the native side. addContext() now throws Node's code and message first.
|
@cirospaciari Done in c6cd813.
#42050 carries the same guard and error code inside a larger change. The lines are identical, so the PR that lands second can drop its copy. |
There was a problem hiding this comment.
LGTM — the latest push resolves the earlier $delete/$set nit and the new ERR_TLS_REQUIRED_SERVER_NAME check is a faithful port of Node's contract.
What was reviewed:
addContextin src/js/node/tls.ts: the map is now always allocated and written only after a live handle accepts the entry, so a rejected name is never replayed;net.ts'skRealListenreads the same Map by reference via[buntls](), so a cluster worker's deferred listen sees later entries.- The new error code is registered in all three places (ErrorCode.ts as
Error, ErrorCode.cpp message, builtins.d.ts) with Node's verbatim message, and the falsy-name check runs before thetypeofcheck, matching Node's ordering. - Tests: read (not executed here — no debug build in this checkout); they assert exact
code/name/message, useport: 0,tempDir, and drain pipes concurrently in the cluster case.
Extended reasoning...
The change is 13 lines in src/js/node/tls.ts plus a three-site registration of a new Node error code and ~150 lines of tests in the existing node-tls-server.test.ts. It touches the TLS SNI context bookkeeping in the node: compat layer but does not change certificate validation, handshake, or any trust decision — it only persists user-supplied contexts across listen() calls. The earlier inline nit (public Map methods) was addressed in the diff, and the pre-existing replay-failure concern is acknowledged in the description and tracked separately. The small, self-contained diff, the Node source citation for the new behavior, and the direct test coverage of each clause decided approve.
Problem
server.addContext(name, ctx)on a listeningtls.Serveris lost afterserver.close()+server.listen(). The new listener serves the default certificate, or the older one from anaddContext(name, old)beforelisten(). Node v26.3.0 keeps the newest entry.addContext(src/js/node/tls.ts:1244) only calls the nativeaddServerNamewhen a listener exists. It skips thecontextsmap, and eachlisten()loads that map into the new listener. The firstaddContext()creates the map, so a cluster worker's pendinglisten()holdsnulland misses a later entry.addContext("")does not throw Node'sERR_TLS_REQUIRED_SERVER_NAME. Beforelisten()the empty name is kept, andlisten()fails withhostname pattern cannot be empty.Fix
addContextalways records the entry incontexts, after the live listener accepted it. The map exists from construction, so a pendinglisten()shares it. A re-added name moves to the end, solisten()replays the calls in order.ERR_TLS_REQUIRED_SERVER_NAME(ErrorCode.ts,ErrorCode.cpp) with Node's message, before anything is recorded.server.emit("connection")still gets the default certificate (no SNI tree on that path, cluster: hand TLS workers their connections on Windows instead of sharing the listening socket #37896).test/js/node/tls/node-tls-server.test.ts, each fails without the fix. Alsonode-tls-context.test.tsand 8 upstreamtest-tls-*tests.Background
addContext(name, ctx)adds a certificate that the server presents when the client's SNI name matchesname.server._contextsand checks them newest first.Bun.listen) owns an SNI tree.listen()fills it from thecontextsmap,addServerNameupdates it, andclose()destroys it.Notes
Repro on bun 1.4.3 canary and on main at 630e921 (agent1 is the default certificate):
Tests:
ERR_TLS_REQUIRED_SERVER_NAMEtest replaces the earlier test for a rejected empty name. It checks"",undefinedandnullbeforelisten(),""while listening, and that the nextlisten()works. The same assertions pass on node v26.3.0. Checked on Linux x64 only.$deletebefore$set: the call order test fails.addServerName. With the new guard, the live listener rejects only a name with more than 10 labels whose twin is registered (tls.Server: a listen() that fails while it loads addContext() entries leaves the listener bound and accepting #43082). Node accepts that input, so no test pins the order for it.tsc --noEmit -p src/js/tsconfig.jsonpasses.SNICallback runs even when the requested servername matches the bind hostnamein the same file fails in my Linux container with and without this change.localhostresolves to::1for the listener and to127.0.0.1for the client there. It passes on Windows.Details:
addServerNamealready replaces an existing entry for the same name (Listener::add_server_nameremoves, then adds). The live listener and the map agree that the last call wins.addContext()calls for one name do not grow it. Node's_contextsarray grows with every call..and ignores an empty last label, soa.example.anda.exampleare one entry. With the callsa.example,a.example.,a.example, the old map order replayed the second call last. Node serves the third.if (!servername)(https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1571-L1574). It runs before the existinghostname must be a stringcheck, as in Node. node:tls: build the tls.Server SecureContext in setSecureContext() #42050 carries the same lines for the guard and the error code. The PR that lands second drops its duplicate.listen()that fails while it loads an entry leaves the listener bound and accepting. Main does the same for entries added beforelisten(). tls.Server: a listen() that fails while it loads addContext() entries leaves the listener bound and accepting #43082 tracks it, with a fix open in node:net: stop the listener when listen() fails after the socket is bound #43093. TLS SNI: names with more than 10 labels are added to the SNI tree but never found or removed #43092 tracks the label limit of the SNI tree. An entry that the live listener rejects is not recorded, so it cannot cause that. One new sequence reaches it: a name with more than 10 labels added while the server listens, thenclose(), then the same name with a trailing dot, thenlisten(). The native tree cannot replace a name with more than 10 labels, and such a name never matches a handshake.addContextuses the$deleteand$setMap intrinsics, assrc/js/CLAUDE.mdasks._contexts: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1571-L1611tls.Servercannot reach it on main.listenInClustersetssharedOnlyfor TLS, so a TLS worker always gets a native listener, andaddServerNamenever sees the faux handle.Overlap with open PRs:
addContextfor wrapped connections and carries the same map change inside a larger one. This PR is the standalone fix. The PR that lands second needs a small rebase inaddContext.setSecureContext()on a listening server) reads[buntls]()again when the primary answers a worker'slisten(), and its cluster fixture also coverslisten()thenaddContext(). Both changes work together. The PR that lands second can drop its duplicate of that test.https.Server#addContext), node:tls: keep ALPN across an SNI SecureContext selection #33253 (ALPN on SNI contexts), tls: select the certificate by server name for connections accepted before close() / graceful stop() #42355 (SNI for connections accepted beforeclose()).no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/tls/node-tls-server.test.ts