Skip to content

net,tls: port Node.js net/tls compatibility tests and fix the gaps they surface — half-open/reset/write semantics, server TLSSocket wrap, session/keylog, SNICallback/ALPNCallback, pfx, OpenSSL error shapes, addCACert, local binding (+305 tests)#31155

Merged
cirospaciari merged 7 commits into
mainfrom
claude/port-node-net-tls-tests-2
Jun 17, 2026

Conversation

@cirospaciari

@cirospaciari cirospaciari commented May 21, 2026

Copy link
Copy Markdown
Member

Brings node:net and node:tls compatibility in line with Node by porting upstream tests verbatim and fixing the native gaps they expose.

parallel sequential total
net 141/148 (95.3%) 10/12 151/160 (94.4%)
tls 150/215 (69.8%) 4/4 154/219 (70.3%)

305 verbatim upstream tests added.

Behavior changes

  • Half-open semantics: socket.end() now half-closes (FIN) instead of full-closing, so a server's response after a client end() is delivered before close.
  • Reset/write semantics: socket.resetAndDestroy(), server.close({ resetConnections }), write-after-end / write-after-destroy errors, and the post-write callback contract match Node.
  • Close-time read errors: a reset delivered with the close destroys the socket with Node's read ECONNRESET shape when an error listener is attached and a clean EOF has not already been delivered; a codeless close error that carries an errno derives its code from it. A reset that lands after the exchange already ended cleanly stays a graceful close.
  • Local binding: net.connect({ localAddress, localPort }) binds before connecting on every connect path including deferred DNS resolution.
  • The 'session' and 'keylog' events, end-to-end through the native handler-slot chain; the --tls-keylog flag.
  • OpenSSL error decomposition: handshake/context-creation failures carry Node's code/library/function/reason properties (ERR_SSL_<REASON>, ERR_OSSL_<LIB>_<REASON>).
  • secureContext.context.addCACert() extends the full default trust set (bundled roots, NODE_EXTRA_CA_CERTS, system CAs when enabled) on the context's own store, and chain verification then uses that store; tls.createSecureContext() returns a context that owns its SSL_CTX exclusively so a CA appended to one cannot affect another (the internal connect/listen paths keep the per-digest cache).
  • tls.setDefaultCACertificates() applies on every secure-context construction path (plain tls.connect(), addContext, setSecureContext), not just the public createSecureContext().
  • The SNICallback and ALPNCallback server dispatches, resolved per connection with Node's semantics; tlsSocket.setKeyCert(); ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS.
  • The OpenSSL cipher-list selector grammar (PSK+HIGH, !aNULL, @SECLEVEL, …) is accepted/rejected the way BoringSSL evaluates it; a mixed EC/RSA multi-identity configuration is rejected with Node's decomposed KEY_TYPE_MISMATCH.
  • The pfx option: PKCS#12 blobs (single or array, per-entry passphrases) are parsed into key/cert with Node's error messages; CAs embedded in the bundle extend the trust set (they are not treated as an explicit ca replacement).
  • Server-side TLSSocket wrapping of an accepted socket and tls.connect({ socket: duplex }) over a generic Duplex, including connecting parity and synchronous teardown of the wrapped duplex on destroy.
  • Memory safety: a deferred-close protocol so a destroy issued from inside an SNI/ALPN/keylog callback cannot free the SSL out from under BoringSSL; the per-loop BIO state is snapshotted/restored across in-handshake JS so cross-socket I/O cannot misroute the in-flight handshake; the GC-rooting of the detailed peer-certificate chain.
  • net perf_hooks observer, options.handle, pause()/unref() accounting, SocketAddress/BlockList parity, the autoSelectFamily flag plumbing.

