authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 03:55:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 03:55:03-07:00
log9688e74112af93147a19499260dfaaa3f2517e0f
tree9d83ca5cb20b605818db647623b56509bc887ceb
parent6c142934188fa83bcc1d9cb44b425ced82d66468
signature Signed by PGP key B5690EEEBB952194

fix(ssh): SSH launch no longer picks a port another server answers on (#19372)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 202 insertions(+), 7 deletions(-)

apps/server/src/cli/sshHelper.test.ts created+86
......@@ -0,0 +1,86 @@
1import * as NodeNet from "node:net";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, describe, it } from "@effect/vitest";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Stream from "effect/Stream";
9import { ChildProcess, ChildProcessSpawner } from "effect/process";
10
11// On macOS, Node can bind 127.0.0.1:<port> while another process (such as the
12// desktop app with network access on) listens on *:<port>. Linux refuses that
13// bind unless both sockets set SO_REUSEPORT, so on Linux the preload adds it to
14// the picker's probe and the holder sets it too. macOS needs neither.
15const MACOS_BIND_PRELOAD = `const net = require("node:net");
16const listen = net.Server.prototype.listen;
17net.Server.prototype.listen = function (port, host, callback) {
18 if (typeof port !== "number" || typeof host !== "string") return listen.apply(this, arguments);
19 return listen.call(this, { port, host, reusePort: true }, callback);
20};
21`;
22
23describe("__ssh-helper pick-port", () => {
24 const hostPlatform = HostProcess.Platform.defaultValue();
25 const linuxHost = hostPlatform === "linux";
26 const windowsHost = hostPlatform === "win32";
27
28 const holdWildcardPort = Effect.acquireRelease(
29 Effect.callback<NodeNet.Server>((resume) => {
30 const server = NodeNet.createServer((socket) => socket.destroy());
31 server.once("error", (error) => resume(Effect.die(error)));
32 server.listen({ port: 0, host: "0.0.0.0", reusePort: linuxHost }, () =>
33 resume(Effect.succeed(server)),
34 );
35 }),
36 (server) =>
37 Effect.callback<void>((resume) => {
38 server.close(() => resume(Effect.void));
39 }),
40 );
41
42 it.effect.skipIf(windowsHost)(
43 "skips a port another server answers on loopback",
44 () =>
45 Effect.gen(function* () {
46 const fs = yield* FileSystem.FileSystem;
47 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
48 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pick-port-" });
49 yield* fs.writeFileString(`${root}/preload.cjs`, MACOS_BIND_PRELOAD);
50 const holder = yield* holdWildcardPort;
51 const heldPort = (holder.address() as NodeNet.AddressInfo).port;
52
53 const child = yield* spawner.spawn(
54 ChildProcess.make(process.execPath, [
55 ...(linuxHost ? ["--require", `${root}/preload.cjs`] : []),
56 "--experimental-strip-types",
57 new URL("../bin.ts", import.meta.url).pathname,
58 "__ssh-helper",
59 "pick-port",
60 "",
61 String(heldPort),
62 "20",
63 ]),
64 );
65 const [stdout, exitCode] = yield* Effect.all(
66 [
67 child.stdout.pipe(
68 Stream.decodeText(),
69 Stream.runFold(
70 () => "",
71 (acc, chunk) => acc + chunk,
72 ),
73 ),
74 child.exitCode.pipe(Effect.map(Number)),
75 ],
76 { concurrency: "unbounded" },
77 );
78
79 assert.equal(exitCode, 0);
80 const picked = Number(stdout);
81 assert.isAbove(picked, heldPort);
82 assert.isBelow(picked, heldPort + 20);
83 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
84 30_000,
85 );
86});
apps/server/src/cli/sshHelper.ts+21-4
......@@ -18,14 +18,31 @@ import { Argument, Command } from "effect/cli";
1818 * them.
1919 */
2020
21const tryPort = (port: number) =>
22 new Promise<number | false>((resolve) => {
21const 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.
33const 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+74
......@@ -1,3 +1,5 @@
1import * as NodeNet from "node:net";
2
13import { assert, describe, it } from "@effect/vitest";
24import * as NodeServices from "@effect/platform-node/NodeServices";
35import * 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.
702const MACOS_BIND_PRELOAD = `const net = require("node:net");
703const listen = net.Server.prototype.listen;
704net.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
710describe("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+21-3
......@@ -260,20 +260,38 @@ const preferred = Number.parseInt(raw, 10);
260260const start = Number.isInteger(preferred) ? preferred : defaultPort;
261261const end = start + scanWindow;
262262
263function tryPort(port) {
263function 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.
276function 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;