Skip to content

Commit 7d2355e

Browse files
authored
[wrangler] Improve error message when port binding is blocked by sandbox (#12556)
1 parent 7f18183 commit 7d2355e

File tree

4 files changed

+138
-15
lines changed

4 files changed

+138
-15
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
Fix port availability check probing the wrong host when host changes
6+
7+
`memoizeGetPort` correctly invalidated its cached port when called with a different host, but then still probed the original host for port availability. This could return a port that was free on the original host but already in use on the requested one, leading to bind failures at startup.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
Improve error message when port binding is blocked by a sandbox or security policy
6+
7+
When running `wrangler dev` inside a restricted environment (such as an AI coding agent sandbox or locked-down container), the port availability check would fail with a raw `EPERM` error. This now provides a clear message explaining that a sandbox or security policy is blocking network access, rather than the generic permission error that previously pointed at the file system.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { UserError } from "@cloudflare/workers-utils";
2+
import getPort from "get-port";
3+
import { describe, it, vi } from "vitest";
4+
import { memoizeGetPort } from "../utils/memoizeGetPort";
5+
6+
vi.mock("get-port", () => {
7+
return {
8+
default: vi.fn().mockResolvedValue(8787),
9+
portNumbers: (from: number, to: number) => [from, to],
10+
};
11+
});
12+
13+
const mockGetPort = vi.mocked(getPort);
14+
15+
describe("memoizeGetPort()", () => {
16+
it("should throw a UserError when port binding is blocked by EPERM", async ({
17+
expect,
18+
}) => {
19+
mockGetPort.mockImplementationOnce(() => {
20+
throw Object.assign(
21+
new Error("listen EPERM: operation not permitted ::1:8787"),
22+
{ code: "EPERM", syscall: "listen" }
23+
);
24+
});
25+
26+
const getPortFn = memoizeGetPort(8787, "localhost");
27+
await expect(getPortFn()).rejects.toThrow(UserError);
28+
});
29+
30+
it("should mention sandbox in EPERM error message", async ({ expect }) => {
31+
mockGetPort.mockImplementationOnce(() => {
32+
throw Object.assign(
33+
new Error("listen EPERM: operation not permitted ::1:8787"),
34+
{ code: "EPERM", syscall: "listen" }
35+
);
36+
});
37+
38+
const getPortFn = memoizeGetPort(8787, "localhost");
39+
await expect(getPortFn()).rejects.toThrow(/sandbox or security policy/);
40+
});
41+
42+
it("should throw a UserError when port binding is blocked by EACCES", async ({
43+
expect,
44+
}) => {
45+
const eaccesError = Object.assign(
46+
new Error("listen EACCES: permission denied 127.0.0.1:8787"),
47+
{ code: "EACCES", syscall: "listen" }
48+
);
49+
mockGetPort.mockImplementation(() => {
50+
throw eaccesError;
51+
});
52+
53+
const getPortFn = memoizeGetPort(8787, "127.0.0.1");
54+
await expect(getPortFn()).rejects.toThrow(UserError);
55+
await expect(getPortFn()).rejects.toThrow(/sandbox or security policy/);
56+
57+
mockGetPort.mockReset();
58+
});
59+
60+
it("should re-throw non-permission errors unchanged", async ({ expect }) => {
61+
mockGetPort.mockImplementation(() => {
62+
throw new Error("something else went wrong");
63+
});
64+
65+
const getPortFn = memoizeGetPort(8787, "localhost");
66+
await expect(getPortFn()).rejects.toThrow("something else went wrong");
67+
await expect(getPortFn()).rejects.not.toThrow(UserError);
68+
69+
mockGetPort.mockReset();
70+
});
71+
72+
it("should not treat filesystem EPERM as a network bind error", async ({
73+
expect,
74+
}) => {
75+
mockGetPort.mockImplementationOnce(() => {
76+
throw Object.assign(
77+
new Error("EPERM: operation not permitted, open '/tmp/foo'"),
78+
{ code: "EPERM", syscall: "open" }
79+
);
80+
});
81+
82+
const getPortFn = memoizeGetPort(8787, "localhost");
83+
await expect(getPortFn()).rejects.not.toThrow(UserError);
84+
});
85+
});
Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1+
import { UserError } from "@cloudflare/workers-utils";
12
import ci from "ci-info";
23
import getPort, { portNumbers } from "get-port";
34

45
// Probe consecutive ports to increase the chance to get the same port across dev sessions.
56
// In CI, we avoid probing consecutive ports to reduce the chance of collisions
67
const NUM_CONSECUTIVE_PORTS_TO_PROBE = ci.isCI ? 0 : 10;
78

9+
function isNetworkBindPermissionError(e: unknown): boolean {
10+
return (
11+
e !== null &&
12+
typeof e === "object" &&
13+
"code" in e &&
14+
(e.code === "EPERM" || e.code === "EACCES") &&
15+
"syscall" in e &&
16+
(e.syscall === "listen" || e.syscall === "bind")
17+
);
18+
}
19+
820
/**
921
* Get an available TCP port number.
1022
*
@@ -13,26 +25,38 @@ const NUM_CONSECUTIVE_PORTS_TO_PROBE = ci.isCI ? 0 : 10;
1325
* - Avoiding calling `getPort()` multiple times by memoizing the first result.
1426
*
1527
* @param defaultPort The preferred port to use when available
16-
* @param host The host to probe for available ports
28+
* @param defaultHost The default host to probe for available ports (can be overridden per-call)
1729
*/
18-
export function memoizeGetPort(defaultPort: number, host: string) {
30+
export function memoizeGetPort(defaultPort: number, defaultHost: string) {
1931
let portValue: number | undefined;
20-
let cachedHost = host;
21-
return async (forHost: string = host) => {
32+
let cachedHost = defaultHost;
33+
return async (forHost: string = defaultHost) => {
2234
if (forHost !== cachedHost) {
2335
portValue = undefined;
2436
cachedHost = forHost;
2537
}
26-
// Check a specific host to avoid probing all local addresses.
27-
portValue =
28-
portValue ??
29-
(await getPort({
30-
port: portNumbers(
31-
defaultPort,
32-
defaultPort + NUM_CONSECUTIVE_PORTS_TO_PROBE
33-
),
34-
host,
35-
}));
36-
return portValue;
38+
try {
39+
// Check a specific host to avoid probing all local addresses.
40+
portValue =
41+
portValue ??
42+
(await getPort({
43+
port: portNumbers(
44+
defaultPort,
45+
defaultPort + NUM_CONSECUTIVE_PORTS_TO_PROBE
46+
),
47+
host: forHost,
48+
}));
49+
return portValue;
50+
} catch (e) {
51+
if (isNetworkBindPermissionError(e)) {
52+
throw new UserError(
53+
`Failed to bind to ${forHost}:${defaultPort}: permission denied.\n` +
54+
`This usually means a sandbox or security policy is preventing network access.\n` +
55+
`If you are running inside a restricted environment (container, VM, AI coding agent, etc.),\n` +
56+
`configure it to allow binding to loopback addresses.`
57+
);
58+
}
59+
throw e;
60+
}
3761
};
3862
}

0 commit comments

Comments
 (0)