diff --git a/src/download/engine.test.ts b/src/download/engine.test.ts new file mode 100644 index 0000000..f6dab1d --- /dev/null +++ b/src/download/engine.test.ts @@ -0,0 +1,87 @@ +import { EventEmitter } from "node:events"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +const constructorCalls: Record[] = []; + +vi.mock("webtorrent", () => { + return { + default: class extends EventEmitter { + torrentPort = 6881; + constructor(opts?: Record) { + super(); + constructorCalls.push(opts ?? {}); + } + add(): EventEmitter { + return new EventEmitter(); + } + destroy(): void {} + }, + }; +}); + +afterEach(() => { + constructorCalls.length = 0; + vi.resetModules(); +}); + +describe("TorrentEngine macOS port-5350 fix (#22)", () => { + it("passes natPmp:false on macOS so mDNSResponder's port 5350 is never bound", async () => { + const { TorrentEngine } = await import("./engine"); + const original = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + try { + const engine = new TorrentEngine(); + engine.add( + "test-id", + "magnet:?xt=urn:btih:0000000000000000000000000000000000000000", + "/downloads", + {}, + ); + engine.destroy(); + } finally { + Object.defineProperty(process, "platform", { value: original }); + } + expect(constructorCalls).toHaveLength(1); + expect(constructorCalls[0]).toMatchObject({ natPmp: false }); + }); + + it("does not disable natPmp on Linux (port 5350 is free)", async () => { + const { TorrentEngine } = await import("./engine"); + const original = process.platform; + Object.defineProperty(process, "platform", { value: "linux" }); + try { + const engine = new TorrentEngine(); + engine.add( + "test-id", + "magnet:?xt=urn:btih:0000000000000000000000000000000000000000", + "/downloads", + {}, + ); + engine.destroy(); + } finally { + Object.defineProperty(process, "platform", { value: original }); + } + expect(constructorCalls).toHaveLength(1); + expect(constructorCalls[0]).not.toHaveProperty("natPmp", false); + }); + + it("does not disable natPmp on Windows (port 5350 is free)", async () => { + const { TorrentEngine } = await import("./engine"); + const original = process.platform; + Object.defineProperty(process, "platform", { value: "win32" }); + try { + const engine = new TorrentEngine(); + engine.add( + "test-id", + "magnet:?xt=urn:btih:0000000000000000000000000000000000000000", + "/downloads", + {}, + ); + engine.destroy(); + } finally { + Object.defineProperty(process, "platform", { value: original }); + } + expect(constructorCalls).toHaveLength(1); + expect(constructorCalls[0]).not.toHaveProperty("natPmp", false); + }); +}); diff --git a/src/download/engine.ts b/src/download/engine.ts index a454e52..cb6c745 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -38,7 +38,15 @@ export class TorrentEngine { private ensureClient(): WebTorrent { if (!this.client) { - this.client = new WebTorrent(); + // On macOS, mDNSResponder occupies UDP port 5350 — the NAT-PMP + // client port. Binding it fails asynchronously with EADDRINUSE, + // and since the PMP client is a raw EventEmitter with no error + // listener, the error surfaces as an uncaughtException that kills + // the app the moment a download starts. NAT-PMP can never succeed + // on macOS because the port is permanently taken, so disable it + // and let UPnP handle NAT traversal instead. + const opts = process.platform === "darwin" ? { natPmp: false } : {}; + this.client = new WebTorrent(opts); this.client.on("error", () => {}); } return this.client; diff --git a/src/webtorrent.d.ts b/src/webtorrent.d.ts index 568e3e9..b079f18 100644 --- a/src/webtorrent.d.ts +++ b/src/webtorrent.d.ts @@ -42,6 +42,8 @@ declare module "webtorrent" { utp?: boolean; tracker?: boolean; lsd?: boolean; + natPmp?: boolean; + natUpnp?: boolean | "permanent"; } class WebTorrent extends EventEmitter {