Skip to content

Commit 0635659

Browse files
t3dotggclaude
andcommitted
fix(ssh): SSH launch no longer picks a port another server answers on
On macOS, the port picker could bind 127.0.0.1:<port> while the desktop app (network access on) listened on *:<port>, so the launcher started a second server on the same port. Both pickers now also skip a port that already accepts connections on loopback. Fixes #19011 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 519d64f commit 0635659

4 files changed

Lines changed: 180 additions & 7 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// @effect-diagnostics nodeBuiltinImport:off - The test holds a real socket and runs the real CLI.
2+
import * as NodeChildProcess from "node:child_process";
3+
import * as NodeFS from "node:fs";
4+
import * as NodeNet from "node:net";
5+
import * as NodeOS from "node:os";
6+
import * as NodePath from "node:path";
7+
import * as NodeUtil from "node:util";
8+
9+
import * as HostProcess from "@t3tools/shared/HostProcess";
10+
import { describe, expect, it } from "vite-plus/test";
11+
12+
const execFile = NodeUtil.promisify(NodeChildProcess.execFile);
13+
14+
// On macOS, Node can bind 127.0.0.1:<port> while another process (such as the
15+
// desktop app with network access on) listens on *:<port>. Linux refuses that
16+
// bind unless both sockets set SO_REUSEPORT, so on Linux the preload adds it to
17+
// the picker's probe and the holder sets it too. macOS needs neither.
18+
const MACOS_BIND_PRELOAD = `const net = require("node:net");
19+
const listen = net.Server.prototype.listen;
20+
net.Server.prototype.listen = function (port, host, callback) {
21+
if (typeof port !== "number" || typeof host !== "string") return listen.apply(this, arguments);
22+
return listen.call(this, { port, host, reusePort: true }, callback);
23+
};
24+
`;
25+
26+
describe("__ssh-helper pick-port", () => {
27+
const hostPlatform = HostProcess.Platform.defaultValue();
28+
const linuxHost = hostPlatform === "linux";
29+
30+
it.skipIf(hostPlatform === "win32")(
31+
"skips a port another server answers on loopback",
32+
async () => {
33+
const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pick-port-"));
34+
const holder = NodeNet.createServer((socket) => socket.destroy());
35+
try {
36+
const preload = NodePath.join(root, "preload.cjs");
37+
NodeFS.writeFileSync(preload, MACOS_BIND_PRELOAD);
38+
await new Promise<void>((resolve, reject) => {
39+
holder.once("error", reject);
40+
holder.listen({ port: 0, host: "0.0.0.0", reusePort: linuxHost }, resolve);
41+
});
42+
const heldPort = (holder.address() as NodeNet.AddressInfo).port;
43+
44+
const { stdout } = await execFile(process.execPath, [
45+
...(linuxHost ? ["--require", preload] : []),
46+
new URL("../bin.ts", import.meta.url).pathname,
47+
"__ssh-helper",
48+
"pick-port",
49+
"",
50+
String(heldPort),
51+
"20",
52+
]);
53+
54+
const picked = Number(stdout);
55+
expect(picked).toBeGreaterThan(heldPort);
56+
expect(picked).toBeLessThan(heldPort + 20);
57+
} finally {
58+
await new Promise((resolve) => holder.close(resolve));
59+
NodeFS.rmSync(root, { recursive: true, force: true });
60+
}
61+
},
62+
30_000,
63+
);
64+
});

‎apps/server/src/cli/sshHelper.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,31 @@ import { Argument, Command } from "effect/cli";
1818
* them.
1919
*/
2020

