diff --git a/docs/runtime/networking/udp.mdx b/docs/runtime/networking/udp.mdx index 8ca87d969294..7e557251c67e 100644 --- a/docs/runtime/networking/udp.mdx +++ b/docs/runtime/networking/udp.mdx @@ -129,6 +129,51 @@ const socket = await Bun.udpSocket({ }); ``` +### Handle errors + +Bun calls the `error` socket handler with the socket and the error when the `data` or `drain` handler throws. The socket stays open. + +Earlier versions of Bun call `error` with the error as the only argument. + +```ts +const server = await Bun.udpSocket({ + socket: { + data(socket, buf, port, addr) { + throw new Error("invalid packet"); + }, + error(socket, error) { + console.log(error.message); // "invalid packet" + console.log(socket.closed); // false + }, + }, +}); + +const client = await Bun.udpSocket({}); +client.send("Hello!", server.port, "127.0.0.1"); +``` + +If the socket has no `error` handler, Bun reports the thrown value as an uncaught exception. + +Bun does not wait for a promise that `data` or `drain` returns. If the promise rejects, Bun reports an unhandled rejection and does not call `error`. + +On Linux and macOS, Bun also calls `error` when a receive fails. For example, a send from a connected socket to a port with no listener makes the next receive fail with `ECONNREFUSED`: + +```ts +const socket = await Bun.udpSocket({ + connect: { + port: 41234, // no listener on this port + hostname: "127.0.0.1", + }, + socket: { + error(socket, error: Bun.SystemError) { + console.log(error.code); // "ECONNREFUSED" + }, + }, +}); + +socket.send("Hello"); +``` + ### Socket options UDP sockets support setting various socket options: diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index e6e36301b614..51b72ea1aefe 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7434,6 +7434,22 @@ declare module "bun" { flags: ReceiveFlags, ): void | Promise; drain?(socket: Socket): void | Promise; + /** + * Called when the `data` or `drain` handler throws. On Linux and macOS, + * also called when a receive fails. The socket stays open. + * + * If `data` or `drain` throws and there is no `error` handler, the + * thrown value is reported as an uncaught exception. + * + * A promise that `data` or `drain` returns is not awaited. If it + * rejects, the rejection is reported as an unhandled rejection and does + * not reach `error`. + * + * Earlier versions of Bun call this handler with the error as the only + * argument. + * + * @param error The error. A value that `data` or `drain` throws is passed as thrown. + */ error?(socket: Socket, error: Error): void | Promise; } @@ -7446,6 +7462,22 @@ declare module "bun" { flags: ReceiveFlags, ): void | Promise; drain?(socket: ConnectedSocket): void | Promise; + /** + * Called when the `data` or `drain` handler throws. On Linux and macOS, + * also called when a receive fails. The socket stays open. + * + * If `data` or `drain` throws and there is no `error` handler, the + * thrown value is reported as an uncaught exception. + * + * A promise that `data` or `drain` returns is not awaited. If it + * rejects, the rejection is reported as an unhandled rejection and does + * not reach `error`. + * + * Earlier versions of Bun call this handler with the error as the only + * argument. + * + * @param error The error. A value that `data` or `drain` throws is passed as thrown. + */ error?(socket: ConnectedSocket, error: Error): void | Promise; } diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index d2b662209ca3..dc5dcbfa1d69 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -756,7 +756,7 @@ function startBunSocket(self, state, createOptions, sharedHandle?) { drain: () => { handleDrain.$call(state.handle); }, - error: error => { + error: (_socket, error) => { if (error?.syscall === "recv") { // Drop errqueue-origin ICMP errors on unconnected sockets like // Node (which never enables IP_RECVERR); always emit real diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index d919f2af5184..eeceb880d83a 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -868,7 +868,7 @@ impl UDPSocket { callback, global_this, this_value, - &[err.to_error().unwrap_or(err)], + &[this_value, err.to_error().unwrap_or(err)], ); } diff --git a/test/integration/bun-types/fixture/udp.ts b/test/integration/bun-types/fixture/udp.ts index 619733f37297..fa8947c050ad 100644 --- a/test/integration/bun-types/fixture/udp.ts +++ b/test/integration/bun-types/fixture/udp.ts @@ -56,3 +56,72 @@ expectType(connectedSocket.setMulticastTTL(1)).is(); expectType(connectedSocket.setMulticastLoopback(false)).is(); connectedSocket.close(); + +const socketWithHandlers = await Bun.udpSocket({ + port: 0, + socket: { + data(socket, data, port, address, flags) { + expectType(socket).is>(); + expectType(data).is(); + expectType(port).is(); + expectType(address).is(); + expectType(flags).is(); + }, + drain(socket) { + expectType(socket).is>(); + }, + error(socket, error) { + expectType(socket).is>(); + expectType(error).is(); + }, + }, +}); + +expectType(socketWithHandlers).is>(); + +const connectedSocketHandlers: Bun.udp.ConnectedSocketHandler<"buffer"> = { + data(socket, data, port, address, flags) { + expectType(socket).is>(); + expectType(data).is(); + expectType(port).is(); + expectType(address).is(); + expectType(flags).is(); + }, + drain(socket) { + expectType(socket).is>(); + }, + error(socket, error) { + expectType(socket).is>(); + expectType(error).is(); + }, +}; + +const connectedSocketWithHandlers = await Bun.udpSocket({ + port: 0, + connect: { + hostname: "127.0.0.1", + port: 41234, + }, + socket: connectedSocketHandlers, +}); + +expectType(connectedSocketWithHandlers).is>(); + +// The example in docs/runtime/networking/udp.mdx: handlers written inline on a connected socket. +// No check of `socket` here: TypeScript gives it the type of an unconnected socket (https://github.com/oven-sh/bun/issues/44272). +await Bun.udpSocket({ + connect: { + hostname: "127.0.0.1", + port: 41234, + }, + socket: { + error(socket, error: Bun.SystemError) { + expectType(error.code).is(); + }, + }, +}); + +const errorFirstHandlers: Bun.udp.SocketHandler<"buffer"> = { + // @ts-expect-error The socket is the first argument. The error is the second. + error(error: Error) {}, +}; diff --git a/test/js/bun/udp/udp_socket.test.ts b/test/js/bun/udp/udp_socket.test.ts index 24c3939a767c..4a4bb09ef7b5 100644 --- a/test/js/bun/udp/udp_socket.test.ts +++ b/test/js/bun/udp/udp_socket.test.ts @@ -780,6 +780,67 @@ describe("udpSocket()", () => { }); }); +// `error` gets the socket first, like `data` and `drain`, and like the +// handlers of Bun.listen() and Bun.connect(). +describe("error handler is called with (socket, error)", () => { + describe.each(["unconnected", "connected"] as const)("%s socket", kind => { + describe.each(["data", "drain"] as const)("%s handler throws", trigger => { + test.each(["udpSocket()", "reload()"] as const)("handlers registered by %s", async registeredBy => { + const thrown = new Error("boom"); + const { promise, resolve } = Promise.withResolvers<{ args: unknown[]; self: unknown }>(); + const handlers = { + [trigger]() { + throw thrown; + }, + // `this` is the socket too. bun-types does not declare it. + error(this: unknown, ...args: unknown[]) { + resolve({ args, self: this }); + }, + }; + + const peer = await udpSocket({ hostname: "127.0.0.1" }); + const options = { + hostname: "127.0.0.1", + ...(kind === "connected" && { connect: { hostname: "127.0.0.1", port: peer.port } }), + ...(registeredBy === "udpSocket()" && { socket: handlers }), + }; + const socket = await udpSocket(options); + if (registeredBy === "reload()") { + // @ts-expect-error reload() reads the handlers from `socket`: https://github.com/oven-sh/bun/issues/44271 + socket.reload({ socket: handlers }); + } + + let resend: ReturnType | undefined; + try { + // A new socket reports writable once, so `drain` runs with no send. + if (trigger === "data") { + const send = () => peer.send("x", socket.port, "127.0.0.1"); + resend = setInterval(send, 20); + send(); + } + + const { args, self } = await promise; + expect({ + argc: args.length, + firstIsSocket: args[0] === socket, + secondIsThrown: args[1] === thrown, + thisIsSocket: self === socket, + }).toEqual({ + argc: 2, + firstIsSocket: true, + secondIsThrown: true, + thisIsSocket: true, + }); + } finally { + clearInterval(resend); + socket.close(); + peer.close(); + } + }); + }); + }); +}); + // us_udp_socket_send batches at most ~204 messages per sendmmsg; a >batch-size // sendMany must loop and report the TOTAL accepted. The pre-fix loop condition // compared against a decremented `num` and stopped after one batch, which every diff --git a/test/js/bun/udp/udp_socket_recv_flags.test.ts b/test/js/bun/udp/udp_socket_recv_flags.test.ts index 548a13307799..7fb8ae4a3be7 100644 --- a/test/js/bun/udp/udp_socket_recv_flags.test.ts +++ b/test/js/bun/udp/udp_socket_recv_flags.test.ts @@ -4,7 +4,7 @@ import { udpSocket } from "bun"; import { describe, expect, test } from "bun:test"; -import { isLinux } from "harness"; +import { isLinux, isWindows } from "harness"; describe("udpSocket() receive flags", () => { test("data callback receives flags object with truncated=false for normal packets", async () => { @@ -55,7 +55,7 @@ describe("udpSocket() receive flags", () => { const sender = await udpSocket({ socket: { - error(err: Error & { code?: string }) { + error(_socket, err: Error & { code?: string }) { resolveErr(err); }, }, @@ -93,4 +93,47 @@ describe("udpSocket() receive flags", () => { } }, ); + + // A connected socket gets the ICMP error on Linux and macOS. Linux reads it + // from the error queue. macOS gets it from the receive that fails. + test.skipIf(isWindows)("a connected socket reports ECONNREFUSED as error(socket, error) and stays open", async () => { + // A port that refuses the datagrams of this test and that no other process + // can bind: a socket connected to another peer holds it. + const other = await udpSocket({ hostname: "127.0.0.1" }); + const holder = await udpSocket({ hostname: "127.0.0.1", connect: { hostname: "127.0.0.1", port: other.port } }); + + const { promise, resolve, reject } = Promise.withResolvers<{ argc: number; socket: unknown; code: unknown }>(); + const sender = await udpSocket({ + connect: { hostname: "127.0.0.1", port: holder.port }, + socket: { + error(...args: unknown[]) { + resolve({ argc: args.length, socket: args[0], code: (args[1] as { code?: unknown } | undefined)?.code }); + }, + }, + }); + + const send = () => { + if (sender.closed) return reject(new Error("the socket closed and did not call `error`")); + // A send can throw the pending ECONNREFUSED before a receive reports it. + try { + sender.send("x"); + } catch {} + }; + const resend = setInterval(send, 10); + try { + send(); + const { argc, socket, code } = await promise; + expect({ argc, socketIsSender: socket === sender, code, closed: sender.closed }).toEqual({ + argc: 2, + socketIsSender: true, + code: "ECONNREFUSED", + closed: false, + }); + } finally { + clearInterval(resend); + sender.close(); + holder.close(); + other.close(); + } + }); });