Issue #3533 requires the supervised port-forward process to own its lifecycle even when the user's SSH config enables ControlMaster or ControlPersist.
Unified · Split
| @@ -14,6 +14,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | import { SshPasswordPrompt } from "./auth.ts"; |
| 16 | 16 | import { |
| 17 | + buildSupervisedSshTunnelArgs, | |
| 17 | 18 | buildRemoteLaunchScript, |
| 18 | 19 | buildRemotePairingScript, |
| 19 | 20 | buildRemoteStopScript, |
| @@ -89,6 +90,50 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> { · 69 unmodified lines | ||
| 89 | 90 | } |
| 90 | 91 | |
| 91 | 92 | describe("ssh tunnel scripts", () => { |
| 93 | + it("disables connection multiplexing for the supervised port-forward command", () => { | |
| 94 | + const target = { | |
| 95 | + alias: "devbox", | |
| 96 | + hostname: "devbox.example.com", | |
| 97 | + username: "julius", | |
| 98 | + port: 2222, | |
| 99 | + } as const; | |
| 100 | + | |
| 101 | + assert.deepEqual( | |
| 102 | + buildSupervisedSshTunnelArgs({ | |
| 103 | + target, | |
| 104 | + batchMode: "no", | |
| 105 | + localPort: 41_773, | |
| 106 | + remotePort: 3_773, | |
| 107 | + hostSpec: "julius@devbox", | |
| 108 | + }), | |
| 109 | + [ | |
| 110 | + "-o", | |
| 111 | + "BatchMode=no", | |
| 112 | + "-o", | |
| 113 | + "ConnectTimeout=10", | |
| 114 | + "-p", | |
| 115 | + "2222", | |
| 116 | + "-o", | |
| 117 | + "ControlMaster=no", | |
| 118 | + "-o", | |
| 119 | + "ControlPath=none", | |
| 120 | + "-o", | |
| 121 | + "ControlPersist=no", | |
| 122 | + "-o", | |
| 123 | + "ExitOnForwardFailure=yes", | |
| 124 | + "-o", | |
| 125 | + "ServerAliveInterval=15", | |
| 126 | + "-o", | |
| 127 | + "ServerAliveCountMax=3", | |
| 128 | + "-n", | |
| 129 | + "-N", | |
| 130 | + "-L", | |
| 131 | + "41773:127.0.0.1:3773", | |
| 132 | + "julius@devbox", | |
| 133 | + ], | |
| 134 | + ); | |
| 135 | + }); | |
| 136 | + | |
| 92 | 137 | it("builds the remote t3 runner with npx and npm fallbacks", () => { |
| 93 | 138 | const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }); |
| 94 | 139 | |
| @@ -382,6 +427,17 @@ describe("ssh tunnel scripts", () => { · 287 unmodified lines | ||
| 382 | 427 | const first = yield* manager.ensureEnvironment(target); |
| 383 | 428 | assert.equal(first.httpBaseUrl, "http://127.0.0.1:41773/"); |
| 384 | 429 | |
| 430 | + const firstTunnelArgs = spawnedCommands.find((args) => args.includes("-N")); | |
| 431 | + assert.isDefined(firstTunnelArgs); | |
| 432 | + assert.include(firstTunnelArgs, "ControlMaster=no"); | |
| 433 | + assert.include(firstTunnelArgs, "ControlPath=none"); | |
| 434 | + assert.include(firstTunnelArgs, "ControlPersist=no"); | |
| 435 | + for (const args of spawnedCommands.filter((entry) => !entry.includes("-N"))) { | |
| 436 | + assert.notInclude(args, "ControlMaster=no"); | |
| 437 | + assert.notInclude(args, "ControlPath=none"); | |
| 438 | + assert.notInclude(args, "ControlPersist=no"); | |
| 439 | + } | |
| 440 | + | |
| 385 | 441 | yield* manager.disconnectEnvironment(target); |
| 386 | 442 | assert.equal(tunnelKillCount, 1); |
| 387 | 443 | assert.equal(stopCommandCount, 1); |
| @@ -917,6 +917,38 @@ const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(fu · 916 unmodified lines | ||
| 917 | 917 | return yield* net.reserveLoopbackPort(); |
| 918 | 918 | }); |
| 919 | 919 | |
| 920 | +export function buildSupervisedSshTunnelArgs(input: { | |
| 921 | + readonly target: DesktopSshEnvironmentTarget; | |
| 922 | + readonly batchMode: "yes" | "no"; | |
| 923 | + readonly localPort: number; | |
| 924 | + readonly remotePort: number; | |
| 925 | + readonly hostSpec: string; | |
| 926 | +}): ReadonlyArray<string> { | |
| 927 | + return [ | |
| 928 | + ...baseSshArgs(input.target, { batchMode: input.batchMode }), | |
| 929 | + // This process owns the tunnel lifecycle. Inherited ControlMaster settings | |
| 930 | + // can otherwise hand the forward to an existing master and let `ssh -N` | |
| 931 | + // exit while the manager believes it still supervises the connection. | |
| 932 | + "-o", | |
| 933 | + "ControlMaster=no", | |
| 934 | + "-o", | |
| 935 | + "ControlPath=none", | |
| 936 | + "-o", | |
| 937 | + "ControlPersist=no", | |
| 938 | + "-o", | |
| 939 | + "ExitOnForwardFailure=yes", | |
| 940 | + "-o", | |
| 941 | + "ServerAliveInterval=15", | |
| 942 | + "-o", | |
| 943 | + "ServerAliveCountMax=3", | |
| 944 | + "-n", | |
| 945 | + "-N", | |
| 946 | + "-L", | |
| 947 | + `${input.localPort}:127.0.0.1:${input.remotePort}`, | |
| 948 | + input.hostSpec, | |
| 949 | + ]; | |
| 950 | +} | |
| 951 | + | |
| 920 | 952 | const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: { |
| 921 | 953 | readonly key: string; |
| 922 | 954 | readonly resolvedTarget: DesktopSshEnvironmentTarget; |
| @@ -956,22 +988,13 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: · 33 unmodified lines | ||
| 956 | 988 | }), |
| 957 | 989 | ), |
| 958 | 990 | ); |
| 959 | − const args = [ | |
| 960 | − ...baseSshArgs(input.resolvedTarget, { | |
| 961 | − batchMode: input.authOptions.batchMode ?? "no", | |
| 962 | − }), | |
| 963 | − "-o", | |
| 964 | − "ExitOnForwardFailure=yes", | |
| 965 | − "-o", | |
| 966 | − "ServerAliveInterval=15", | |
| 967 | − "-o", | |
| 968 | − "ServerAliveCountMax=3", | |
| 969 | − "-n", | |
| 970 | − "-N", | |
| 971 | − "-L", | |
| 972 | − `${input.localPort}:127.0.0.1:${input.remotePort}`, | |
| 991 | + const args = buildSupervisedSshTunnelArgs({ | |
| 992 | + target: input.resolvedTarget, | |
| 993 | + batchMode: input.authOptions.batchMode ?? "no", | |
| 994 | + localPort: input.localPort, | |
| 995 | + remotePort: input.remotePort, | |
| 973 | 996 | hostSpec, |
| 974 | − ]; | |
| 997 | + }); | |
| 975 | 998 | const sshCommand = yield* resolveSshCommand; |
| 976 | 999 | const tunnelCommand = [sshCommand, ...args]; |
| 977 | 1000 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |