import type { Socket } from "bun"; import { connect, fileURLToPath, SocketHandler, spawn } from "bun"; import { createSocketPair } from "bun:internal-for-testing"; import { describe, expect, it, jest } from "bun:test"; import { closeSync } from "fs"; import { bunEnv, bunExe, expectMaxObjectTypeCount, getMaxFD, isWindows, tls } from "harness"; describe.concurrent("socket", () => { it("should throw when a socket from a file descriptor has a bad file descriptor", async () => { const open = jest.fn(); const close = jest.fn(); const data = jest.fn(); const connectError = jest.fn(() => {}); { expect( async () => await Bun.connect({ fd: getMaxFD() + 1024, socket: { open, close, data, connectError, }, }), ).toThrow(); Bun.gc(true); await Bun.sleep(10); Bun.gc(true); } await Bun.sleep(10); expect(open).toHaveBeenCalledTimes(0); expect(close).toHaveBeenCalledTimes(0); expect(data).toHaveBeenCalledTimes(0); expect(connectError).toHaveBeenCalledTimes(1); connectError.mockClear(); open.mockClear(); close.mockClear(); data.mockClear(); }); it.skipIf(isWindows)( "should not crash when a socket from a file descriptor is already closed after opening", async () => { const [server, client] = createSocketPair(); const open = jest.fn(); const close = jest.fn(); const data = jest.fn(); closeSync(client); { const socket = await Bun.connect({ fd: server, socket: { open, close, data, }, }); Bun.gc(true); await Bun.sleep(10); Bun.gc(true); } await Bun.sleep(10); expect(open).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledTimes(1); expect(data).toHaveBeenCalledTimes(0); open.mockClear(); close.mockClear(); data.mockClear(); }, ); it("should coerce '0' to 0", async () => { const listener = Bun.listen({ // @ts-expect-error port: "0", hostname: "localhost", socket: { open() {}, close() {}, data() {}, }, }); listener.stop(true); }); it("should NOT coerce '-1234' to 1234", async () => { expect(() => Bun.listen({ // @ts-expect-error port: "-1234", hostname: "localhost", socket: { open() {}, close() {}, data() {}, }, }), ).toThrow("port must be in the range [0, 65535]"); }); it("should keep process alive only when active", async () => { const { exited, stdout, stderr } = spawn({ cmd: [bunExe(), "echo.js"], cwd: import.meta.dir, stdout: "pipe", stdin: null, stderr: "pipe", env: bunEnv, }); expect(await exited).toBe(0); expect(await stderr.text()).toBe(""); var lines = (await stdout.text()).split(/\r?\n/); expect( lines.filter(function (line) { return line.startsWith("[Server]"); }), ).toEqual(["[Server] OPENED", "[Server] GOT request", "[Server] CLOSED"]); expect( lines.filter(function (line) { return line.startsWith("[Client]"); }), ).toEqual(["[Client] OPENED", "[Client] GOT response", "[Client] CLOSED"]); }); it("connect without top level await should keep process alive", async () => { const server = Bun.listen({ socket: { open(socket) {}, data(socket, data) {}, }, hostname: "localhost", port: 0, }); const proc = Bun.spawn({ cmd: [bunExe(), "keep-event-loop-alive.js", String(server.port), server.hostname], cwd: import.meta.dir, env: bunEnv, }); await proc.exited; try { expect(proc.exitCode).toBe(0); expect(await proc.stdout.text()).toContain("event loop was not killed"); } finally { server.stop(); } }); it("connect() should return the socket object", async () => { const { exited, stdout, stderr } = spawn({ cmd: [bunExe(), "connect-returns-socket.js"], cwd: import.meta.dir, stdout: "pipe", stdin: null, stderr: "pipe", env: bunEnv, }); expect(await exited).toBe(0); expect(await stderr.text()).toBe(""); }); it("listen() should throw connection error for invalid host", () => { expect(() => { const handlers: SocketHandler = { open(socket) { socket.end(); }, close() {}, data() {}, }; Bun.listen({ port: 4423, hostname: "whatishtis.com", socket: handlers, }); }).toThrow(); }); it("should reject on connection error, calling both connectError() and rejecting the promise", done => { var data = {}; connect({ data, hostname: "localhost", port: 55555, socket: { connectError(socket, error) { expect(socket).toBeDefined(); expect(socket.data).toBe(data); expect(error).toBeDefined(); expect(error.name).toBe("Error"); expect(error.code).toBe("ECONNREFUSED"); expect(error.message).toBe("Failed to connect"); }, data() { done(new Error("Unexpected data()")); }, drain() { done(new Error("Unexpected drain()")); }, close() { done(new Error("Unexpected close()")); }, end() { done(new Error("Unexpected end()")); }, error() { done(new Error("Unexpected error()")); }, open() { done(new Error("Unexpected open()")); }, }, }).then( () => done(new Error("Promise should reject instead")), err => { expect(err).toBeDefined(); expect(err.name).toBe("Error"); expect(err.code).toBe("ECONNREFUSED"); expect(err.message).toBe("Failed to connect"); done(); }, ); }); it("should not leak memory when connect() fails", async () => { await (async () => { // windows can take more than a second per connection const quantity = isWindows ? 10 : 50; var promises = new Array(quantity); for (let i = 0; i < quantity; i++) { promises[i] = connect({ hostname: "localhost", port: 55555, socket: { connectError(socket, error) {}, data() {}, drain() {}, close() {}, end() {}, error() {}, open() {}, }, }); } await Promise.allSettled(promises); promises.length = 0; })(); await expectMaxObjectTypeCount(expect, "TCPSocket", 50, 50); }, 60_000); // this also tests we mark the promise as handled if connectError() is called it("should handle connection error", done => { var data = {}; connect({ data, hostname: "localhost", port: 55555, socket: { connectError(socket, error) { expect(socket).toBeDefined(); expect(socket.data).toBe(data); expect(error).toBeDefined(); expect(error.name).toBe("Error"); expect(error.message).toBe("Failed to connect"); expect((error as any).code).toBe("ECONNREFUSED"); done(); }, data() { done(new Error("Unexpected data()")); }, drain() { done(new Error("Unexpected drain()")); }, close() { done(new Error("Unexpected close()")); }, end() { done(new Error("Unexpected end()")); }, error() { done(new Error("Unexpected error()")); }, open() { done(new Error("Unexpected open()")); }, }, }); }); it("socket.timeout works", async () => { try { const { promise, resolve } = Promise.withResolvers(); var server = Bun.listen({ socket: { binaryType: "buffer", open(socket) { socket.write("hello"); }, data(socket, data) { if (data.toString("utf-8") === "I have timed out!") { client.end(); resolve(undefined); } }, }, hostname: "localhost", port: 0, }); var client = await connect({ hostname: server.hostname, port: server.port, socket: { timeout(socket) { socket.write("I have timed out!"); }, data() {}, drain() {}, close() {}, end() {}, error() {}, open() {}, }, }); client.timeout(1); await promise; } finally { server!.stop(true); } }, 10_000); it("should allow large amounts of data to be sent and received", async () => { expect([fileURLToPath(new URL("./socket-huge-fixture.js", import.meta.url))]).toRun(); }, 60_000); it("it should not crash when getting a ReferenceError on client socket open", async () => { using server = Bun.serve({ port: 0, hostname: "localhost", fetch() { return new Response("Hello World"); }, }); { const { resolve, reject, promise } = Promise.withResolvers(); let client: Socket | null = null; const timeout = setTimeout(() => { client?.end(); reject(new Error("Timeout")); }, 1000); client = await Bun.connect({ port: server.port, hostname: server.hostname, socket: { open(socket) { // ReferenceError: bytes is not defined // @ts-expect-error socket.write(bytes); }, error(socket, error) { clearTimeout(timeout); resolve(error); }, close(socket) { // we need the close handler resolve({ message: "Closed" }); }, data(socket, data) {}, }, }); const result: any = await promise; expect(result?.message).toBe("bytes is not defined"); } }); it("it should not crash when returning a Error on client socket open", async () => { using server = Bun.serve({ port: 0, hostname: "localhost", fetch() { return new Response("Hello World"); }, }); { const { resolve, reject, promise } = Promise.withResolvers(); let client: Socket | null = null; const timeout = setTimeout(() => { client?.end(); reject(new Error("Timeout")); }, 1000); client = await Bun.connect({ port: server.port, hostname: server.hostname, socket: { //@ts-ignore open(socket) { return new Error("CustomError"); }, error(socket, error) { clearTimeout(timeout); resolve(error); }, close(socket) { // we need the close handler resolve({ message: "Closed" }); }, data(socket, data) {}, }, }); const result: any = await promise; expect(result?.message).toBe("CustomError"); } }); it("it should only call open once", async () => { using server = Bun.listen({ port: 0, hostname: "localhost", socket: { open(socket) { socket.end("Hello"); }, data(socket, data) {}, }, }); const { resolve, reject, promise } = Promise.withResolvers(); let client: Socket | null = null; let opened = false; client = await Bun.connect({ port: server.port, hostname: "localhost", socket: { open(socket) { expect(opened).toBe(false); opened = true; }, connectError(socket, error) { expect().fail("connectError should not be called"); }, close(socket) { resolve(); }, data(socket, data) {}, }, }); await promise; expect(opened).toBe(true); }); it.skipIf(isWindows)("should not leak file descriptors when connecting", async () => { expect([fileURLToPath(new URL("./socket-leak-fixture.js", import.meta.url))]).toRun(); }); it("should not call open if the connection had an error", async () => { using server = Bun.listen({ port: 0, hostname: "0.0.0.0", socket: { open(socket) { socket.end(); }, data(socket, data) {}, }, }); const { resolve, reject, promise } = Promise.withResolvers(); let client: Socket | null = null; let hadError = false; try { client = await Bun.connect({ port: server.port, hostname: "::1", socket: { open(socket) { expect().fail("open should not be called, the connection should fail"); }, connectError(socket, error) { expect(hadError).toBe(false); hadError = true; resolve(); }, close(socket) { expect().fail("close should not be called, the connection should fail"); }, data(socket, data) {}, }, }); } catch (e) {} await Bun.sleep(50); await promise; expect(hadError).toBe(true); }); it("should connect directly when using an ip address", async () => { using server = Bun.listen({ port: 0, hostname: "127.0.0.1", socket: { open(socket) { socket.end("Hello"); }, data(socket, data) {}, }, }); const { resolve, reject, promise } = Promise.withResolvers(); let client: Socket | null = null; let opened = false; client = await Bun.connect({ port: server.port, hostname: "127.0.0.1", socket: { open(socket) { expect(opened).toBe(false); opened = true; }, connectError(socket, error) { expect().fail("connectError should not be called"); }, close(socket) { resolve(); }, data(socket, data) {}, }, }); await promise; expect(opened).toBe(true); }); it("should not call drain before handshake", async () => { const { promise, resolve, reject } = Promise.withResolvers(); using socket = await Bun.connect({ hostname: "www.example.com", tls: true, port: 443, socket: { drain() { if (!socket.authorized) { reject(new Error("Socket not authorized")); } }, handshake() { resolve(); }, }, }); await promise; expect(socket.authorized).toBe(true); }); it("upgradeTLS handles errors", async () => { using server = Bun.serve({ port: 0, tls, async fetch(req) { return new Response("Hello World"); }, }); let body = ""; let rawBody = Buffer.alloc(0); for (let i = 0; i < 100; i++) { const socket = await Bun.connect({ hostname: "localhost", port: server.port, socket: { data(socket, data) { rawBody = Buffer.concat([rawBody, data]); }, close() {}, error(err) {}, }, }); const handlers = { data: Buffer.from("GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"), socket: { data: jest.fn(), close: jest.fn(), drain: jest.fn(), error: jest.fn(), open: jest.fn(), }, }; expect(() => socket.upgradeTLS({ ...handlers, tls: { ca: "invalid certificate!", }, }), ).toThrow( expect.objectContaining({ code: "ERR_BORINGSSL", }), ); expect(() => socket.upgradeTLS({ ...handlers, tls: { cert: "invalid certificate!", }, }), ).toThrow( expect.objectContaining({ code: "ERR_BORINGSSL", }), ); expect(() => socket.upgradeTLS({ ...handlers, tls: { ...tls, key: "invalid key!", }, }), ).toThrow( expect.objectContaining({ code: "ERR_BORINGSSL", }), ); expect(() => socket.upgradeTLS({ ...handlers, tls: { ...tls, key: "invalid key!", cert: "invalid cert!", }, }), ).toThrow( expect.objectContaining({ code: "ERR_BORINGSSL", }), ); expect(() => socket.upgradeTLS({ ...handlers, tls: {}, }), ).toThrow(); expect(handlers.socket.close).not.toHaveBeenCalled(); expect(handlers.socket.error).not.toHaveBeenCalled(); expect(handlers.socket.data).not.toHaveBeenCalled(); expect(handlers.socket.drain).not.toHaveBeenCalled(); expect(handlers.socket.open).not.toHaveBeenCalled(); socket.end(); } Bun.gc(true); }, 20_000); // only needed in debug mode it("should be able to upgrade to TLS", async () => { using server = Bun.serve({ port: 0, tls, async fetch(req) { return new Response("Hello World"); }, }); for (let i = 0; i < 50; i++) { const { promise: tlsSocketPromise, resolve, reject } = Promise.withResolvers(); const { promise: rawSocketPromise, resolve: rawSocketResolve, reject: rawSocketReject } = Promise.withResolvers(); { let body = ""; let rawBody = Buffer.alloc(0); const socket = await Bun.connect({ hostname: "localhost", port: server.port, socket: { data(socket, data) { rawBody = Buffer.concat([rawBody, data]); }, close() { rawSocketResolve(rawBody); }, error(err) { rawSocketReject(err); }, }, }); const result = socket.upgradeTLS({ data: Buffer.from("GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"), tls, socket: { data(socket, data) { body += data.toString("utf8"); if (body.includes("\r\n\r\n")) { socket.end(); } }, close() { resolve(body); }, drain(socket) { while (socket.data.byteLength > 0) { const written = socket.write(socket.data); if (written === 0) { break; } socket.data = socket.data.slice(written); } socket.flush(); }, error(err) { reject(err); }, }, }); const [raw, tls_socket] = result; expect(raw).toBeDefined(); expect(tls_socket).toBeDefined(); } const [tlsData, rawData] = await Promise.all([tlsSocketPromise, rawSocketPromise]); expect(tlsData).toContain("HTTP/1.1 200 OK"); expect(tlsData).toContain("Content-Length: 11"); expect(tlsData).toContain("\r\nHello World"); expect(rawData.byteLength).toBeGreaterThanOrEqual(1980); } }); }); it.skipIf(isWindows)("should not crash when a socket from a file descriptor is closed after opening", async () => { const [server, client] = createSocketPair(); const open = jest.fn(); const close = jest.fn(); const data = jest.fn(); { const socket = await Bun.connect({ fd: server, socket: { open, close, data, }, }); Bun.gc(true); await Bun.sleep(10); closeSync(client); Bun.gc(true); } await Bun.sleep(10); expect(open).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledTimes(1); expect(data).toHaveBeenCalledTimes(0); open.mockClear(); close.mockClear(); data.mockClear(); }); it("should not leak memory", async () => { // assert we don't leak the sockets // we expect 1 or 2 because that's the prototype / structure await expectMaxObjectTypeCount(expect, "Listener", 2); await expectMaxObjectTypeCount(expect, "TCPSocket", isWindows ? 3 : 2); await expectMaxObjectTypeCount(expect, "TLSSocket", isWindows ? 3 : 2); }); it("should not leak memory when connect() fails again", async () => { await expectMaxObjectTypeCount(expect, "TCPSocket", 5, 50); });