fix: disable NAT-PMP on macOS to prevent EADDRINUSE crash on port 5350 (#45)

macOS mDNSResponder permanently occupies UDP port 5350, which is the
NAT-PMP client port that webtorrent binds when initializing its NAT
traversal. The bind fails asynchronously with EADDRINUSE, and since
the PMP client is a raw EventEmitter with no error listener, it
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 on darwin only and let UPnP handle NAT traversal
instead. Other platforms are unaffected.

Closes #22
This commit is contained in:
Manak
2026-07-05 09:59:53 -04:00
committed by GitHub
parent b8f88728db
commit 75af643083
3 changed files with 98 additions and 1 deletions
+87
View File
@@ -0,0 +1,87 @@
import { EventEmitter } from "node:events";
import { describe, it, expect, vi, afterEach } from "vitest";
const constructorCalls: Record<string, unknown>[] = [];
vi.mock("webtorrent", () => {
return {
default: class extends EventEmitter {
torrentPort = 6881;
constructor(opts?: Record<string, unknown>) {
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);
});
});
+9 -1
View File
@@ -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;
+2
View File
@@ -42,6 +42,8 @@ declare module "webtorrent" {
utp?: boolean;
tracker?: boolean;
lsd?: boolean;
natPmp?: boolean;
natUpnp?: boolean | "permanent";
}
class WebTorrent extends EventEmitter {