21-
const tryPort = (port: number) =>
22-
new Promise<number | false>((resolve) => {
21+
const canBind = (port: number) =>
22+
new Promise<boolean>((resolve) => {
2323
const server = NodeNet.createServer();
2424
server.unref();
2525
server.once("error", () => resolve(false));
2626
server.listen(port, "127.0.0.1", () => {
27-
server.close((error) => resolve(error ? false : port));
27+
server.close((error) => resolve(!error));
28+
});
29+
});
30+
31+
// macOS lets the bind above succeed while another process listens on
32+
// *:<port>, so also skip any port that already answers on loopback.
33+
const answersOnLoopback = (port: number) =>
34+
new Promise<boolean>((resolve) => {
35+
const socket = NodeNet.connect({ port, host: "127.0.0.1" });
36+
socket.setTimeout(1000);
37+
socket.once("connect", () => {
38+
socket.destroy();
39+
resolve(true);
40+
});
41+
socket.once("timeout", () => {
42+
socket.destroy();
43+
resolve(true);
2844
});
45+
socket.once("error", () => resolve(false));
2946
});
3047

3148
/** Prints the first free loopback port from the preferred one, scanning `window` ports. */
@@ -40,7 +57,7 @@ const pickPort = Command.make("pick-port", {
4057
const preferred = Number.parseInt(raw, 10);
4158
const start = Number.isInteger(preferred) ? preferred : defaultPort;
4259
for (let port = start; port < start + scanWindow; port += 1) {
43-
if (await tryPort(port)) {
60+
if ((await canBind(port)) && !(await answersOnLoopback(port))) {
4461
process.stdout.write(String(port));
4562
return;
4663
}

‎packages/ssh/src/tunnel.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as NodeNet from "node:net";
2+
13
import { assert, describe, it } from "@effect/vitest";
24
import * as NodeServices from "@effect/platform-node/NodeServices";
35
import * as NetService from "@t3tools/shared/Net";
@@ -693,6 +695,78 @@ describe("ssh tunnel scripts", () => {
693695
);
694696
});
695697

698+
// On macOS, Node can bind 127.0.0.1:<port> while another process (such as the
699+
// desktop app with network access on) listens on *:<port>. Linux refuses that
700+
// bind unless both sockets set SO_REUSEPORT, so on Linux the preload adds it to
701+
// the picker's probe and the holder sets it too. macOS needs neither.
702+
const MACOS_BIND_PRELOAD = `const net = require("node:net");
703+
const listen = net.Server.prototype.listen;
704+
net.Server.prototype.listen = function (port, host, callback) {
705+
if (typeof port !== "number" || typeof host !== "string") return listen.apply(this, arguments);
706+
return listen.call(this, { port, host, reusePort: true }, callback);
707+
};
708+
`;
709+
710+
describe("remote port picker", () => {
711+
const hostPlatform = HostProcess.Platform.defaultValue();
712+
const linuxHost = hostPlatform === "linux";
713+
const windowsHost = hostPlatform === "win32";
714+
715+
const holdWildcardPort = Effect.acquireRelease(
716+
Effect.callback<NodeNet.Server>((resume) => {
717+
const server = NodeNet.createServer((socket) => socket.destroy());
718+
server.once("error", (error) => resume(Effect.die(error)));
719+
server.listen({ port: 0, host: "0.0.0.0", reusePort: linuxHost }, () =>
720+
resume(Effect.succeed(server)),
721+
);
722+
}),
723+
(server) =>
724+
Effect.callback<void>((resume) => {
725+
server.close(() => resume(Effect.void));
726+
}),
727+
);
728+
729+
it.effect.skipIf(windowsHost)("skips a port another server answers on loopback", () =>
730+
Effect.gen(function* () {
731+
const fs = yield* FileSystem.FileSystem;
732+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
733+
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pick-port-" });
734+
yield* fs.writeFileString(`${root}/preload.cjs`, MACOS_BIND_PRELOAD);
735+
yield* fs.writeFileString(`${root}/pick-port.cjs`, SshTunnel.REMOTE_PICK_PORT_SCRIPT);
736+
const holder = yield* holdWildcardPort;
737+
const heldPort = (holder.address() as NodeNet.AddressInfo).port;
738+
739+
const child = yield* spawner.spawn(
740+
ChildProcess.make(process.execPath, [
741+
...(linuxHost ? ["--require", `${root}/preload.cjs`] : []),
742+
`${root}/pick-port.cjs`,
743+
"",
744+
String(heldPort),
745+
"20",
746+
]),
747+
);
748+
const [stdout, exitCode] = yield* Effect.all(
749+
[
750+
child.stdout.pipe(
751+
Stream.decodeText(),
752+
Stream.runFold(
753+
() => "",
754+
(acc, chunk) => acc + chunk,
755+
),
756+
),
757+
child.exitCode.pipe(Effect.map(Number)),
758+
],
759+
{ concurrency: "unbounded" },
760+
);
761+
762+
assert.equal(exitCode, 0);
763+
const picked = Number(stdout);
764+
assert.isAbove(picked, heldPort);
765+
assert.isBelow(picked, heldPort + 20);
766+
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
767+
);
768+
});
769+
696770
// The archive runner is generated shell; string assertions cannot prove the
697771
// lock excludes concurrent installers. Run the real script against a tiny
698772
// fake archive served from a file:// mirror.

‎packages/ssh/src/tunnel.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,20 +260,38 @@ const preferred = Number.parseInt(raw, 10);
260260
const start = Number.isInteger(preferred) ? preferred : defaultPort;
261261
const end = start + scanWindow;
262262
263-
function tryPort(port) {
263+
function canBind(port) {
264264
return new Promise((resolve) => {
265265
const server = net.createServer();
266266
server.unref();
267267
server.once("error", () => resolve(false));
268268
server.listen(port, "127.0.0.1", () => {
269-
server.close((error) => resolve(error ? false : port));
269+
server.close((error) => resolve(!error));
270270
});
271271
});
272272
}
273273
274+
// macOS lets the bind above succeed while another process listens on
275+
// *:<port>, so also skip any port that already answers on loopback.
276+
function answersOnLoopback(port) {
277+
return new Promise((resolve) => {
278+
const socket = net.connect({ port, host: "127.0.0.1" });
279+
socket.setTimeout(1000);
280+
socket.once("connect", () => {
281+
socket.destroy();
282+
resolve(true);
283+
});
284+
socket.once("timeout", () => {
285+
socket.destroy();
286+
resolve(true);
287+
});
288+
socket.once("error", () => resolve(false));
289+
});
290+
}
291+
274292
(async () => {
275293
for (let port = start; port < end; port += 1) {
276-
const available = await tryPort(port);
294+
const available = (await canBind(port)) && !(await answersOnLoopback(port));
277295
if (available) {
278296
process.stdout.write(String(port));
279297
return;

0 commit comments

Comments
 (0)