Skip to content

node:net, node:tls: socket teardown events that still differ from Node (spurious 'end', ERR_SOCKET_CLOSED in place of the real error) #43381

Open

Description

@robobun

This issue tracks flows in node:net and node:tls where the events of a socket differ from Node. The reviews of #43250 found them, and each one is older than that PR. #43250 fixes item 1 and changes item 3. It does not change the other items.

Versions: Node v26.3.0, Linux x64. "canary" is Bun 1.4.3-canary.1+367d939d9. "main" is a debug build of 2d6d4454f3. "PR build" is a debug build of #43250 at 7958eadddc.

teardown.js (items 1 to 3)
// Usage: node teardown.js <plain-destroy | no-listener | not-reading>
const net = require("node:net");
const scenario = process.argv[2];
const shape = err => (err ? [err.code, err.syscall].filter(Boolean).join(" ") : "ok");
const events = [];
process.on("uncaughtException", err => events.push("uncaught " + shape(err)));
let client, accepted, ready = 0;
const onReady = () => ++ready === 2 && setImmediate(run);
const server = net.createServer(socket => { accepted = socket; onReady(); });
server.listen(0, "127.0.0.1", () => {
  client = net.connect(server.address().port, "127.0.0.1", () => {
    if (scenario === "not-reading") client.pause(); // Node then never starts the reads of this handle
    onReady();
  });
});
function run() {
  accepted.on("error", () => {});
  if (scenario !== "no-listener") client.on("error", err => events.push("error " + shape(err)));
  client.on("end", () => events.push("end"));
  client.on("close", hadError => {
    events.push("close " + hadError);
    setImmediate(() => { console.log(JSON.stringify(events)); process.exit(0); });
  });
  if (scenario === "plain-destroy") return client.destroy();
  // Write until the kernel stops taking the bytes, then queue one more write.
  const chunk = Buffer.alloc(1024 * 1024, "a");
  let writes = 0;
  while (writes < 256 && client.writableLength === 0) {
    const nth = ++writes;
    client.write(chunk, err => { if (nth === writes) events.push("write " + shape(err)); });
  }
  client.write("b", err => events.push("queued " + shape(err)));
  accepted.resetAndDestroy();
}
tls-peer-close.js (item 4)
// A TLS client writes before the handshake completes. The peer closes the TCP connection when it has the ClientHello.
const net = require("node:net");
const tls = require("node:tls");
const shape = err => (err ? [err.code, err.syscall].filter(Boolean).join(" ") : "ok");
const events = [];
const server = net.createServer(peer => { peer.on("error", () => {}); peer.once("data", () => peer.destroy()); });
server.listen(0, "127.0.0.1", () => {
  const client = tls.connect({ port: server.address().port, host: "127.0.0.1", rejectUnauthorized: false });
  client.on("error", err => events.push("error " + shape(err)));
  client.on("end", () => events.push("end"));
  client.on("close", hadError => {
    events.push("close " + hadError);
    setImmediate(() => { console.log(JSON.stringify(events)); process.exit(0); });
  });
  client.write("hello", err => events.push("write " + shape(err)));
});
holders.js (item 3)
// Holder states for a write in flight when the peer closes. One process, the cases run one after the other.
const net = require("node:net");
const shape = err => (err ? [err.code, err.syscall].filter(Boolean).join(" ") : "ok");
const chunk = Buffer.alloc(64 * 1024 * 1024);
const reset = (holder, peer) => peer.resetAndDestroy();
const finThenDestroy = (holder, peer) => {
  peer.end();
  holder.once("end", () => setImmediate(() => peer.destroy()));
};
const cases = {
  "1 reads, peer reset": { teardown: reset },
  "2 client paused in 'connect', peer reset": { pauseInConnect: true, teardown: reset },
  "3 accepted with pauseOnConnect, peer reset": { holderSide: "server", serverOpts: { pauseOnConnect: true }, teardown: reset },
  "4 client paused after 'connect', peer reset": { prep: holder => holder.pause(), teardown: reset },
  "5 paused with 1 MB unread, peer reset": {
    prep: (holder, peer) =>
      new Promise(resolve => {
        holder.pause();
        peer.write(Buffer.alloc(1024 * 1024), () => setTimeout(resolve, 100));
      }),
    teardown: reset,
  },
  "6 client, allowHalfOpen, peer FIN then destroy": {
    serverOpts: { allowHalfOpen: true },
    clientOpts: { allowHalfOpen: true },
    teardown: finThenDestroy,
  },
  "7 client, peer FIN then destroy": { teardown: finThenDestroy },
  "8 accepted, peer FIN then destroy": { holderSide: "server", teardown: finThenDestroy },
  "9 accepted, allowHalfOpen, peer FIN then destroy": {
    holderSide: "server",
    serverOpts: { allowHalfOpen: true },
    teardown: finThenDestroy,
  },
  "10 client, allowHalfOpen, peer FIN then reset": {
    serverOpts: { allowHalfOpen: true },
    clientOpts: { allowHalfOpen: true },
    teardown: (holder, peer) => {
      peer.end();
      holder.once("end", () => setImmediate(() => peer.resetAndDestroy()));
    },
  },
};
const runCase = name =>
  new Promise(resolve => {
    const c = cases[name];
    let client, accepted, ready = 0, done = false;
    const onReady = () => ++ready === 2 && setImmediate(run);
    const server = net.createServer(c.serverOpts || {}, socket => {
      accepted = socket;
      onReady();
    });
    server.listen(0, "127.0.0.1", () => {
      client = net.connect({ port: server.address().port, host: "127.0.0.1", ...c.clientOpts }, () => {
        if (c.pauseInConnect) client.pause();
        onReady();
      });
    });
    async function run() {
      const [holder, peer] = c.holderSide === "server" ? [accepted, client] : [client, accepted];
      const events = [];
      const report = why => {
        if (done) return;
        done = true;
        if (why) events.push(why);
        resolve(events);
        holder.destroy();
        peer.destroy();
        server.close();
      };
      peer.on("error", () => {});
      holder.on("error", err => events.push("error " + shape(err)));
      holder.on("end", () => events.push("end"));
      holder.on("close", hadError => {
        events.push("close " + hadError);
        setImmediate(report);
      });
      if (c.prep) await c.prep(holder, peer);
      let writes = 0;
      while (writes < 8 && holder.writableLength === 0) {
        const nth = ++writes;
        holder.write(chunk, err => {
          if (nth === writes) events.push("write " + shape(err));
        });
      }
      if (holder.writableLength === 0) return report("no write in flight");
      holder.write("b", err => events.push("queued " + shape(err)));
      c.teardown(holder, peer);
      setTimeout(report, 4000, "no 'close' after 4 s");
    }
  });
