|
| 1 | +import * as NodeNet from "node:net"; |
| 2 | + |
1 | 3 | import { assert, describe, it } from "@effect/vitest"; |
2 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
3 | 5 | import * as NetService from "@t3tools/shared/Net"; |
@@ -693,6 +695,78 @@ describe("ssh tunnel scripts", () => { |
693 | 695 | ); |
694 | 696 | }); |
695 | 697 |
|
| 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 | + |
696 | 770 | // The archive runner is generated shell; string assertions cannot prove the |
697 | 771 | // lock excludes concurrent installers. Run the real script against a tiny |
698 | 772 | // fake archive served from a file:// mirror. |
|
0 commit comments