node:net: route throws from 'data'/'connection' listeners to uncaughtException and keep the socket alive
Fixes #29761
Problem
- A throw inside a
net.Socket'data'listener or anet.Server'connection'listener goes to the socket'error'listener and the socket closes. Node reportsuncaughtException, keeps the socket reading, and delivers the next chunk. - Cause:
src/js/node/net.tscallsself.push(buffer)andself.emit("connection")bare inside native callbacks.push()runs the'data'listeners synchronously. The throw unwinds into the native dispatch, which routes it to the handler table'serrorentry, the path for transport failures.
Fix
pushDataToSocketwrapsself.push(buffer)in the three handler tables with a try/catch that callsreportError(e)and leaves the socket alone.onconnectionwraps the'connection'emit the same way and still starts the read.- Matches Node:
onStreamReadcallsstream.push()bare, so the throw becomesuncaughtExceptionand nothing stops the handle. Node also skipsreadStopon a throw. Safe here too: a throw only comes from the flowing path, wherepush()returns true. - Transport failures do not pass through
push(), so theirerrorrouting is unchanged.Bun.listenandBun.connectare unchanged. - Verified:
test/js/node/net/node-net.test.tsruns seven fixture files (listener-throw-*-fixture.js: plain and TLS'data'throws on both sides, a'connection'throw, thepgpool shape from #34064, no handler) withbunRun. All seven fail on main, pass here, and print the same expected logs undernodev26.3.0 unchanged. The net, http and tls suites pass (Notes).
Background
- node:net is a JS
Duplexover a nativeBun.connect/Bun.listensocket. A handler table receives the native callbacks. - The native dispatch applies the public contract: a throwing handler is reported through the table's
errorhandler. node:net has to catch user throws at the JS boundary. reportError(e)runs theuncaughtExceptionhandlers synchronously, or prints the error and exits 1 when there is none. net.ts already uses it for the TLS handshake emits andonread.
Notes
Repro (server side). Node: ["connection","data:A","UNCAUGHT:data-boom","data:B","close:false"]. Bun before: ["connection","data:A","socket-error-event:data-boom","close:true"].
const net = require('node:net');
const ev = [], fin = () => { console.log(JSON.stringify(ev)); process.exit(0); };
process.on('uncaughtException', e => ev.push('UNCAUGHT:' + e.message));
const srv = net.createServer(s => {
ev.push('connection');
s.on('error', e => ev.push('socket-error-event:' + e.message));
s.on('data', d => { ev.push('data:' + d); if (String(d) === 'A') throw new Error('data-boom'); });
s.on('close', h => { ev.push('close:' + h); srv.close(); setTimeout(fin, 80); });
});
srv.listen(0, '127.0.0.1', () => {
const c = net.connect(srv.address().port, '127.0.0.1', () => {
c.write('A'); setTimeout(() => c.write('B'), 120); setTimeout(() => c.end(), 300);
});
c.on('error', () => {});
});
The script from #29761 (with listen(0)) now prints [Fatal] Uncaught Exception: nonExistentFunction is not defined and exits 1, the same as Node.
reportError is captured at module load (const reportError = globalThis.reportError) so user code that clobbers the global cannot defeat the routing.
History of this PR. The first revision also carried a uws_res_end_without_body guard and a node-http.test.ts test. Routing the throw correctly surfaced a second bug: ServerResponse.destroy() on an in-flight chunked response wrote Connection: close\r\n\r\n into the body, and the client parse error that produced had been swallowed by the same misrouting. #39364 fixed that on main with its own tests (node-http-transfer-encoding.test.ts), so this PR no longer touches libuwsockets.cpp.
http client parse errors: _http_client.ts gets parse errors as a return value from parser.execute(), not as a throw, so they do not pass through the push() catch. Probe: a server that answers Transfer-Encoding: chunked with ZZZ as the chunk size gives ["response:200","req-error:HPE_INVALID_CHUNK_SIZE","res-aborted","req-close","res-error:ECONNRESET"] on Node and on this branch.
Other listener paths probed against Node v26.3.0: 'end' and 'close' throws already match (both emit from a JS tick). A client 'connect'/'ready' throw is still routed to the socket 'error' listener. That is the scope of #29762 and is not changed here.
Sweeps on this branch (debug build, linux x64):
test/js/node/net/node-net.test.ts: 95 pass, 10 fail. The same 10 fail with main'snet.tsin this container:localhostresolves to::1first, and there is no outbound DNS.test/js/node/http/node-http.test.ts: 149 pass, 1 fail (request via http proxy, samelocalhostcause, fails on main).test/js/node/tls/node-tls-server.test.ts76/77 (same cause),node-tls-connect.test.ts45/45,node-tls-cert.test.ts28/28,node-http-transfer-encoding.test.ts36/36.test/js/node/test/parallel/test-net-*.js: 139/139 (test-net-pingpongflaked under 4x parallel load, passes alone).test-http-*.js: 384/386.test-http-agent-keepaliveandtest-http-client-timeout-optionfail with main'snet.tstoo.test-http-abort-client,test-http-client-aborted-event,test-http-server-capture-rejections,test-http-catch-uncaughtexceptionpass.test-tls-*.js: 185/186 (test-tls-client-allow-partial-trust-chainneedsbun test, a runner artifact).
Related: #34066 took the other approach (wrap every handler table entry, then socket.terminate() on a caught throw). Node leaves the socket alive, so that teardown is the wrong semantics for this bug. #29762 covers the lifecycle emits ('connect'/'ready').
no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/net/node-net.test.ts