(async () => {
  for (const name in cases) console.log(name.padEnd(50) + JSON.stringify(await runCase(name)));
  process.exit(0);
})();
write-throw.js (item 6)
// Usage: WHERE=write-cb|drain node write-throw.js
// An accepted socket writes two 4 MB chunks to a client that does not read yet, so the first write waits for the socket.
const net = require("node:net");
const WHERE = process.env.WHERE;
const ev = [];
process.on("uncaughtException", e => ev.push("uncaught:" + e.message));
const boom = tag => {
  throw new Error("boom-" + tag);
};
const BIG = Buffer.alloc(4 * 1024 * 1024, "a");
let client;
const server = net.createServer(s => {
  s.on("error", e => ev.push("error:" + e.message));
  s.on("close", had => {
    ev.push("close:" + had);
    server.close(() => console.log(JSON.stringify(ev)));
  });
  if (WHERE === "drain") {
    s.once("drain", () => setImmediate(() => s.end(() => ev.push("end cb"))));
    s.once("drain", () => (ev.push("drain"), boom("drain")));
  }
  const waited = !s.write(BIG, () => {
    ev.push("write#1");
    if (WHERE === "write-cb") boom("cb");
  });
  s.write(BIG, () => ev.push("write#2"));
  ev.push("waited:" + waited);
  if (WHERE !== "drain") s.end(() => ev.push("end cb"));
  client.resume();
});
server.listen(0, "127.0.0.1", () => {
  client = net.connect(server.address().port, "127.0.0.1");
  client.pause();
  client.on("end", () => client.end());
  client.on("error", () => {});
});
flush-fails.js (item 7, Bun debug build) and not-reading.js (item 7, Node reference)
// flush-fails.js. Usage: [HTTP=1] bun-debug flush-fails.js
// The first send takes 1 byte, so the write waits. The flush on the next writable event fails with EPIPE.
const { socketFaultInjection: fault } = require("bun:internal-for-testing");
const net = require("node:net");
const http = require("node:http");
const ev = [];
const shape = e => (e ? [e.code, e.syscall].filter(Boolean).join(" ") : "ok");
let client, accepted, ready = 0;
const onReady = () => ++ready === 2 && setImmediate(run);
const server = net.createServer(s => { accepted = s; onReady(); });
server.listen(0, "127.0.0.1", () => { client = net.connect(server.address().port, "127.0.0.1", onReady); });
function run() {
  client.on("error", () => {});
  accepted.on("error", e => ev.push("error " + shape(e)));
  accepted.on("close", had => {
    ev.push("close " + had);
    setImmediate(() => { fault.clear(); console.log(JSON.stringify(ev)); client.destroy(); server.close(); });
  });
  const fd = accepted._handle.fd;
  fault.set({ syscall: "send", action: "short", bytes: 1, repeat: 1, fd });
  if (process.env.HTTP) {
    const res = new http.ServerResponse({ method: "GET", httpVersionMajor: 1, httpVersionMinor: 1 });
    res.assignSocket(accepted);
    res.on("finish", () => ev.push("res finish"));
    res.end("hello", () => ev.push("res end cb"));
  } else {
    accepted.write("hello", e => ev.push("write " + shape(e) + " _hadError=" + accepted._hadError));
  }
  fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: -1, fd });
}
// not-reading.js. Usage: [HTTP=1] node not-reading.js
// The accepted socket does not read (pauseOnConnect), so Node learns of the peer's reset from the write.
const net = require("node:net");
const http = require("node:http");
const ev = [];
const shape = e => (e ? [e.code, e.syscall].filter(Boolean).join(" ") : "ok");
let client;
const server = net.createServer({ pauseOnConnect: true }, s => {
  s.on("error", e => ev.push("error " + shape(e)));
  s.on("close", had => {
    ev.push("close " + had);
    setImmediate(() => { console.log(JSON.stringify(ev)); server.close(); });
  });
  const chunk = Buffer.alloc(64 * 1024 * 1024, "a");
  if (process.env.HTTP) {
    const res = new http.ServerResponse({ method: "GET", httpVersionMajor: 1, httpVersionMinor: 1 });
    res.assignSocket(s);
    res.on("finish", () => ev.push("res finish"));
    res.writeHead(200, { "content-length": String(chunk.length) });
    res.end(chunk, () => ev.push("res end cb"));
  } else {
    s.write(chunk, e => ev.push("write " + shape(e) + " _hadError=" + s._hadError));
  }
  setImmediate(() => client.resetAndDestroy());
});
server.listen(0, "127.0.0.1", () => {
  client = net.connect(server.address().port, "127.0.0.1");
  client.pause();
  client.on("error", () => {});
});
https-cert.js (item 8)
// Usage: node https-cert.js cert.json
// An https request with a body, to a server whose certificate the client rejects.
const CERT = JSON.parse(require("node:fs").readFileSync(process.argv[2], "utf8"));
const tls = require("node:tls");
const https = require("node:https");
const shape = err => (err ? [err.code, err.syscall].filter(Boolean).join(" ") : "ok");
const events = [];
const server = tls.createServer(CERT, socket => socket.on("error", () => {}));
server.on("tlsClientError", () => {});
server.listen(0, "127.0.0.1", () => {
  const req = https.request({ port: server.address().port, host: "127.0.0.1", method: "POST", agent: false });
  req.on("finish", () => events.push("finish"));
  req.on("error", err => events.push("error " + err.code));
  req.on("close", () => {
    events.push("close");
    setImmediate(() => {
      console.log(JSON.stringify(events));
      server.close();
    });
  });
  req.write("hello", err => events.push("write " + shape(err)));
  req.end("world");
});

cert.json holds { "key": "...", "cert": "..." } of a self-signed certificate.

http-server-socket.js (item 10)
// The socket that node:http's server hands out (req.socket) holds a write in flight. Then: destroy(), destroy(err), or a peer reset.
const http = require("node:http");
const net = require("node:net");
const shape = err => (err ? [err.code, err.syscall].filter(Boolean).join(" ") : "ok");
const boom = Object.assign(new Error("boom"), { code: "EBOOM" });
const chunk = Buffer.alloc(64 * 1024 * 1024);
const runCase = teardown =>
  new Promise(resolve => {
    const events = [];
    let client;
    const server = http.createServer((req, res) => {
      const socket = req.socket;
      socket.on("error", err => events.push("error " + shape(err)));
      socket.on("end", () => events.push("end"));
      socket.on("close", hadError => {
        events.push("close " + hadError);
        setImmediate(() => {
          resolve(events);
          client.destroy();
          server.close();
        });
      });
      let writes = 0;
      while (writes < 8 && socket.writableLength === 0) {
        const nth = ++writes;
        socket.write(chunk, err => {
          if (nth === writes) events.push("write " + shape(err));
        });
      }
      if (socket.writableLength === 0) events.push("no write in flight");
      socket.write("b", err => events.push("queued " + shape(err)));
      setImmediate(() => {
        if (teardown === "destroy()") socket.destroy();
        else if (teardown === "destroy(err)") socket.destroy(boom);
        else client.resetAndDestroy();
      });
    });
    server.listen(0, "127.0.0.1", () => {
      client = net.connect(server.address().port, "127.0.0.1", () => client.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"));
      client.on("error", () => {});
      client.pause();
    });
    setTimeout(() => resolve(events.concat("no 'close' after 5 s")), 5000).unref();
  });
(async () => {
  for (const teardown of ["destroy()", "destroy(err)", "peer reset"]) {
    console.log(teardown.padEnd(14) + JSON.stringify(await runCase(teardown)));
  }
  process.exit(0);
})();

1. destroy() emits 'end'

Fixed by #43250, which takes the guard of #43392.

teardown.js plain-destroy: a connected socket calls destroy(). Nothing is in flight.

node:     ["close false"]
canary:   ["end","close false"]
PR build: ["close false"]

handle.close() runs the native close handler before _destroy returns. The handler (SocketHandlers2.close, and SocketEmitEndNT for accepted sockets, in src/js/node/net.ts) calls finishSocketEnd, which pushes EOF into a stream that is already destroyed. Node never pushes EOF after destroy(). A consumer that treats 'end' as a clean end of stream (pipe, pipeline, an HTTP parser) sees one where Node reports only 'close'.

2. Peer reset with a write in flight and no 'error' listener

teardown.js no-listener:

node:     ["uncaught ECONNRESET read","write ECANCELED write","queued ECONNRESET read","close true"]
canary:   ["write ERR_SOCKET_CLOSED","queued ERR_SOCKET_CLOSED","uncaught ERR_SOCKET_CLOSED","close true"]
PR build: ["write ERR_SOCKET_CLOSED","queued ERR_SOCKET_CLOSED","uncaught ERR_SOCKET_CLOSED","close true"]

The close handlers destroy the socket with the read error only when the socket has an 'error' listener. Without one they end the stream, and the block at the end of the handler fails the parked write with ERR_SOCKET_CLOSED. That failure then destroys the socket, so the uncaught error is ERR_SOCKET_CLOSED and the real cause (ECONNRESET, syscall read) is lost. The comment in SocketEmitEndNT describes the no-listener case as a silent close. With a write in flight it is not silent. #43392 is open for this item and for item 4.

3. The peer closes a socket that does not read

teardown.js not-reading: the client calls pause() in its 'connect' listener, so Node never starts the reads of that handle.

node:     ["write ECONNRESET write","queued ECONNRESET write","error ECONNRESET write","close true"]
canary:   ["error ECONNRESET read","close true"]
PR build: ["error ECONNRESET read","write ECANCELED write","queued ECONNRESET read","close true"]

Node learns of the reset from the write. In Bun the reset always arrives as a read error: packages/bun-usockets/src/loop.c runs the read loop for an error event on a socket that does not read (drain_for_error). On the canary the write callbacks never run. #43250 makes them run, but the errors stay different from Node.

The same holds after the peer's FIN, when Node has stopped the reads of the handle. holders.js runs ten holder states. Node fails the write itself in eight of them:

holder state Node v26.3.0 PR build
1. reads, peer reset error ECONNRESET read, write ECANCELED write, queued ECONNRESET read, close true error ECONNRESET read, write ECANCELED write, queued ECONNRESET read, close true
2. client paused in 'connect', peer reset write ECONNRESET write, queued ECONNRESET write, error ECONNRESET write, close true error ECONNRESET read, write ECANCELED write, queued ECONNRESET read, close true
3. accepted with pauseOnConnect, peer reset write ECONNRESET write, queued ECONNRESET write, error ECONNRESET write, close true error ECONNRESET read, write ECANCELED write, queued ECONNRESET read, close true
4. client paused after 'connect', peer reset error ECONNRESET read, write ECANCELED write, queued ECONNRESET read, close true error ECONNRESET read, write ECANCELED write, queued ECONNRESET read, close true
5. paused with 1 MB unread, peer reset write ECONNRESET write, queued ECONNRESET write, error ECONNRESET write, close true error ECONNRESET read, write ECANCELED write, queued ECONNRESET read, close true
6. client, allowHalfOpen, peer FIN then destroy end, write EPIPE write, queued EPIPE write, error EPIPE write, close true end, error EPIPE read, write ECANCELED write, queued EPIPE read, close true
7. client, peer FIN then destroy end, write EPIPE write, queued EPIPE write, error EPIPE write, close true end, error EPIPE read, write ECANCELED write, queued EPIPE read, close true
8. accepted, peer FIN then destroy end, write EPIPE write, queued EPIPE write, error EPIPE write, close true end, error EPIPE read, write ECANCELED write, queued EPIPE read, close true
9. accepted, allowHalfOpen, peer FIN then destroy end, write EPIPE write, queued EPIPE write, error EPIPE write, close true end, error EPIPE read, write ECANCELED write, queued EPIPE read, close true
10. client, allowHalfOpen, peer FIN then reset end, write EPIPE write, queued EPIPE write, error EPIPE write, close true end, error EPIPE read, write ECANCELED write, queued EPIPE read, close true

On the canary no row calls the write callback: rows 1 to 5 give error ECONNRESET read, close true, and rows 6 to 10 give end, error EPIPE read, close true.

4. Peer closes during the TLS handshake with a write queued

tls-peer-close.js:

node:     ["end","error ECONNRESET","write ECANCELED write","close true"]
canary:   ["write ERR_SOCKET_CLOSED","error ERR_SOCKET_CLOSED","close true"]
PR build: ["write ERR_SOCKET_CLOSED","error ERR_SOCKET_CLOSED","close true"]

This is the same block as in item 2, reached through a native close with no error. Node's upstream test/parallel/test-tls-writewrap-leak.js covers this flow. It does not pass on a debug build, with net.ts from main or from #43250: both runs hit a timeout. Draft #35386 changes this block for TLS sockets.

5. Windows: a peer reset reports the write in flight as done

On Windows the reset reaches a socket that has a write in flight as a writable event first. on_writable in src/runtime/socket/socket_body.rs keeps its older drain contract on Windows (its comment explains why), so the write callback gets no error although the peer did not receive the bytes. I saw this on the Windows x64 canary with the helper of the new test in #43250, not with the scripts above:

["write ok","queued ECONNRESET write","error ECONNRESET write","close true"]

Linux gives ["error ECONNRESET read","write ECANCELED write","queued ECONNRESET read","close true"] for the same steps. The peer-reset cases of that test run on Linux only for this reason.

Items 6 and 7 are not teardown flows. They are about the same write (the one that waits for the socket) while the socket is alive. A third difference of that write, the async context that its callback runs in, has its own pull request: #46141.

6. A throw under the callback of a write that waited becomes a socket 'error'

WHERE=write-cb write-throw.js: the callback of the first write throws when the client reads.

node: ["waited:true","write#1","uncaught:boom-cb","write#2","end cb","close:false"]
main: ["waited:true","write#1","write#2","end cb","error:boom-cb","close:true"]

WHERE=drain write-throw.js: a 'drain' listener throws.

node: ["waited:true","write#1","drain","uncaught:boom-drain","end cb","close:false"]
main: ["waited:true","drain","write#2","error:boom-drain","end cb","close:true"]

Node reports the throw as an uncaught exception and the socket goes on. Bun reports it as the socket's 'error' and destroys the socket. The drain handlers (SocketHandlers.drain, SocketHandlers2.drain in src/js/node/net.ts) call the parked callback bare, inside the native drain dispatch. The throw unwinds into on_writable (src/runtime/socket/socket_body.rs), which hands it to the handler table's error entry. That entry is the path for transport failures.

This is the listener-throw class of #29761. #35347 fixed it for 'data' and 'connection' listeners. #29762 is open for 'connect' and 'ready'. The drain dispatch is the site that is left.

7. A flush that fails records the error before it calls the write back, and node:http then emits no 'finish'

flush-fails.js on a Bun debug build (an injected EPIPE on the flush of a write that waits), and not-reading.js under Node (a real reset, on a socket that does not read, which Node also reports as a failed write):

node, not-reading.js:        ["write ECONNRESET write _hadError=false","error ECONNRESET write","close true"]
main, flush-fails.js:        ["write EPIPE write _hadError=true","error EPIPE write","close true"]

node, HTTP=1 not-reading.js: ["res finish","res end cb","error ECONNRESET write","close true"]
main, HTTP=1 flush-fails.js: ["error EPIPE write","close true"]

ServerHandlers.error, SocketHandlers.error and SocketHandlers2.error (src/js/node/net.ts) set _hadError = true and then call the callback of the write that failed. onFinish in src/js/node/_http_outgoing.ts returns when socket._hadError is set, so a message whose last write fails this way emits no 'finish' and its end() callback does not run. Node sets _hadError in lib/internal/tls/wrap.js and in lib/_http_client.js only, not for a failed write of a plain socket.

The native side calls these handlers from on_writable when the flush of a buffered write gets a fatal send() errno (POSIX, plain TCP). On Linux a real reset reaches the socket as a read error first (item 3), so the repro uses the send-fault injector of debug builds. #43038 is open on the same handlers for a double 'error'.

8. A TLS write that is pending when the handshake fails

https-cert.js: an https request with a body, to a server whose certificate is rejected.

node:     ["error DEPTH_ZERO_SELF_SIGNED_CERT","write EBADF write","close"]
canary:   ["write ERR_SOCKET_CLOSED","finish","error DEPTH_ZERO_SELF_SIGNED_CERT","close"]
PR build: ["error DEPTH_ZERO_SELF_SIGNED_CERT","write ECANCELED write","close"]

Node's TLSWrap fails the write that it holds when the handshake fails, with EBADF in this flow. Bun cancels that write with the socket. #43250 fixes the order ('error', then the write callback, and no 'finish'). The code stays different from Node.

9. connect() before 'close'

A connect() in the same tick as destroy() works on main. #44327 keeps it for a connect request that destroy() cancels, and #43250 for a write in flight. The socket then gets the 'close' of the connection that it gave up after it has connected again. These rows are from the fixture of the new test in #43250 (a write in flight and one queued, destroy(), then connect() to a server that answers again):

node:     ["write ECANCELED write","queued ECANCELED write","error ECANCELED write"]
main:     ["write ERR_SOCKET_CLOSED","queued ERR_SOCKET_CLOSED","end","close false","data again"]
PR build: ["write ECANCELED write","queued ECANCELED write","close false","data again"]

Node fails the new connection with the cancel. Its documentation of socket.connect() says: "This function should only be used for reconnecting a socket after 'close' has been emitted or otherwise it may lead to undefined behavior." This item records the state. A maintainer has to rule on it first (see #43250).

10. The socket of a node:http server request reports every write as done

http-server-socket.js: req.socket writes 64 MB chunks to a client that does not read, then destroy(), destroy(err), or the client resets.

node, destroy():     ["write ECANCELED write","queued ECANCELED write","close false"]
node, destroy(err):  ["error EBOOM","write ECANCELED write","queued EBOOM","close true"]
node, peer reset:    ["error ECONNRESET read","write ECANCELED write","queued ECONNRESET read","close true"]

main, destroy():     ["no write in flight","write ok","queued ok","close undefined"]
main, destroy(err):  ["no write in flight","write ok","queued ok","error EBOOM","close undefined"]
main, peer reset:    ["no write in flight","write ok","queued ok","error ECONNRESET read","close undefined"]

That socket is NodeHTTPServerSocket (src/js/node/_http_server.ts), a subclass of net.Socket with its own _write and _destroy. Outside a CONNECT tunnel its _write calls back at once, so no write stays in flight, and its _destroy does not run the hook of #43250. #45017 is open for the 'close' of that socket.

Activity

  1. robobun commented on Sep 18, 2026

    @robobun
    CollaboratorAuthor

    Reproduced items 1 to 4 on main at 367d939 with the scripts from the issue. Items 3 and 5 are not changed.
    Fix for items 1, 2 and 4 in progress: #43392

  2. esaari commented on Sep 26, 2026

    @esaari

    A production data point that looks like item 2, in its 1.4.0 form.

    Setup: Linux x64 container, oven/bun:1.4.0-slim. An Express 4 app on node:http proxies upstream fetch() responses to browsers (Readable.fromWeb(body) piped into res). Browsers regularly abort in-flight requests when the user navigates away.

    What happened: on 1.4.0 the process died 6 times within about 15 minutes. Every crash printed exactly this, with no JS stack frames:

    EPIPE: broken pipe, read
       errno: -32,
     syscall: "read",
        code: "EPIPE"
    
    Bun v1.4.0 (Linux x64)
    

    There were zero such exits in the 9 days on 1.3.2 beforehand, and none since the service moved to 1.3.14.

    Why I think it's item 2: the message has Bun's native SystemError format. On 1.4.0 the net.ts close path does not reshape that error. It re-throws it (// Any other coded error (ETIMEDOUT, EPIPE, ...) keeps its identity., src/js/node/net.ts:672 at bun-v1.4.0). When no 'error' listener is attached, it goes to the parked write instead: pendingWrite(_err ?? $ERR_SOCKET_CLOSED()) (:691). An EPIPE/read close error on a socket with a write in flight and no listener would surface uncaught with exactly this shape and no frames. That block is not in 1.3.14.

    Caveats:

    • I haven't reproduced it in isolation. teardown.js no-listener does not crash on darwin arm64 1.4.0, and I don't have a Linux box handy right now.
    • I don't know which socket had no listener. Nothing in the app uses node:net directly.

    One note on #43392: it aligns this path with Node, and Node also throws here (uncaught ECONNRESET read). So for apps like this one the crash changes name but still happens. A process-level uncaughtException guard is what kept the service up. If Bun's own node:http or agent sockets can end up with a write in flight and no 'error' listener, Node would normally have attached one (e.g. socketErrorListener, the free-socket listener). That might be worth checking.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions