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();
+ }
+ });
});