authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 02:15:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:15:49-07:00
log60e6fa30c3a8c1e17ba7fb80e5f85f44a0cd2693
treeb8c583c22c2346eef59c19bafea728e598cfdb73
parentf66cfe2219e3f255f573f6430f9c989c58ed517d
signature Signed by PGP key B5690EEEBB952194

fix(ssh): report remote stop failures without losing ownership (#10105)


3 files changed, 427 insertions(+), 177 deletions(-)

packages/ssh/src/runnerProcess.test.ts+124-1
...@@ -11,7 +11,7 @@ import * as Stream from "effect/Stream";...@@ -11,7 +11,7 @@ import * as Stream from "effect/Stream";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12import * as NodeNet from "node:net";12import * as NodeNet from "node:net";
1313
14import { buildRemoteT3RunnerScript } from "./tunnel.ts";14import { buildRemoteStopScript, buildRemoteT3RunnerScript } from "./tunnel.ts";
1515
16const Started = Schema.Struct({16const Started = Schema.Struct({
17 pid: Schema.Number,17 pid: Schema.Number,
...@@ -166,6 +166,129 @@ if (args.includes("--package")) {...@@ -166,6 +166,129 @@ if (args.includes("--package")) {
166 },166 },
167);167);
168168
169describe.skipIf(HostProcessPlatform.defaultValue() === "win32")(
170 "remote stop process ownership",
171 () => {
172 it.live.each(["graceful", "timeout", "external"] as const)(
173 "confirms the stop result for a %s server",
174 (mode) =>
175 Effect.gen(function* () {
176 const fs = yield* FileSystem.FileSystem;
177 const path = yield* Path.Path;
178 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
179 const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-stop-" });
180 const signalPath = path.join(fixture, "signals");
181 const child = yield* spawner.spawn(
182 ChildProcess.make(
183 process.execPath,
184 [
185 "--input-type=module",
186 "-e",
187 `import * as fs from "node:fs";
188import * as net from "node:net";
189const server = net.createServer((socket) => socket.end());
190let signals = 0;
191process.on("SIGTERM", () => {
192 fs.writeFileSync(process.argv[2], String(++signals));
193 if (process.argv[1] !== "timeout" || signals > 1) server.close();
194});
195server.listen(0, "127.0.0.1", () => {
196 process.stdout.write(JSON.stringify({ pid: process.pid, port: server.address().port, args: [] }) + "\\n");
197});
198`,
199 mode,
200 signalPath,
201 ],
202 { cwd: fixture, detached: false },
203 ),
204 );
205 // A failed assertion must still stop this captured fixture process.
206 yield* Effect.addFinalizer(() =>
207 child.kill({ killSignal: "SIGKILL" }).pipe(Effect.ignore),
208 );
209 const started = decodeStarted(
210 yield* child.stdout.pipe(
211 Stream.decodeText(),
212 Stream.splitLines,
213 Stream.take(1),
214 Stream.mkString,
215 ),
216 );
217 assert.equal(started.pid, child.pid);
218 const savedState = {
219 pid: `${child.pid}\n`,
220 port: `${started.port}\n`,
221 managed: mode === "external" ? "external\n" : "managed\n",
222 };
223 for (const [name, contents] of Object.entries(savedState)) {
224 yield* fs.writeFileString(path.join(fixture, name), contents);
225 }
226 const script = buildRemoteStopScript({
227 alias: "fixture",
228 hostname: "fixture",
229 username: null,
230 port: null,
231 });
232 // Redirect only the state directory. Never use the developer's SSH state.
233 const isolatedScript = script.replace(
234 /^STATE_DIR=.*$/mu,
235 'STATE_DIR="$T3_TEST_STATE_DIR"',
236 );
237 assert.notEqual(isolatedScript, script);
238 const runStop = Effect.fn("test.remoteStop")(function* () {
239 const stop = yield* spawner.spawn(
240 ChildProcess.make("/bin/sh", ["-s"], {
241 cwd: fixture,
242 env: { T3_TEST_STATE_DIR: fixture },
243 stdin: Stream.make(new TextEncoder().encode(isolatedScript)),
244 }),
245 );
246 return yield* Effect.all(
247 {
248 stdout: stop.stdout.pipe(Stream.decodeText(), Stream.mkString),
249 stderr: stop.stderr.pipe(Stream.decodeText(), Stream.mkString),
250 exitCode: stop.exitCode,
251 },
252 { concurrency: "unbounded" },
253 );
254 }, Effect.scoped);
255 let result = yield* runStop();
256 if (mode !== "graceful") {
257 assert.isTrue(yield* child.isRunning);
258 yield* Effect.callback<void, Error>((resume) => {
259 const connection = NodeNet.connect(started.port, "127.0.0.1");
260 connection.once("error", (error) => resume(Effect.fail(error)));
261 connection.once("close", () => resume(Effect.void));
262 return Effect.sync(() => connection.destroy());
263 });
264 }
265 if (mode === "timeout") {
266 assert.equal(result.exitCode, 1);
267 assert.equal(result.stdout, "");
268 assert.include(result.stderr, "did not stop within 2 seconds");
269 assert.equal(yield* fs.readFileString(signalPath), "1");
270 for (const [name, contents] of Object.entries(savedState)) {
271 assert.equal(yield* fs.readFileString(path.join(fixture, name)), contents);
272 }
273 result = yield* runStop();
274 }
275 assert.equal(result.exitCode, 0);
276 assert.equal(result.stdout, '{"stopped":true}\n');
277 assert.equal(result.stderr, "");
278 for (const name of Object.keys(savedState)) {
279 assert.isFalse(yield* fs.exists(path.join(fixture, name)));
280 }
281 if (mode === "external") {
282 assert.isFalse(yield* fs.exists(signalPath));
283 } else {
284 assert.equal(yield* child.exitCode, 0);
285 assert.equal(yield* fs.readFileString(signalPath), mode === "timeout" ? "2" : "1");
286 }
287 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
288 );
289 },
290);
291
169describe.skipIf(HostProcessPlatform.defaultValue() === "win32")(292describe.skipIf(HostProcessPlatform.defaultValue() === "win32")(
170 "remote runner install diagnostics",293 "remote runner install diagnostics",
171 () => {294 () => {
packages/ssh/src/tunnel.test.ts+199-55
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NetService from "@t3tools/shared/Net";3import * as NetService from "@t3tools/shared/Net";
4import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";5import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";7import * as Fiber from "effect/Fiber";
...@@ -13,6 +14,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";...@@ -13,6 +14,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
13import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1415
15import { SshPasswordPrompt } from "./auth.ts";16import { SshPasswordPrompt } from "./auth.ts";
17import { SshCommandError } from "./errors.ts";
16import {18import {
17 buildRemoteLaunchScript,19 buildRemoteLaunchScript,
18 buildRemotePairingScript,20 buildRemotePairingScript,
...@@ -384,63 +386,205 @@ describe("ssh tunnel scripts", () => {...@@ -384,63 +386,205 @@ describe("ssh tunnel scripts", () => {
384 }).pipe(Effect.provide(processLayer));386 }).pipe(Effect.provide(processLayer));
385 });387 });
386388
387 it.effect("closes the tunnel scope and starts fresh after disconnect", () => {389 it.effect.each(["successful stop", "failed stop"] as const)(
388 const spawnedCommands: Array<ReadonlyArray<string>> = [];390 "closes the tunnel scope and starts fresh after a %s",
389 let tunnelKillCount = 0;391 (mode) => {
390 let stopCommandCount = 0;392 const spawnedCommands: Array<ReadonlyArray<string>> = [];
391 const spawner = ChildProcessSpawner.make((command) =>393 let tunnelKillCount = 0;
392 Effect.sync(() => {394 let stopCommandCount = 0;
393 const args = commandArgs(command);395 const spawner = ChildProcessSpawner.make((command) =>
394 spawnedCommands.push(args);396 Effect.sync(() => {
395 if (args.includes("-N")) {397 const args = commandArgs(command);
396 return makeRunningProcess(() => {398 spawnedCommands.push(args);
397 tunnelKillCount += 1;399 if (args.includes("-N")) {
398 });400 return makeRunningProcess(() => {
399 }401 tunnelKillCount += 1;
400 if (args.includes("sh") && args.includes("--")) {402 });
401 return makeSuccessfulProcess('{"remotePort":3773}\n');403 }
402 }404 if (args.includes("sh") && args.includes("--")) {
403 if (args.includes("sh")) {405 return makeSuccessfulProcess('{"remotePort":3773}\n');
404 stopCommandCount += 1;406 }
405 return makeSuccessfulProcess('{"stopped":true}\n');407 if (args.includes("sh")) {
408 stopCommandCount += 1;
409 if (mode === "failed stop" && stopCommandCount === 1) {
410 return {
411 ...makeSuccessfulProcess(""),
412 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
413 stderr: Stream.make(
414 new TextEncoder().encode("Remote T3 server did not stop within 2 seconds.\n"),
415 ),
416 };
417 }
418 return makeSuccessfulProcess('{"stopped":true}\n');
419 }
420 return makeSuccessfulProcess("\n");
421 }),
422 );
423 const layer = Layer.mergeAll(
424 NodeServices.layer,
425 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
426 Layer.succeed(HttpClient.HttpClient, testHttpClient),
427 Layer.succeed(NetService.NetService, testNetService),
428 SshPasswordPrompt.disabledLayer,
429 SshEnvironmentManager.layer(),
430 );
431 const target = {
432 alias: "devbox",
433 hostname: "devbox.example.com",
434 username: "julius",
435 port: 2222,
436 } as const;
437
438 return Effect.gen(function* () {
439 const manager = yield* SshEnvironmentManager;
440
441 const first = yield* manager.ensureEnvironment(target);
442 assert.equal(first.httpBaseUrl, "http://127.0.0.1:41773/");
443 const firstTunnelArgs = spawnedCommands.find((args) => args.includes("-N"));
444 assert.isDefined(firstTunnelArgs);
445 assert.include(firstTunnelArgs, "ControlMaster=no");
446 assert.include(firstTunnelArgs, "ControlPath=none");
447 assert.include(firstTunnelArgs, "ControlPersist=no");
448
449 const disconnected = yield* Effect.result(manager.disconnectEnvironment(target));
450 if (mode === "failed stop") {
451 assert.isTrue(Result.isFailure(disconnected));
452 if (Result.isFailure(disconnected)) {
453 assert.instanceOf(disconnected.failure, SshCommandError);
454 assert.equal(
455 disconnected.failure.message,
456 "Remote T3 server did not stop within 2 seconds.",
457 );
458 }
459 } else {
460 assert.isTrue(Result.isSuccess(disconnected));
406 }461 }
407 return makeSuccessfulProcess("\n");462 assert.equal(tunnelKillCount, 1);
408 }),463 assert.equal(stopCommandCount, 1);
409 );
410 const layer = Layer.mergeAll(
411 NodeServices.layer,
412 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
413 Layer.succeed(HttpClient.HttpClient, testHttpClient),
414 Layer.succeed(NetService.NetService, testNetService),
415 SshPasswordPrompt.disabledLayer,
416 SshEnvironmentManager.layer(),
417 );
418 const target = {
419 alias: "devbox",
420 hostname: "devbox.example.com",
421 username: "julius",
422 port: 2222,
423 } as const;
424
425 return Effect.gen(function* () {
426 const manager = yield* SshEnvironmentManager;
427464
428 const first = yield* manager.ensureEnvironment(target);465 if (mode === "failed stop") {
429 assert.equal(first.httpBaseUrl, "http://127.0.0.1:41773/");466 yield* manager.disconnectEnvironment(target);
430 const firstTunnelArgs = spawnedCommands.find((args) => args.includes("-N"));467 assert.equal(tunnelKillCount, 1);
431 assert.isDefined(firstTunnelArgs);468 assert.equal(stopCommandCount, 2);
432 assert.include(firstTunnelArgs, "ControlMaster=no");469 }
433 assert.include(firstTunnelArgs, "ControlPath=none");
434 assert.include(firstTunnelArgs, "ControlPersist=no");
435
436 yield* manager.disconnectEnvironment(target);
437 assert.equal(tunnelKillCount, 1);
438 assert.equal(stopCommandCount, 1);
439470
440 yield* manager.ensureEnvironment(target);471 yield* manager.ensureEnvironment(target);
472
473 assert.equal(spawnedCommands.filter((args) => args.includes("-N")).length, 2);
474 assert.equal(tunnelKillCount, 1);
475 }).pipe(
476 Effect.provide(layer),
477 Effect.scoped,
478 Effect.andThen(
479 Effect.sync(() => {
480 assert.equal(tunnelKillCount, 2);
481 assert.equal(stopCommandCount, mode === "failed stop" ? 3 : 2);
482 }),
483 ),
484 );
485 },
486 );
441487
442 assert.equal(spawnedCommands.filter((args) => args.includes("-N")).length, 2);488 it.effect.each(["local tunnel", "remote server"] as const)(
443 assert.equal(tunnelKillCount, 1);489 "waits for %s shutdown before reconnecting the same target",
444 }).pipe(Effect.provide(layer), Effect.scoped);490 (stalledStep) =>
445 });491 Effect.gen(function* () {
492 const shutdownStarted = yield* Deferred.make<void>();
493 const finishShutdown = yield* Deferred.make<void>();
494 const reconnectsStarted = yield* Deferred.make<void>();
495 const pauseShutdown = Deferred.succeed(shutdownStarted, undefined).pipe(
496 Effect.andThen(Deferred.await(finishShutdown)),
497 );
498 let resolutions = 0;
499 let launches = 0;
500 let tunnels = 0;
501 let stops = 0;
502 let remoteRunning = false;
503 const target = { alias: "devbox", hostname: "devbox", username: null, port: null };
504 const spawner = ChildProcessSpawner.make((command) =>
505 Effect.gen(function* () {
506 const args = commandArgs(command);
507 const isTarget = args.includes(target.alias);
508 if (args.includes("-G")) {
509 if (isTarget && ++resolutions === 4) {
510 yield* Deferred.succeed(reconnectsStarted, undefined);
511 }
512 return makeSuccessfulProcess("");
513 }
514 if (args.includes("-N")) {
515 const tunnel = makeRunningProcess(() => undefined);
516 if (isTarget && ++tunnels === 1 && stalledStep === "local tunnel") {
517 return {
518 ...tunnel,
519 kill: (options?: ChildProcess.KillOptions) =>
520 pauseShutdown.pipe(Effect.andThen(tunnel.kill(options))),
521 };
522 }
523 return tunnel;
524 }
525 if (args.includes("--")) {
526 if (isTarget) {
527 launches += 1;
528 remoteRunning = true;
529 }
530 return makeSuccessfulProcess('{"remotePort":3773}\n');
531 }
532 const stop = makeSuccessfulProcess('{"stopped":true}\n');
533 if (!isTarget) return stop;
534 const pause = ++stops === 1 && stalledStep === "remote server";
535 return {
536 ...stop,
537 exitCode: (pause ? pauseShutdown : Effect.void).pipe(
538 Effect.andThen(
539 Effect.sync(() => {
540 remoteRunning = false;
541 return ChildProcessSpawner.ExitCode(0);
542 }),
543 ),
544 ),
545 };
546 }),
547 );
548 const layer = Layer.mergeAll(
549 NodeServices.layer,
550 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
551 Layer.succeed(HttpClient.HttpClient, testHttpClient),
552 Layer.succeed(NetService.NetService, testNetService),
553 SshPasswordPrompt.disabledLayer,
554 SshEnvironmentManager.layer(),
555 );
556 yield* Effect.gen(function* () {
557 const manager = yield* SshEnvironmentManager;
558 yield* manager.ensureEnvironment(target);
559 const disconnect = yield* Effect.forkChild(manager.disconnectEnvironment(target));
560 yield* Deferred.await(shutdownStarted);
561 const firstReconnect = yield* Effect.forkChild(manager.ensureEnvironment(target));
562 const secondReconnect = yield* Effect.forkChild(manager.ensureEnvironment(target));
563 yield* Deferred.await(reconnectsStarted);
564
565 yield* manager.ensureEnvironment({
566 alias: "other",
567 hostname: "other",
568 username: null,
569 port: null,
570 });
571 yield* TestClock.adjust(Duration.zero);
572 const launchesBeforeShutdown = launches;
573 yield* Deferred.succeed(finishShutdown, undefined);
574 yield* Fiber.join(disconnect);
575 const first = yield* Fiber.join(firstReconnect);
576 const second = yield* Fiber.join(secondReconnect);
577
578 assert.equal(launchesBeforeShutdown, 1);
579 assert.equal(launches, 2);
580 assert.equal(tunnels, 2);
581 assert.isTrue(remoteRunning);
582 assert.equal(first.httpBaseUrl, second.httpBaseUrl);
583 }).pipe(
584 Effect.ensuring(Deferred.succeed(finishShutdown, undefined)),
585 Effect.provide(layer),
586 Effect.scoped,
587 );
588 }),
589 );
446});590});
packages/ssh/src/tunnel.ts+104-121
...@@ -10,7 +10,6 @@ import * as NetService from "@t3tools/shared/Net";...@@ -10,7 +10,6 @@ import * as NetService from "@t3tools/shared/Net";
10import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";10import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";
11import { satisfiesSemverRange } from "@t3tools/shared/semver";11import { satisfiesSemverRange } from "@t3tools/shared/semver";
12import * as Context from "effect/Context";12import * as Context from "effect/Context";
13import * as Deferred from "effect/Deferred";
14import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";14import * as Exit from "effect/Exit";
16import * as FileSystem from "effect/FileSystem";15import * as FileSystem from "effect/FileSystem";
...@@ -18,6 +17,7 @@ import * as Layer from "effect/Layer";...@@ -18,6 +17,7 @@ import * as Layer from "effect/Layer";
18import * as Path from "effect/Path";17import * as Path from "effect/Path";
19import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
20import * as Scope from "effect/Scope";19import * as Scope from "effect/Scope";
20import * as Semaphore from "effect/Semaphore";
21import * as Stream from "effect/Stream";21import * as Stream from "effect/Stream";
22import { HttpClient } from "effect/unstable/http";22import { HttpClient } from "effect/unstable/http";
23import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";23import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
...@@ -98,15 +98,6 @@ type SshEnvironmentEffectError =...@@ -98,15 +98,6 @@ type SshEnvironmentEffectError =
98 | SshPasswordPromptError98 | SshPasswordPromptError
99 | NetService.NetError;99 | NetService.NetError;
100100
101function makeSshTunnelCancelledError(target: DesktopSshEnvironmentTarget): SshCommandError {
102 return new SshCommandError({
103 command: ["ssh"],
104 exitCode: null,
105 stderr: "",
106 message: `SSH environment connection was cancelled for ${target.alias || target.hostname}.`,
107 });
108}
109
110function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {101function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
111 return {102 return {
112 alias: target.alias,103 alias: target.alias,
...@@ -642,6 +633,10 @@ if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMO...@@ -642,6 +633,10 @@ if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMO
642 WAIT_COUNT=$((WAIT_COUNT + 1))633 WAIT_COUNT=$((WAIT_COUNT + 1))
643 sleep 0.1634 sleep 0.1
644 done635 done
636 if kill -0 "$REMOTE_PID" 2>/dev/null; then
637 printf 'Remote T3 server with PID %s did not stop within 2 seconds. Its ownership files were kept.\\n' "$REMOTE_PID" >&2
638 exit 1
639 fi
645fi640fi
646rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"641rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"
647printf '{"stopped":true}\\n'642printf '{"stopped":true}\\n'
...@@ -1176,12 +1171,22 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1176,12 +1171,22 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1176): Effect.fn.Return<SshEnvironmentManagerShape, never, Scope.Scope> {1171): Effect.fn.Return<SshEnvironmentManagerShape, never, Scope.Scope> {
1177 const managerScope = yield* Scope.Scope;1172 const managerScope = yield* Scope.Scope;
1178 const tunnels = new Map<string, SshTunnelEntry>();1173 const tunnels = new Map<string, SshTunnelEntry>();
1179 const pendingTunnelEntries = new Map<1174 const targetLocks = new Map<string, Semaphore.Semaphore>();
1180 string,
1181 Deferred.Deferred<SshTunnelEntry, SshEnvironmentEffectError>
1182 >();
1183 const authSecrets = new Map<string, string>();1175 const authSecrets = new Map<string, string>();
11841176
1177 // Keep one lock per target so reconnect cannot reuse a server while stop is pending.
1178 const withTargetLock = Effect.fn("ssh/tunnel.withTargetLock")(function* <A, E, R>(
1179 key: string,
1180 effect: Effect.Effect<A, E, R>,
1181 ): Effect.fn.Return<A, E, R> {
1182 let lock = targetLocks.get(key);
1183 if (lock === undefined) {
1184 lock = Semaphore.makeUnsafe(1);
1185 targetLocks.set(key, lock);
1186 }
1187 return yield* lock.withPermits(1)(effect);
1188 });
1189
1185 const closeTunnelEntry = Effect.fn("ssh/tunnel.closeTunnelEntry")(function* (1190 const closeTunnelEntry = Effect.fn("ssh/tunnel.closeTunnelEntry")(function* (
1186 entry: SshTunnelEntry,1191 entry: SshTunnelEntry,
1187 ) {1192 ) {
...@@ -1200,18 +1205,6 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1200,18 +1205,6 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1200 });1205 });
1201 });1206 });
12021207
1203 const cancelPendingTunnelEntry = Effect.fn("ssh/tunnel.cancelPendingTunnelEntry")(function* (
1204 key: string,
1205 target: DesktopSshEnvironmentTarget,
1206 ) {
1207 const pending = pendingTunnelEntries.get(key);
1208 if (!pending) {
1209 return;
1210 }
1211 pendingTunnelEntries.delete(key);
1212 yield* Deferred.fail(pending, makeSshTunnelCancelledError(target)).pipe(Effect.ignore);
1213 });
1214
1215 yield* Scope.addFinalizer(1208 yield* Scope.addFinalizer(
1216 managerScope,1209 managerScope,
1217 Effect.sync(() => [...tunnels.values()]).pipe(1210 Effect.sync(() => [...tunnels.values()]).pipe(
...@@ -1392,7 +1385,17 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1392,7 +1385,17 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1392 yield* Scope.addFinalizer(1385 yield* Scope.addFinalizer(
1393 entryScope,1386 entryScope,
1394 Effect.gen(function* () {1387 Effect.gen(function* () {
1395 if (tunnels.get(tunnelEntry.key) !== tunnelEntry) {1388 const stopRemote = tunnels.get(tunnelEntry.key) === tunnelEntry;
1389 if (stopRemote) {
1390 tunnels.delete(tunnelEntry.key);
1391 }
1392 yield* tunnelEntry.process
1393 .kill({
1394 killSignal: "SIGTERM",
1395 forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS,
1396 })
1397 .pipe(Effect.ignore);
1398 if (!stopRemote) {
1396 return;1399 return;
1397 }1400 }
1398 yield* Effect.logDebug("ssh.environment.tunnel.finalizer.start", {1401 yield* Effect.logDebug("ssh.environment.tunnel.finalizer.start", {
...@@ -1401,34 +1404,24 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1401,34 +1404,24 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1401 localPort: tunnelEntry.localPort,1404 localPort: tunnelEntry.localPort,
1402 remotePort: tunnelEntry.remotePort,1405 remotePort: tunnelEntry.remotePort,
1403 });1406 });
1404 tunnels.delete(tunnelEntry.key);
1405 const authSecret = authSecrets.get(tunnelEntry.key) ?? null;1407 const authSecret = authSecrets.get(tunnelEntry.key) ?? null;
1406 yield* Effect.all(1408 yield* stopRemoteServer(
1407 [1409 tunnelEntry.target,
1408 tunnelEntry.process.kill({1410 authSecret === null
1409 killSignal: "SIGTERM",1411 ? {
1410 forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS,1412 batchMode: "yes",
1411 }),1413 interactiveAuth: false,
1412 stopRemoteServer(1414 }
1413 tunnelEntry.target,1415 : {
1414 authSecret === null1416 authSecret,
1415 ? {1417 batchMode: "no",
1416 batchMode: "yes",1418 interactiveAuth: true,
1417 interactiveAuth: false,1419 },
1418 }1420 ).pipe(
1419 : {1421 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService),
1420 authSecret,1422 Effect.provideService(FileSystem.FileSystem, fileSystemService),
1421 batchMode: "no",1423 Effect.provideService(Path.Path, pathService),
1422 interactiveAuth: true,1424 );
1423 },
1424 ).pipe(
1425 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService),
1426 Effect.provideService(FileSystem.FileSystem, fileSystemService),
1427 Effect.provideService(Path.Path, pathService),
1428 ),
1429 ],
1430 { concurrency: "unbounded" },
1431 ).pipe(Effect.ignore);
1432 yield* Effect.logDebug("ssh.environment.tunnel.finalizer.succeeded", {1425 yield* Effect.logDebug("ssh.environment.tunnel.finalizer.succeeded", {
1433 ...sshTargetLogFields(tunnelEntry.target),1426 ...sshTargetLogFields(tunnelEntry.target),
1434 key: tunnelEntry.key,1427 key: tunnelEntry.key,
...@@ -1451,7 +1444,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1451,7 +1444,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1451 resolvedTarget: DesktopSshEnvironmentTarget,1444 resolvedTarget: DesktopSshEnvironmentTarget,
1452 runner?: RemoteT3RunnerOptions,1445 runner?: RemoteT3RunnerOptions,
1453 ): Effect.fn.Return<SshTunnelEntry, SshEnvironmentEffectError, SshEnvironmentEffectContext> {1446 ): Effect.fn.Return<SshTunnelEntry, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1454 let entry = tunnels.get(key) ?? null;1447 const entry = tunnels.get(key) ?? null;
14551448
1456 if (entry !== null) {1449 if (entry !== null) {
1457 yield* Effect.logDebug("ssh.environment.tunnel.existing.check", {1450 yield* Effect.logDebug("ssh.environment.tunnel.existing.check", {
...@@ -1480,22 +1473,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1480,22 +1473,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1480 cause: readinessExit.cause,1473 cause: readinessExit.cause,
1481 });1474 });
1482 yield* closeTunnelEntry(entry);1475 yield* closeTunnelEntry(entry);
1483 yield* cancelPendingTunnelEntry(key, resolvedTarget);
1484 entry = null;
1485 }1476 }
14861477
1487 const pending = pendingTunnelEntries.get(key);
1488 if (pending) {
1489 yield* Effect.logDebug("ssh.environment.tunnel.pending.await", {
1490 ...sshTargetLogFields(resolvedTarget),
1491 key,
1492 });
1493 return yield* Deferred.await(pending);
1494 }
1495
1496 const deferred = yield* Deferred.make<SshTunnelEntry, SshEnvironmentEffectError>();
1497 pendingTunnelEntries.set(key, deferred);
1498
1499 return yield* createTunnelEntry({1478 return yield* createTunnelEntry({
1500 key,1479 key,
1501 resolvedTarget,1480 resolvedTarget,
...@@ -1508,13 +1487,6 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1508,13 +1487,6 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1508 cause,1487 cause,
1509 }),1488 }),
1510 ),1489 ),
1511 Effect.onExit((exit) =>
1512 Effect.sync(() => {
1513 if (pendingTunnelEntries.get(key) === deferred) {
1514 pendingTunnelEntries.delete(key);
1515 }
1516 }).pipe(Effect.andThen(Deferred.done(deferred, exit))),
1517 ),
1518 );1490 );
1519 });1491 });
15201492
...@@ -1553,33 +1525,39 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1553,33 +1525,39 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1553 ...sshRunnerLogFields(runner),1525 ...sshRunnerLogFields(runner),
1554 key,1526 key,
1555 });1527 });
1556 const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner);1528 return yield* withTargetLock(
1529 key,
1530 Effect.gen(function* () {
1531 const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner);
1532
1533 const pairingResult = requestOptions?.issuePairingToken
1534 ? yield* runWithSshAuth({
1535 key,
1536 target: entry.target,
1537 operation: (authOptions) =>
1538 issueRemotePairingToken(entry.target, authOptions, runner),
1539 })
1540 : null;
1541 const pairingToken = pairingResult?.credential ?? null;
15571542
1558 const pairingResult = requestOptions?.issuePairingToken1543 yield* Effect.logInfo("ssh.environment.ensure.succeeded", {
1559 ? yield* runWithSshAuth({1544 ...sshTargetLogFields(entry.target),
1560 key,1545 key,
1546 localPort: entry.localPort,
1547 remotePort: entry.remotePort,
1548 remoteServerKind: entry.remoteServerKind,
1549 issuedPairingToken: pairingToken !== null,
1550 });
1551 return {
1561 target: entry.target,1552 target: entry.target,
1562 operation: (authOptions) => issueRemotePairingToken(entry.target, authOptions, runner),1553 httpBaseUrl: entry.httpBaseUrl,
1563 })1554 wsBaseUrl: entry.wsBaseUrl,
1564 : null;1555 pairingToken,
1565 const pairingToken = pairingResult?.credential ?? null;1556 remotePort: entry.remotePort,
15661557 ...(entry.remoteServerKind ? { remoteServerKind: entry.remoteServerKind } : {}),
1567 yield* Effect.logInfo("ssh.environment.ensure.succeeded", {1558 };
1568 ...sshTargetLogFields(entry.target),1559 }),
1569 key,1560 );
1570 localPort: entry.localPort,
1571 remotePort: entry.remotePort,
1572 remoteServerKind: entry.remoteServerKind,
1573 issuedPairingToken: pairingToken !== null,
1574 });
1575 return {
1576 target: entry.target,
1577 httpBaseUrl: entry.httpBaseUrl,
1578 wsBaseUrl: entry.wsBaseUrl,
1579 pairingToken,
1580 remotePort: entry.remotePort,
1581 ...(entry.remoteServerKind ? { remoteServerKind: entry.remoteServerKind } : {}),
1582 };
1583 });1561 });
15841562
1585 const disconnectEnvironment = Effect.fn("ssh/tunnel.disconnectEnvironment")(function* (1563 const disconnectEnvironment = Effect.fn("ssh/tunnel.disconnectEnvironment")(function* (
...@@ -1593,28 +1571,33 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1593,28 +1571,33 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1593 ...(target.port !== null ? { port: target.port } : {}),1571 ...(target.port !== null ? { port: target.port } : {}),
1594 };1572 };
1595 const key = targetConnectionKey(resolvedTarget);1573 const key = targetConnectionKey(resolvedTarget);
1596 const entry = tunnels.get(key) ?? null;1574 yield* withTargetLock(
1597 yield* Effect.logDebug("ssh.environment.disconnect.targetResolved", {
1598 ...sshTargetLogFields(resolvedTarget),
1599 key,
1600 hasTunnel: entry !== null,
1601 hasPendingTunnel: pendingTunnelEntries.has(key),
1602 });
1603 if (entry !== null) {
1604 yield* closeTunnelEntry(entry);
1605 }
1606 yield* cancelPendingTunnelEntry(key, resolvedTarget);
1607 if (entry === null) {
1608 yield* runWithSshAuth({
1609 key,
1610 target: resolvedTarget,
1611 operation: (authOptions) => stopRemoteServer(resolvedTarget, authOptions),
1612 });
1613 }
1614 yield* Effect.logInfo("ssh.environment.disconnect.succeeded", {
1615 ...sshTargetLogFields(resolvedTarget),
1616 key,1575 key,
1617 });1576 Effect.gen(function* () {
1577 const entry = tunnels.get(key) ?? null;
1578 yield* Effect.logDebug("ssh.environment.disconnect.targetResolved", {
1579 ...sshTargetLogFields(resolvedTarget),
1580 key,
1581 hasTunnel: entry !== null,
1582 });
1583 if (entry !== null) {
1584 // Explicit disconnect owns the remote stop so its failure reaches the caller.
1585 yield* Effect.gen(function* () {
1586 tunnels.delete(key);
1587 yield* closeTunnelEntry(entry);
1588 }).pipe(Effect.uninterruptible);
1589 }
1590 yield* runWithSshAuth({
1591 key,
1592 target: resolvedTarget,
1593 operation: (authOptions) => stopRemoteServer(resolvedTarget, authOptions),
1594 });
1595 yield* Effect.logInfo("ssh.environment.disconnect.succeeded", {
1596 ...sshTargetLogFields(resolvedTarget),
1597 key,
1598 });
1599 }),
1600 );
1618 });1601 });
16191602
1620 return SshEnvironmentManager.of({ ensureEnvironment, disconnectEnvironment });1603 return SshEnvironmentManager.of({ ensureEnvironment, disconnectEnvironment });