Known limitations / follow-ups

  • An asynchronous SNICallback now suspends the handshake (BoringSSL select-certificate retry) until its callback resolves - the upstream test-tls-sni-option.js passes; synchronous callbacks behave as before.
  • Two linked follow-ups around mid-handshake teardown: (1) a server connection raw-closed (RST) while its handshake is suspended leaves the JS connection count stale until the socket is GC'd - server.close() waits longer than it should in that edge case; (2) fixing it by dispatching the handshake failure from the close path requires first aligning tlsHandshakeError's no-listener behavior with Node's silent-destroy semantics, otherwise routine client-initiated mid-handshake teardowns (h2 connection management) surface as spurious ECONNRESET errors.
  • An SNICallback that reports an error, returns something that is not a SecureContext, or throws aborts the handshake (synchronously or asynchronously): the connection is dropped without a TLS alert and the server emits 'tlsClientError' with the callback's error, matching Node.
  • setKeyCert() from inside ALPNCallback is too late under BoringSSL's TLS 1.3 (the credential is already chosen); calling it from SNICallback works.
  • tls.DEFAULT_MIN/MAX_VERSION are now honored at context-construction time (assignment through the module exports works the way Node's does), and a TLS socket that ends first keeps reading the peer's in-flight data - BoringSSL has no TLS half-close, so end() defers the close_notify (flushing pending session tickets first) and half-closes at the TCP level instead.
  • test-net-perf_hooks.js is intermittently divergent on Ubuntu 25.04 (dual-stack localhost resolution).
  • A response written to a peer that has already gone away used to be retried as would-block on Linux, hanging the FIN-terminated-response teardown tests there; node:net writes now go through an opt-in fatal-send-error path (us_socket_write_check_error) that fails the pending write and closes the socket instead.
  • The fetch/h2 suites on Windows still see teardown resets surfaced between tests; under investigation alongside the write-error work.
  • An http2 session with no 'error' listener swallows ECONNRESET transport teardown noise (destroying the session quietly) instead of crashing the process the way Node's EventEmitter contract would; all other unobserved errors still surface.

Fixes #28638
Fixes #28641
Fixes #26418
Fixes #20642


Origin (consolidated from #31148)

What

Ports 11 net/tls tests from the Node.js test suite into test/js/node/test/parallel/ (verbatim) and fixes the runtime divergences they surfaced.

Tests added (verbatim from upstream Node)

net: test-net-pause-resume-connecting, test-net-server-keepalive, test-net-server-nodelay, test-net-socket-setnodelay, test-net-connect-memleak
tls: test-tls-basic-validations, test-tls-buffersize, test-tls-client-reject, test-tls-net-socket-keepalive, test-tls-secure-session, test-tls-connect-memleak

Fixes

net.Socket / net.Server

  • Accepted server sockets inherit the server's keepAlive, noDelay, allowHalfOpen and highWaterMark; the Socket constructor honors options.handle.
  • The native socket's half-open flag follows the Duplex's allowHalfOpen (default false closes on peer FIN so 'close' fires; true keeps the writable side open), matching Node/libuv.
  • pause() is honored while a socket is still connecting.
  • setNoDelay() forwards to the handle; Server coerces noDelay with Boolean() like keepAlive.
  • _write defers its callback to the next tick only for TLS sockets (the SSL engine batches, so bufferSize/writableLength reflect the queued bytes); plain TCP completes synchronously so a tight write() loop backpressures at the kernel rather than the JS highWaterMark.

tls

  • validateSecureContextOptions (ciphers / passphrase / ecdhCurve / min-max version / ticketKeys / sessionTimeout) and a simplified convertALPNProtocols.
  • TLS clients emit the 'session' event.

Errors

  • validateBuffer reports "must be an instance of Buffer, TypedArray, or DataView" to match Node. Two existing tests that asserted the old wording are re-synced to upstream.

Comments that mirror Node behavior link the corresponding upstream source.

Testing

Validated locally with the debug build before pushing:

  • All 11 ported tests pass 20× with no flakes and are byte-identical to upstream (flaky candidates were dropped).
  • Full test-{net,http,tls}-* parallel sweep: 0 failures.
  • test/js/node/http/node-http.test.ts (incl. HTTP server security tests): 78 pass, 0 fail.

Notes

The strictly Node-correct half-close _final (shutdown() rather than $end()) is left for a follow-up: it exposes a uWS TLS-handshake edge case where a successful handshake immediately followed by a close_notify is reported as ECONNRESET. Until that's addressed, _final uses $end().


This PR consolidates the original test-porting branch (claude/port-node-net-tls-tests, #31148) and the follow-up branch (claude/port-node-net-tls-tests-2); both branches now point to the same squashed content.

@robobun

robobun commented May 21, 2026

Copy link
Copy Markdown
Collaborator
Updated 5:07 PM PT - Jun 16th, 2026

@cirospaciari, your commit ce06dce has 1 failures in Build #62894 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31155

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

bun-31155 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. Error response not sent to client when request body read fails due to client abort #28638 - socket.end() after partial POST body now half-closes (FIN) instead of full-closing, so the server's error response can still be read by the client
  2. http.createServer destroys socket instead of sending 431 response #28641 - socket.end("431...") in clientError handler now sends FIN after writing instead of immediately destroying, letting the error response reach the client
  3. net.createServer socket data events not emitted when used with ssh2 forwardOut #26418 - Half-close keeps the readable side open so bidirectional piped data flows correctly instead of being killed by a premature full close
  4. Cluster module freezes on worker disconnect when using unshared TCP servers #20642 - Half-close allows the remote end to see FIN and emit end, unblocking the disconnect() call that was never reached

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #28638
Fixes #28641
Fixes #26418
Fixes #20642

🤖 Generated with Claude Code

Comment thread src/js/node/tls.ts
@cirospaciari cirospaciari force-pushed the claude/port-node-net-tls-tests-2 branch from 9302345 to f36f956 Compare May 21, 2026 02:39
Comment thread src/js/node/net.ts
Comment thread test/js/node/test/parallel/test-net-pingpong.js
Comment thread src/js/node/tls.ts Outdated
Comment thread src/js/node/tls.ts Outdated
Comment thread src/http/ssl_config.rs
Comment thread src/js/node/tls.ts Outdated
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts Outdated
@cirospaciari cirospaciari changed the title net,tls: half-close Socket.end() (FIN) instead of a full close net,tls: Node.js compatibility — TLS protocol versions, listen/reset/connect fixes, +30 tests May 21, 2026
Comment thread test/js/node/test/parallel/test-net-server-listen-path.js
Comment thread test/js/node/test/parallel/test-tls-client-reject-12.js

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js/node/tls.ts:671-673 — Node only validates SNICallback for server-side TLSSockets (the validateFunction call in _tls_wrap.js is inside the if (options.isServer) block — SNI callbacks are inherently server-only), but this check runs unconditionally, so tls.connect({ ..., SNICallback: {} }) or new tls.TLSSocket(sock, { SNICallback: 42 }) throws ERR_INVALID_ARG_TYPE in Bun while Node silently ignores it. One-token fix since this.isServer is set just above: if (this.isServer && options.SNICallback != null). Very narrow trigger (junk server-only option on a client socket), so just a nit.

    Extended reasoning...

    What the bug is

    The TLSSocket constructor (src/js/node/tls.ts:671-673) now does:

    this.isServer = !!options.isServer;
    
    if (options.SNICallback != null) {
      validateFunction(options.SNICallback, "options.SNICallback");
    }

    This validates SNICallback regardless of whether the socket is server- or client-side. In Node.js, the equivalent check in TLSSocket.prototype._init (lib/_tls_wrap.js / lib/internal/tls/wrap.js) lives inside the if (options.isServer && options.SNICallback && ...) block — SNI callbacks are conceptually server-only (they let a server pick a certificate based on the client's SNI extension), so Node only type-checks the option when wrapping a server socket and silently ignores it on clients.

    Code path that triggers it

    const tls = require('tls');
    const net = require('net');
    // Either of these throws ERR_INVALID_ARG_TYPE in Bun, no-op in Node:
    new tls.TLSSocket(new net.Socket(), { SNICallback: {} });
    tls.connect({ port: 443, host: 'example.com', SNICallback: 42 });

    For tls.connect, the options object flows into new TLSSocket(options) with no isServer key, so this.isServer is false, but the unconditional check on the next line still runs validateFunction({}, ...) and throws.

    Why existing tests don't catch it

    The newly-ported test-tls-snicallback-error.js (verbatim from upstream) only exercises tls.createServer({ SNICallback }) and new tls.TLSSocket(sock, { isServer: true, SNICallback }) — both server-side — which is itself evidence that Node only validates on the server path. The Server constructor in this PR does its own SNICallback check (tls.ts:911-913), so the test passes without ever reaching the over-broad TLSSocket check on a client socket.

    Step-by-step proof

    Step Node.js Bun (this PR)
    new tls.TLSSocket(sock, { SNICallback: 42 }) (no isServer) _init: options.isServer is falsy → SNICallback branch skipped → no throw ctor: this.isServer = false; next line: options.SNICallback != nullvalidateFunction(42, ...)throws ERR_INVALID_ARG_TYPE
    new tls.TLSSocket(sock, { isServer: true, SNICallback: 42 }) enters isServer branch → validateFunction(42, ...) → throws validateFunction(42, ...) → throws ✅ (matches)

    Impact

    Minor Node-compat divergence in new code from this PR. The trigger is implausible in real code — SNICallback is documented as a server-only option, so passing a non-function value to a client socket is essentially garbage input; Bun is just stricter than Node here. No correct program is affected. The inline comment says "matches Node's TLSSocket init" but it's slightly stricter than Node. Filed as a nit since the fix is one token and the check is being added specifically for Node compat.

    Fix

    Gate on this.isServer, which is set on the line immediately above:

    if (this.isServer && options.SNICallback != null) {
      validateFunction(options.SNICallback, "options.SNICallback");
    }

@cirospaciari cirospaciari force-pushed the claude/port-node-net-tls-tests-2 branch from 04333c4 to 0a25656 Compare May 21, 2026 18:05
Comment thread src/js/node/net.ts
Comment thread src/js/node/tls.ts
Comment thread src/http/ssl_config.rs
Comment thread test/js/node/test/parallel/test-net-pingpong.js
Comment thread src/js/node/net.ts
Comment thread src/js/node/tls.ts
Comment thread src/js/node/tls.ts Outdated
Comment thread src/js/node/net.ts Outdated
@cirospaciari cirospaciari changed the title net,tls: Node.js compatibility — TLS protocol versions, listen/reset/connect fixes, +30 tests net,tls: Node.js compatibility — TLS versions, setDefaultCACertificates, listen/reset/connect fixes, +42 tests May 21, 2026
@cirospaciari cirospaciari requested a review from alii as a code owner May 21, 2026 20:13

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js/node/tls.ts:651-653tls.setDefaultCACertificates([]) makes getCACertificates('default') return [] correctly, but actual TLS connections without an explicit ca still trust the full bundled root store — the injected ca: [] collapses to ca: undefined at the native layer (handle_file_array() returns None for empty arrays, SSLConfig.rs:338-340), so the per-SSL client attach at openssl.c:677-681 falls back to us_get_shared_default_ca_store(). In Node an empty array clears the defaults so such connections fail verification; here the user requests zero-trust and silently gets full-trust. Worth listing as a known limitation alongside the other deferred setDefaultCACertificates items (a real fix needs a native 'empty store' sentinel); the ported -precedence-empty.js/-basic.js tests don't exercise this path.

    Extended reasoning...

    What the bug is

    The new tls.setDefaultCACertificates() is implemented as a JS-side override: _defaultCACertificatesOverride is stored, getCACertificates('default') returns it, and createSecureContext() injects it as options.ca when no explicit ca is given (tls.ts:651-653). For a non-empty override this works (modulo Bun's pre-existing additive-CA semantics). For an empty override it does not: ca: [] is silently dropped on the way into the native SSL_CTX, so the connection ends up using the full bundled Mozilla root store + NODE_EXTRA_CA_CERTS, exactly as if setDefaultCACertificates had never been called. Node's documented behavior is that setDefaultCACertificates([]) clears the defaults so subsequent connections without their own ca fail certificate verification.

    Code path

    1. tls.setDefaultCACertificates([]) → the validation loop runs zero times → _defaultCACertificatesOverride = [].
    2. tls.connect({...}) (no ca) → TLSSocket ctor → createSecureContext(options). _defaultCACertificatesOverride !== undefined && options.ca == null is true → options = { ...options, ca: [] }.
    3. newNativeSecureContextNativeSecureContext.intern → bindgen → SSLConfig::from_jshandle_file for cahandle_file_array():
      // SSLConfig.rs:338-340
      if elements.is_empty() {
          return Ok(None);
      }
      So result.ca = Noneidentical to ca: undefined.
    4. as_usockets()ctx_opts.ca = NULL, ca_count = 0.
    5. us_ssl_ctx_build_raw (openssl.c:543): options.ca && options.ca_count > 0 is false. requestCert is set on the connect-time tls object (net.ts:1006), not on the createSecureContext options that built this SSL_CTX, so options.request_cert (openssl.c:561) is also false. All three branches skipped → SSL_CTX has verify_mode == SSL_VERIFY_NONE and no cert store configured.
    6. us_internal_ssl_attach (openssl.c:677-681), client path:
      if (SSL_CTX_get_verify_mode(ctx) == SSL_VERIFY_NONE) {
        SSL_set_verify(ssl, SSL_VERIFY_PEER, us_verify_callback);
        X509_STORE *roots = us_get_shared_default_ca_store();
        if (roots) SSL_set0_verify_cert_store(ssl, roots);
      }
      The per-socket override installs the shared bundled-roots store (Mozilla bundle + NODE_EXTRA_CA_CERTS, see root_certs.cpp). The connection trusts everything Bun trusts by default.

    Why the ported tests don't catch it

    • test-tls-set-default-ca-certificates-precedence-empty.js sets defaults to [] but then passes a per-connection ca: [fakeStartcomCert], which makes options.ca == null false at tls.ts:651 — the override-injection branch is never taken; the test only proves per-connection ca still works.
    • test-tls-set-default-ca-certificates-basic.js / -array-buffer.js only round-trip through getCACertificates('default'), which reads the JS-side _defaultCACertificatesOverride array directly and never builds a native context or opens a connection.

    No test in this PR connects without an explicit ca after setDefaultCACertificates([]) and asserts a verification failure.

    Step-by-step proof

    const tls = require('tls');
    tls.setDefaultCACertificates([]);
    console.log(tls.getCACertificates('default')); // [] ✓ — JS surface correct
    tls.connect(443, 'example.com', { servername: 'example.com' }, () => {
      console.log('connected, authorized =', this.authorized);
    });
    Step Node.js Bun (this PR)
    getCACertificates('default') [] []
    native trust store for the connection empty X509_STORE us_get_shared_default_ca_store() (full bundle) ❌
    handshake against a public-CA-signed server fails verification (UNABLE_TO_GET_ISSUER_CERT etc.) succeeds, authorized === true

    The SSL_CTX content-hash digest for {ca: []} and {ca: undefined} is also identical (both feed ca = None into content_hash()), so the interned context is literally the same object as the no-CA default.

    Impact / why nit

    The failure mode is silently security-relevant — a user who calls setDefaultCACertificates([]) is explicitly asking for zero default trust and gets full default trust instead, with no error or warning. That said:

    • setDefaultCACertificates is brand-new in this PR (not a regression).
    • The implementation comment at tls.ts:1285-1287 already acknowledges "Bun has no equivalent native store override, so keep a JS-side override" — the JS-side ca-injection approach inherently can't express "empty store" because of pre-existing native behavior (handle_file_array's empty→None and the per-SSL client fallback at openssl.c:677-681 are both unchanged by this PR).
    • The non-empty override has a related pre-existing limitation: openssl.c:547 starts from us_get_default_ca_store() even when ca is supplied, so setDefaultCACertificates([myCA]) trusts bundled+myCA rather than only myCA. The empty case is the starkest instance of a broader "override doesn't restrict trust" gap.
    • A proper fix requires native changes (sentinel for "install an empty X509_STORE", or removing the per-socket shared-store fallback when ca was explicitly empty) that are out of scope for this compat PR.

    Filed as nit: worth flagging in the PR description's deferred-items list and/or adding a TODO at the override-injection site, since the PR description currently says "setDefaultCACertificates() implemented" without this caveat.

    Fix direction

    Either:

    1. Special-case the empty override in createSecureContext to pass a sentinel that the native layer interprets as "install an empty X509_STORE and set VERIFY_PEER" (so openssl.c:677 doesn't fall back to the shared store), or
    2. Thread an explicit "override default roots" flag through to us_internal_ssl_attach so it skips us_get_shared_default_ca_store() when an override (even empty) is active.

    Both require native changes; until then, documenting it as a known limitation is the honest option.

  • 🟡 src/js/node/net.ts:2403-2407 — The new comment says "a valid port takes precedence over path", but the implementation never clears path after validating port — so listen({ port: 0, path: '/tmp/sock' }) still falls into kRealListen's if (path) branch (net.ts:2543) and listens on the unix socket, ignoring port. The path-wins behavior is pre-existing (not a regression), but since this block is rewritten under "Match Node's listen() option normalization" and the ported test-net-server-listen-options.js only exercises { port: -1, path } (which throws in validatePort before reaching kRealListen), it'd be worth either adding path = undefined; after port = port | 0 to actually match Node, or dropping the precedence clause from the comment.

    Extended reasoning...

    What the issue is

    The rewritten options-object branch of Server.prototype.listen() adds, at net.ts:2403-2407:

    if (typeof port === "number" || typeof port === "string") {
      // validatePort coerces "0" -> 0 and throws ERR_SOCKET_BAD_PORT for
      // out-of-range/non-numeric values; a valid port takes precedence over path.
      validatePort(port, "options.port");
      port = port | 0;
    } else if (isPipeName(path)) {

    The comment (and the block's header at line 2397, "Match Node's listen() option normalization") states that a valid port takes precedence over path. In Node that is true — lib/net.js returns from the port branch (calling listenInCluster with no pipe) before ever reaching the path branch. In Bun, however, path is read at line 2379 and never cleared in the port branch. Both port and path are then passed through listenInCluster(..., path, ...) (line 2515) to kRealListen, which checks if (path) first (line 2543) and calls Bun.listen({ unix: path }), ignoring port entirely. So in practice path takes precedence over port, the opposite of what the new comment claims.

    Step-by-step proof

    For net.createServer().listen({ port: 0, path: '/tmp/sock' }):

    Step Line State
    1 2379 path = '/tmp/sock'
    2 2380 port = 0
    3 2399 port === undefined? no; port === null? no → skip
    4 2403 typeof port === 'number'true, enter port branch
    5 2406 validatePort(0) → ok
    6 2407 port = 0 | 00; path still '/tmp/sock'
    7 2515 listenInCluster(..., port=0, ..., path='/tmp/sock', ...)
    8 2543 kRealListen: if (path)trueBun.listen({ unix: '/tmp/sock' })

    Result: listens on the unix socket. In Node the same call listens on TCP port 0 (random port).

    Why existing tests don't catch it

    The newly-ported test-net-server-listen-options.js does include a port-vs-path precedence assertion:

    // In listen(options, cb), port takes precedence over path
    assert.throws(() => {
      net.createServer().listen({ port: -1, path: common.PIPE }, common.mustNotCall());
    }, assertPort());

    But port: -1 throws ERR_SOCKET_BAD_PORT inside validatePort at line 2406, before kRealListen is reached — so it only proves that an invalid port is validated ahead of path, not that a valid port wins at listen time. There is no test for { port: <valid>, path: <valid> }.

    Why this is a nit, not a regression

    The underlying path-wins behavior predates this PR. The old code's if (!Number.isSafeInteger(port) || port < 0) guard was also false for port = 0, leaving path set, and the same kRealListen if (path) check applied. So no user-observable behavior changes here. What is new is:

    • The comment at line 2405 explicitly claiming Node's precedence, which the implementation doesn't honor.
    • The block header at line 2397 ("Match Node's listen() option normalization") implying parity that isn't there for this case.
    • The pre-existing doc-comment at line ~2442 ("path Will be ignored if port is specified") was already wrong, but this PR adds a second wrong comment rather than fixing either.

    The trigger is also narrow — passing both port and path to listen() is unusual.

    Fix

    One line in the port branch:

    if (typeof port === "number" || typeof port === "string") {
      validatePort(port, "options.port");
      port = port | 0;
      path = undefined;   // port takes precedence over path (Node returns early here)
    } else if (isPipeName(path)) {

    Or, if matching Node's precedence is out of scope, drop "a valid port takes precedence over path" from the comment so it doesn't misdocument the implementation.

@cirospaciari cirospaciari force-pushed the claude/port-node-net-tls-tests branch from 5ca55ec to f49ffa0 Compare May 21, 2026 21:00

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟣 src/bun_core/lib.rs:3347-3352 — Pre-existing (already on main via #31116, not introduced by this PR): the musl/Android fallback Bun__captureStackTrace at line 3339 is still pub extern "C" fn (safe) while the glibc/macOS/BSD and Windows definitions are pub unsafe extern "C" fn, so the shared wrapper's unsafe { Bun__captureStackTrace(...) } here triggers the unused_unsafe warning on aarch64-musl / x64-musl / x64-android / aarch64-android / x64-musl-baseline (the robobun annotations at bun_core/lib.rs:3352 in build #56711). It's warn-level only (unused_unsafe is not in the workspace deny list), so it doesn't block the build — just noting since it shows up in this PR's CI output; the one-keyword fix is to mark the fallback unsafe too (with a # Safety doc) so all three cfg variants match.

    Extended reasoning...

    What the issue is

    Bun__captureStackTrace has three cfg-gated definitions in src/bun_core/lib.rs:

    cfg line signature
    glibc / macOS / *BSD ~3233 pub unsafe extern "C" fn
    Windows ~3285 pub unsafe extern "C" fn
    fallback (musl, Android, …) 3339 pub extern "C" fnnot unsafe

    The shared safe wrapper at line 3349-3352 unconditionally wraps the call:

    pub fn capture_stack_trace(begin: usize, addrs: &mut [usize]) -> usize {
        // SAFETY: `addrs.as_mut_ptr()` is writable for `addrs.len()` slots; ...
        unsafe { Bun__captureStackTrace(begin, addrs.as_mut_ptr(), addrs.len()) }
    }

    On targets where the fallback definition is selected (musl-libc Linux, Android — i.e. anything not in the #[cfg(any(...))] list above), Bun__captureStackTrace is a safe function, so wrapping a call to it in unsafe { } triggers rustc's unused_unsafe lint at line 3352.

    Why this is pre-existing, not PR-introduced

    Although the unsafe-marking changes appear in this PR's GitHub diff, that's an artifact of the three-dot diff being computed against a stale merge-base. git log -S shows the unsafe markers + wrapper unsafe { } were introduced in commit 21db6826 ("clippy: 45 deny lints + fix 2735 violations across workspace (#31116)"), and git merge-base --is-ancestor 21db6826 <origin/main> confirms that commit is already on main. A two-dot git diff main..HEAD -- src/bun_core/lib.rs shows no change to Bun__captureStackTrace or capture_stack_trace from this PR. The same warning fires on main builds; robobun's parseAnnotations (scripts/utils.mjs:2662) scrapes both error: and warning: lines from build output regardless of provenance, so it appears in build #56711's annotation list alongside other ambient noise like clang++: argument unused during compilation: '-no-pie'.

    Why it's a warning, not a build failure

    unused_unsafe is warn-by-default in rustc and is not in the PR's [workspace.lints.rust] deny list (which only promotes dead_code, unused_imports, unused_variables, unused_mut, unused_assignments, unused_macros, unreachable_code, unreachable_patterns). There is no #![deny(unused_unsafe)] in bun_core/lib.rs and no -D warnings in scripts/build/rust.ts. So this surfaces as a compiler warning, not a hard error — robobun's "5 failures" count for build #56711 doesn't include it (the actual failures are the three build-cpp musl entries plus the FreeBSD unreachable_pub items).

    Step-by-step proof

    1. Compile bun_core with --target x86_64-unknown-linux-musl (or aarch64-linux-android, etc.).
    2. cfg resolution: target_os = "linux" but target_env = "musl" (not "gnu"), and not macOS/BSD/Windows → the #[cfg(not(any(...)))] fallback at line 3339 is selected.
    3. The fallback is pub extern "C" fn Bun__captureStackTrace(begin: usize, out: *mut usize, cap: usize) -> usize { let _ = (begin, out, cap); 0 } — no unsafe keyword, so it's a safe function (it never dereferences out, so this is technically correct).
    4. capture_stack_trace at line 3352 evaluates unsafe { Bun__captureStackTrace(...) }. The callee is safe → the unsafe block contains no unsafe operations.
    5. rustc emits warning: unnecessary \unsafe` block#[warn(unused_unsafe)]` (default level).
    6. robobun build #56711 lists exactly this: src/bun_core/lib.rs#L3352 - unnecessary \unsafe` blockon 🐧 aarch64-musl, x64-musl, x64-android, aarch64-android, x64-musl-baseline — the precise set of targets that match the fallback's#[cfg(not(...))]` gate.

    Addressing the refutation

    One reviewer argued for refuting on three grounds, two of which I agree with and have folded into the framing above: (a) "warning ≠ build failure" — correct; the original "fails build-rust" claim was overstated, and this comment is filed accordingly. (b) "not from this PR" — correct; verified via git merge-base --is-ancestor and the empty two-dot diff, hence filed as pre-existing. (c) "the fallback being safe is arguably correct" — also true (it ignores out entirely), but that just shifts where the fix goes: either mark the fallback unsafe for signature consistency, or #[allow(unused_unsafe)] the wrapper with a note that one cfg variant is safe. Either resolves the warning; the former keeps the three definitions interchangeable.

    Impact

    None functionally — a warn-level lint on five non-tier-1 targets. Flagged only because it shows up in this PR's CI annotations and is a one-keyword fix; entirely fine to defer to whichever PR next touches the unused_unsafe family in bun_core.

    Fix

    /// # Safety
    /// `out` must be writable for `cap` `usize` slots (or null/`cap == 0`).
    /// This fallback never dereferences `out`; `unsafe` is for signature parity
    /// with the other cfg-gated definitions so the shared wrapper's `unsafe { }`
    /// is not flagged `unused_unsafe` on musl/Android.
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn Bun__captureStackTrace(begin: usize, out: *mut usize, cap: usize) -> usize {
        let _ = (begin, out, cap);
        0
    }

Comment thread packages/bun-usockets/src/crypto/openssl.c
Comment thread packages/bun-usockets/src/libusockets.h
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/tls.ts Outdated
Comment thread src/js/node/tls.ts
@cirospaciari cirospaciari changed the title net,tls: Node.js compatibility — TLS versions, setDefaultCACertificates, listen/reset/connect fixes, +42 tests net,tls: Node.js compatibility — reset/read-error semantics, graceful HTTP/WS client closes, TLS versions, setDefaultCACertificates, +48 tests May 21, 2026
@cirospaciari cirospaciari changed the title net,tls: Node.js compatibility — reset/read-error semantics, graceful HTTP/WS client closes, TLS versions, setDefaultCACertificates, +48 tests net,tls: Node.js compatibility — half-open/reset semantics, graceful HTTP/WS client closes, TLS versions, setDefaultCACertificates, +49 tests May 22, 2026
@cirospaciari cirospaciari changed the title net,tls: Node.js compatibility — half-open/reset semantics, graceful HTTP/WS client closes, TLS versions, setDefaultCACertificates, +49 tests net,tls: Node.js compatibility — half-open/reset/write semantics, graceful HTTP/WS client closes, TLS versions, setDefaultCACertificates, +50 tests May 22, 2026
@cirospaciari cirospaciari changed the title net,tls: Node.js compatibility — half-open/reset/write semantics, graceful HTTP/WS client closes, TLS versions, setDefaultCACertificates, +50 tests net,tls: Node.js compatibility — half-open/reset/write semantics, real connect errors, graceful HTTP/WS client closes, +51 tests May 22, 2026
Comment thread src/js/node/net.ts Outdated
Comment on lines +2738 to +2743
// context is transferred to the socket in afterConnectMultiple.
if (hasObserver("net")) {
startPerf(context, kPerfHooksNetConnectContext, {
type: "net",
name: "connect",
detail: { host: address, port, addressType },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The startPerf detail at line 2743 includes addressType, but the sibling single-address path at line 2588 (also new in this PR) and Node's lib/net.js both use detail: { host, port } only — and the inline comment at line 2736 says 'Match the single-address path (and Node)' while the detail object matches neither. One-token fix: drop , addressType.

Extended reasoning...

What the gap is

Both startPerf calls in net.ts are new in this PR. The single-address internalConnect path at line 2588 builds:

startPerf(self, kPerfHooksNetConnectContext, {
  type: "net",
  name: "connect",
  detail: { host: address, port },
});

The multiple-address internalConnectMultiple path at line 2743 builds:

startPerf(context, kPerfHooksNetConnectContext, {
  type: "net",
  name: "connect",
  detail: { host: address, port, addressType },
});

— with an extra addressType key. The inline comment at line 2736 explicitly says 'Match the single-address path (and Node)', but the detail object matches neither: the single-address path omits addressType, and Node's lib/net.js uses detail: { host: address, port } for internalConnect and detail: { host: req.address, port: req.port } for internalConnectMultiple — no addressType in either.

Why CI doesn't catch it

The newly-ported test-net-perf_hooks.js only asserts assert.strictEqual(!!entry.detail.host, true) and assert.strictEqual(!!entry.detail.port, true) (lines 57-58); an extra own key on detail is invisible. And in practice the test takes the single-address path anyway (the toAttempt.length === 1 branch in lookupAndConnectMultiple falls back to plain internalConnect when localhost resolves to one address).

Step-by-step proof

  1. net.connect({ host: 'dual-stack-host', port }) with autoSelectFamily: true and a host that resolves to both IPv4 and IPv6 → lookupAndConnectMultipleinternalConnectMultiple(context).
  2. The first attempt dispatches kConnectTcp; err === 0.
  3. Line 2739: hasObserver('net') → true (a PerformanceObserver is watching 'net').
  4. Line 2740-2744: startPerf(context, kPerfHooksNetConnectContext, { type: 'net', name: 'connect', detail: { host: address, port, addressType } })addressType is 4 or 6.
  5. The attempt wins → afterConnectMultiple transfers context[kPerfHooksNetConnectContext] to the socket → afterConnect calls stopPerf, which builds the entry with detail: ctx.detail (shared.ts stopPerf).
  6. The observer's callback receives an entry with entry.detail = { host, port, addressType } — an extra own key that neither Node nor the single-address path produces.
Path entry.detail
Node internalConnect { host, port }
Node internalConnectMultiple { host, port }
Bun internalConnect (line 2588, this PR) { host, port }
Bun internalConnectMultiple (line 2743, this PR) { host, port, addressType }

Impact / why nit

An extra own property on a PerformanceEntry.detail object that almost nobody inspects directly — consumers that read .host/.port are unaffected. Only code doing Object.keys(entry.detail) or assert.deepStrictEqual(entry.detail, { host, port }) would observe the difference. Both startPerf calls are new code added by this PR, so this is an internal inconsistency in code the PR adds, and the inline comment at line 2736 misstates the match. Same flavor of cosmetic-shape nit as the ~30 error-shape gaps already accepted on this PR.

Fix

Drop the extra key so the comment is true:

detail: { host: address, port },

Comment thread src/js/node/net.ts Outdated
Comment on lines +790 to +793
// Node only enforces client-cert verification (and the resulting destroy)
// when the server actually requested a cert; a server without requestCert
// leaves `authorized` false but keeps the connection open.
if (self._rejectUnauthorized && self._requestCert) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The added && self._requestCert at line 793 is dead code — it sits inside the outer if (self._requestCert) block at line 785, so it's always true. The new comment ('Node only enforces client-cert verification … when the server actually requested a cert') describes a guard the outer if already provides; one-token cleanup is to drop && self._requestCert. (Line 801's else if (self._requestCert) has the same redundancy but is unchanged pre-PR context.)

Extended reasoning...

What the issue is

In ServerHandlers.handshake (src/js/node/net.ts:785-803), the PR changes the inner condition from if (self._rejectUnauthorized) to if (self._rejectUnauthorized && self._requestCert) and adds a comment explaining that Node 'only enforces client-cert verification … when the server actually requested a cert; a server without requestCert leaves authorized false but keeps the connection open'. But this line is already inside the outer if (self._requestCert) { block at line 785 (unchanged context in the diff), so self._requestCert is always truthy at line 793 and the added && self._requestCert is dead code.

Step-by-step proof

785    if (self._requestCert) {            // ← outer guard (unchanged context)
786      if (verifyError) {
787        self.authorized = false;
788        self.authorizationError = verifyError.code || verifyError.message;
789        server?.emit('tlsClientError', verifyError, self);
790        // Node only enforces client-cert verification (and the resulting destroy)
791        // when the server actually requested a cert; a server without requestCert
792        // leaves `authorized` false but keeps the connection open.
793        if (self._rejectUnauthorized && self._requestCert) {   // ← && self._requestCert always true
Step self._requestCert Reaches line 793? && self._requestCert evaluates to
truthy passes line 785 yes always true (already checked)
falsy line 785 is false → block skipped no unreachable

So in every case where line 793 executes, self._requestCert is truthy and the added conjunct is a no-op. The pre-PR code was just if (self._rejectUnauthorized), which was already correct given the outer guard.

On the 'defensive re-read' counterargument

Line 789 (server?.emit('tlsClientError', verifyError, self)) runs user JS between the outer check and line 793, so a listener could theoretically mutate self._requestCert. Three reasons this isn't the intent:

  • _requestCert is an internal config flag set once at accept time (onconnection does _socket._requestCert = requestCert), never documented as mutable mid-handshake.
  • The new comment frames the guard as 'a server without requestCert' — i.e. the static configuration the outer if already covers — not as a defensive re-read after user JS.
  • Line 801 (} else if (self._requestCert), unchanged pre-PR context) has the same redundancy with no intervening user JS between line 785 and it, so the pattern is clearly an editing artifact rather than deliberate.

Why nothing catches it

An always-true conjunct compiles fine and behaves identically to the pre-PR code. No test can observe the difference because there isn't one.

Impact

Zero runtime impact — pure dead code with a misleading comment in PR-added code. This is the same flavor of editing-artifact cleanup the author has accepted ~10 times on this PR (resolved 3295640306: redundant ksecureContext early-assignment + unused validateInt32 import; 3285120525: dangling comment fragment; 3291686001: misplaced #[inline]; etc.). Filed as a nit.

Fix

Drop the redundant conjunct so the code matches the comment's intent (which the outer guard already implements):

if (self._rejectUnauthorized) {

(Line 801's else if (self._requestCert)else is the same cleanup but is pre-existing unchanged context, so out of scope.)

… [build images]

When session.goaway() is called without a lastStreamID (or with the
JS-default 0), the auto-filled value was the highest stream id seen in
either direction — for a client that is its own (odd) request id.
RFC 9113 §6.8 says GOAWAY's last-stream-id refers to streams the
RECEIVER initiated; nghttp2 servers reject a wrong-parity id with
NGHTTP2_ERR_PROTO (-505) and tear the connection down.

Track the highest peer-initiated id separately (odd for a server, even
for a client) and use it for the auto value — node's last_proc_stream_id
semantics. Verified: with the fix, the spawned-node fixture in
node-http2.test.js no longer hits the -505 (10/10 clean; was 5/10).
…ey surface [build images]

Half-open/reset/write semantics, server TLSSocket wrap, session/keylog,
SNICallback/ALPNCallback, pfx, OpenSSL error shapes, addCACert, local
binding (+305 tests).
@cirospaciari cirospaciari force-pushed the claude/port-node-net-tls-tests-2 branch from 6aac31a to 731609d Compare June 16, 2026 17:43
Base automatically changed from claude/upgrade-nodejs-26-v2 to main June 16, 2026 20:38
@cirospaciari cirospaciari force-pushed the claude/port-node-net-tls-tests-2 branch from faefb4a to 3c54213 Compare June 16, 2026 20:53
Comment thread src/runtime/api/bun/SecureContext.rs Outdated
…re borrow, register ERR_TLS_ALPN_CALLBACK_INVALID_RESULT, drop redundant requestCert guards, drop addressType from connect perf detail [build images]
@cirospaciari cirospaciari merged commit bd8edc7 into main Jun 17, 2026
88 of 90 checks passed
@cirospaciari cirospaciari deleted the claude/port-node-net-tls-tests-2 branch June 17, 2026 01:09
robobun added a commit that referenced this pull request Jun 17, 2026
Resolved conflict in src/runtime/socket/Handlers.rs: #31155 added four new
handler callbacks (session, keylog, serverName, alpnCallback). Folded them
into the validate-before-construct scheme so they are type-checked before the
Handlers struct exists and assigned via the infallible single-arg macro,
keeping the error path abort-free.
robobun added a commit that referenced this pull request Jun 17, 2026
… path

The rebase picked up #31155, which added a third upgradeTLS site:
Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")], reached via
new tls.TLSSocket(acceptedSocket, { isServer: true }) (server-side
STARTTLS). The native upgradeTLS honors the isServer option and still
enables the ssl_raw_tap ciphertext hook, and the raw half keeps the
accepted socket's ServerHandlers, so post-upgrade ciphertext was
re-pushed as cleartext data on the original accepted socket, the same
class as the client-side #32239 bug.

Set kupgradedToTLS on the accepted connection at the server upgrade site
and guard ServerHandlers.data (after the idle-timer/byteRead update, like
the client handlers). Add a server-side STARTTLS regression test.
robobun added a commit that referenced this pull request Jun 17, 2026
A node:net socket whose peer disappears while the socket has a stuck half
(write-backpressure that can never flush, or a paused readable with nothing
buffered that never consumes the queued EOF) never emitted 'close'. The
process hung forever, spinning on the half-closed fd; server.close() never
completed.

The socket is a Duplex with { emitClose:false, autoDestroy:true }, so 'close'
only fires from _destroy, and autoDestroy only runs _destroy once both halves
finish. When a half is stuck it never does: the socket lingers as a zombie
(destroyed=false), server._connections never decrements, and a peer that keeps
the fd hot spins the loop.

Two changes:

- SocketEmitEndNT now follows push(null) with read(0) (as SocketHandlers2.close
  already does), so a paused stream with nothing buffered still schedules
  'end' and can auto-destroy instead of stalling.

- The native close handlers force teardown via a new destroyAfterClose(): when
  there is nothing left to read (readableLength === 0) and the socket is not
  already destroyed, schedule destroy() on setImmediate. Deferring past the
  nextTick queue lets the pending 'end' (from push(null)+read(0)) and any
  "write EBADF" callback _write scheduled for a write that raced the close
  fire first (test-net-socket-close-after-end.js,
  test-net-socket-write-after-close.js). Readers with data still buffered are
  left alone so they can consume it and emit 'end' before 'close', as Node
  does. No error is passed: real read errors already surface through the
  dedicated error paths in SocketEmitEndNT, and the passive peer close that
  lands here is benign.

Two regression tests: (1) two Bun processes over a UDS, survivor builds
backpressure, peer SIGKILLed mid-flight, survivor must get one 'close' and
exit; (2) a paused server socket whose peer ends, 'close' fires and
server.close() completes. Both hang on the baked bun. All 141 test-net-*
Node parallel tests pass under bun bd (ASAN), along with node-net-server
(18/18), the http/tls parallel tests that previous iterations regressed,
and regression/issue/12117.

Rebased onto #31155, which substantially reworked these close handlers
(ECONNRESET-shaped destroy in SocketEmitEndNT, kwriteCallback flush); those
changes are kept and destroyAfterClose sits after them as the stuck-writable
fallback they leave open.
robobun added a commit that referenced this pull request Jun 17, 2026
A node:net socket whose peer disappears while the socket has a stuck half
(write-backpressure that can never flush, or a paused readable with nothing
buffered that never consumes the queued EOF) never emitted 'close'. The
process hung forever, spinning on the half-closed fd; server.close() never
completed.

The socket is a Duplex with { emitClose:false, autoDestroy:true }, so 'close'
only fires from _destroy, and autoDestroy only runs _destroy once both halves
finish. When a half is stuck it never does: the socket lingers as a zombie
(destroyed=false), server._connections never decrements, and a peer that keeps
the fd hot spins the loop.

Changes:

- SocketEmitEndNT now follows push(null) with read(0) (as SocketHandlers2.close
  already does), so a paused stream with nothing buffered still schedules
  'end' and can auto-destroy instead of stalling.

- SocketEmitEndNT's pending-write flush now also gates on self[kclosed] so a
  clean close (no error) still fails the in-flight write — otherwise a paused
  reader with buffered data plus a backpressured write leaves kWriting set and
  autoDestroy can never fire even after the reader drains.

- The native close handlers force teardown via destroyAfterClose(): when
  there is nothing left to read (readableLength === 0) and the socket is not
  already destroyed, schedule destroy() on setImmediate. Deferring past the
  nextTick queue lets the pending 'end' (from push(null)+read(0)) and any
  "write EBADF" callback _write scheduled for a write that raced the close
  fire first (test-net-socket-close-after-end.js,
  test-net-socket-write-after-close.js). Readers with data still buffered are
  left alone so they can consume it and emit 'end' before 'close', as Node
  does. No error is passed: real read errors already surface through the
  dedicated error paths in SocketEmitEndNT, and the passive peer close that
  lands here is benign.

Two regression tests: (1) two Bun processes over a UDS, survivor builds
backpressure, peer SIGKILLed mid-flight, survivor must get one 'close' and
exit; (2) a paused server socket whose peer ends, 'close' fires and
server.close() completes. Both hang on the baked bun. All 141 test-net-*
Node parallel tests pass under bun bd (ASAN), along with node-net-server
(18/18), the http/tls parallel tests that previous iterations regressed,
and regression/issue/12117.

Rebased onto #31155, which substantially reworked these close handlers
(ECONNRESET-shaped destroy in SocketEmitEndNT, kwriteCallback flush); those
changes are kept and destroyAfterClose sits after them as the stuck-writable
fallback they leave open.
robobun added a commit that referenced this pull request Jun 17, 2026
A node:net socket whose peer disappears while the socket has a stuck half
(write-backpressure that can never flush, or a paused readable with nothing
buffered that never consumes the queued EOF) never emitted 'close'. The
process hung forever, spinning on the half-closed fd; server.close() never
completed.

The socket is a Duplex with { emitClose:false, autoDestroy:true }, so 'close'
only fires from _destroy, and autoDestroy only runs _destroy once both halves
finish. When a half is stuck it never does: the socket lingers as a zombie
(destroyed=false), server._connections never decrements, and a peer that keeps
the fd hot spins the loop.

Changes:

- SocketEmitEndNT now follows push(null) with read(0) (as SocketHandlers2.close
  already does), so a paused stream with nothing buffered still schedules
  'end' and can auto-destroy instead of stalling.

- SocketEmitEndNT's pending-write flush now also gates on self[kclosed] so a
  clean close (no error) still fails the in-flight write — otherwise a paused
  reader with buffered data plus a backpressured write leaves kWriting set and
  autoDestroy can never fire even after the reader drains.

- The native close handlers force teardown via destroyAfterClose(): when
  there is nothing left to read (readableLength === 0) and the socket is not
  already destroyed, schedule destroy() on setImmediate. Deferring past the
  nextTick queue lets the pending 'end' (from push(null)+read(0)) and any
  "write EBADF" callback _write scheduled for a write that raced the close
  fire first (test-net-socket-close-after-end.js,
  test-net-socket-write-after-close.js). Readers with data still buffered are
  left alone so they can consume it and emit 'end' before 'close', as Node
  does. No error is passed: real read errors already surface through the
  dedicated error paths in SocketEmitEndNT, and the passive peer close that
  lands here is benign.

Two regression tests: (1) two Bun processes over a UDS, survivor builds
backpressure, peer SIGKILLed mid-flight, survivor must get one 'close' and
exit; (2) a paused server socket whose peer ends, 'close' fires and
server.close() completes. Both hang on the baked bun. All 141 test-net-*
Node parallel tests pass under bun bd (ASAN), along with node-net-server
(18/18), the http/tls parallel tests that previous iterations regressed,
and regression/issue/12117.

Rebased onto #31155, which substantially reworked these close handlers
(ECONNRESET-shaped destroy in SocketEmitEndNT, kwriteCallback flush); those
changes are kept and destroyAfterClose sits after them as the stuck-writable
fallback they leave open.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants