Skip to content
Closed
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
22 changes: 22 additions & 0 deletions src/runtime/socket/udp_socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,29 @@ impl UDPSocket {
unsafe { &*user.cast::<UDPSocket>() }
}

/// `Bun.udpSocket(options): Promise<UDPSocket>`. Any error raised while
/// creating/binding the socket is surfaced as a rejected promise rather
/// than a synchronous throw so callers can rely on `.catch()` /
/// `Promise.allSettled` for the fallback-port pattern.
pub fn udp_socket(global_this: &JSGlobalObject, options: JSValue) -> JsResult<JSValue> {
let err = match Self::udp_socket_impl(global_this, options) {
Ok(v) => return Ok(v),
Err(e @ (bun_jsc::JsError::Terminated | bun_jsc::JsError::OutOfMemory)) => {
return Err(e);
}
Err(bun_jsc::JsError::Thrown) => match global_this.try_take_exception() {
Some(exc) if exc.is_termination_exception() => {
return Err(bun_jsc::JsError::Terminated);
}
Some(exc) => exc.to_error().unwrap_or(exc),
None => global_this
.create_error_instance(format_args!("Bun.udpSocket() failed to bind")),
},
};
Ok(bun_jsc::JSPromise::rejected_promise(global_this, err).to_js())
}
Comment thread
robobun marked this conversation as resolved.

fn udp_socket_impl(global_this: &JSGlobalObject, options: JSValue) -> JsResult<JSValue> {
bun_output::scoped_log!(UdpSocket, "udpSocket");

let this_ptr = Self::new(Self {
Expand Down
126 changes: 122 additions & 4 deletions test/js/bun/udp/udp_socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,128 @@ describe("udpSocket()", () => {
// Out-of-range connect.port used to be silently rewritten to 0, so send()
// returned true while every datagram was dropped. The bind path already
// rejected the same values; connect must too.
test.each([-1, 0, 65536, 99999, NaN, Infinity, "abc"] as const)("connect with out-of-range port %p rejects", port => {
expect(() => udpSocket({ connect: { hostname: "127.0.0.1", port: port as number } })).toThrow(
'Expected "connect.port" to be an integer between 1 and 65535',
);
test.each([-1, 0, 65536, 99999, NaN, Infinity, "abc"] as const)(
"connect with out-of-range port %p rejects",
async port => {
const result = udpSocket({ connect: { hostname: "127.0.0.1", port: port as number } });
expect(result).toBeInstanceOf(Promise);
await expect(result).rejects.toThrow('Expected "connect.port" to be an integer between 1 and 65535');
},
);

// Bun.udpSocket() is typed as returning a Promise; bind/connect failures must
// surface as promise rejections so `.catch()` / `.then(ok, err)` /
// `Promise.allSettled` work. Previously these threw synchronously out of the
// native call, bypassing any promise chain.
describe("returns a rejected promise on failure instead of throwing synchronously", () => {
test("EADDRINUSE", async () => {
const holder = await udpSocket({ hostname: "127.0.0.1", socket: {} });
try {
let syncThrew = false;
let result: any;
try {
result = udpSocket({ hostname: "127.0.0.1", port: holder.port, socket: {} });
} catch {
syncThrew = true;
}
expect(syncThrew).toBe(false);
expect(result).toBeInstanceOf(Promise);
const rejection = await result.then(
(s: any) => {
s.close();
return null;
},
(e: any) => e,
);
expect(rejection).not.toBeNull();
expect(rejection.syscall).toBe("bind");
expect(rejection.code).toBe("EADDRINUSE");
expect(rejection.address).toBe("127.0.0.1");
} finally {
holder.close();
}
});

test("EADDRINUSE is catchable via .catch()", async () => {
const holder = await udpSocket({ hostname: "127.0.0.1", socket: {} });
try {
// The fallback-port resilience pattern that motivated this fix.
const socket = await udpSocket({ hostname: "127.0.0.1", port: holder.port, socket: {} }).catch(() =>
udpSocket({ hostname: "127.0.0.1", port: 0, socket: {} }),
);
try {
expect(socket.port).toBeInteger();
expect(socket.port).not.toBe(holder.port);
} finally {
socket.close();
}
} finally {
holder.close();
}
});

test("EADDRNOTAVAIL", async () => {
let syncThrew = false;
let result: any;
try {
// 192.0.2.0/24 (TEST-NET-1) is guaranteed non-local.
result = udpSocket({ hostname: "192.0.2.1", port: 0, socket: {} });
} catch {
syncThrew = true;
}
expect(syncThrew).toBe(false);
expect(result).toBeInstanceOf(Promise);
const rejection = await result.then(
(s: any) => {
s.close();
return null;
},
(e: any) => e,
);
expect(rejection).not.toBeNull();
expect(rejection.syscall).toBe("bind");
expect(rejection.code).toBe("EADDRNOTAVAIL");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("invalid options", async () => {
let syncThrew = false;
let result: any;
try {
result = udpSocket({ port: -1 } as any);
} catch {
syncThrew = true;
}
// Attach a handler immediately so the rejected promise is observed.
const rejection = await result?.then(
(s: any) => {
s.close();
return null;
},
(e: any) => e,
);
expect(syncThrew).toBe(false);
expect(result).toBeInstanceOf(Promise);
expect(rejection?.code).toBe("ERR_INVALID_ARG_TYPE");
});

test("Promise.allSettled over a port range", async () => {
const holder = await udpSocket({ hostname: "127.0.0.1", socket: {} });
try {
const results = await Promise.allSettled([
udpSocket({ hostname: "127.0.0.1", port: holder.port, socket: {} }),
udpSocket({ hostname: "127.0.0.1", port: 0, socket: {} }),
]);
try {
expect(results[0].status).toBe("rejected");
expect((results[0] as PromiseRejectedResult).reason.code).toBe("EADDRINUSE");
expect(results[1].status).toBe("fulfilled");
} finally {
for (const r of results) if (r.status === "fulfilled") r.value.close();
}
} finally {
holder.close();
}
});
});

test("connect with valid port at range boundaries is accepted", async () => {
Expand Down
Loading