Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/runtime/networking/udp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Note>Earlier versions of Bun call `error` with the error as the only argument.</Note>

```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:
Expand Down
32 changes: 32 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7434,6 +7434,22 @@ declare module "bun" {
flags: ReceiveFlags,
): void | Promise<void>;
drain?(socket: Socket<DataBinaryType>): void | Promise<void>;
/**
* 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<DataBinaryType>, error: Error): void | Promise<void>;
}

Expand All @@ -7446,6 +7462,22 @@ declare module "bun" {
flags: ReceiveFlags,
): void | Promise<void>;
drain?(socket: ConnectedSocket<DataBinaryType>): void | Promise<void>;
/**
* 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<DataBinaryType>, error: Error): void | Promise<void>;
}

Expand Down
2 changes: 1 addition & 1 deletion src/js/node/dgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/socket/udp_socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Comment thread
robobun marked this conversation as resolved.
Resolved
);
}

Expand Down
69 changes: 69 additions & 0 deletions test/integration/bun-types/fixture/udp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,72 @@ expectType(connectedSocket.setMulticastTTL(1)).is<number>();
expectType(connectedSocket.setMulticastLoopback(false)).is<boolean>();

connectedSocket.close();

const socketWithHandlers = await Bun.udpSocket({
port: 0,
socket: {
data(socket, data, port, address, flags) {
expectType(socket).is<Bun.udp.Socket<"buffer">>();
expectType(data).is<Buffer>();
expectType(port).is<number>();
expectType(address).is<string>();
expectType(flags).is<Bun.udp.ReceiveFlags>();
},
drain(socket) {
expectType(socket).is<Bun.udp.Socket<"buffer">>();
},
error(socket, error) {
expectType(socket).is<Bun.udp.Socket<"buffer">>();
expectType(error).is<Error>();
},
},
});

expectType(socketWithHandlers).is<Bun.udp.Socket<"buffer">>();

const connectedSocketHandlers: Bun.udp.ConnectedSocketHandler<"buffer"> = {
data(socket, data, port, address, flags) {
expectType(socket).is<Bun.udp.ConnectedSocket<"buffer">>();
expectType(data).is<Buffer>();
expectType(port).is<number>();
expectType(address).is<string>();
expectType(flags).is<Bun.udp.ReceiveFlags>();
},
drain(socket) {
expectType(socket).is<Bun.udp.ConnectedSocket<"buffer">>();
},
error(socket, error) {
expectType(socket).is<Bun.udp.ConnectedSocket<"buffer">>();
expectType(error).is<Error>();
},
};

const connectedSocketWithHandlers = await Bun.udpSocket({
port: 0,
connect: {
hostname: "127.0.0.1",
port: 41234,
},
socket: connectedSocketHandlers,
});

expectType(connectedSocketWithHandlers).is<Bun.udp.ConnectedSocket<"buffer">>();

// 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<string | undefined>();
},
},
});

const errorFirstHandlers: Bun.udp.SocketHandler<"buffer"> = {
// @ts-expect-error The socket is the first argument. The error is the second.
error(error: Error) {},
};
61 changes: 61 additions & 0 deletions test/js/bun/udp/udp_socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setInterval> | 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
Expand Down
47 changes: 45 additions & 2 deletions test/js/bun/udp/udp_socket_recv_flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
},
},
Expand Down Expand Up @@ -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();
}
});
});
Loading