Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/ssh/src/tunnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,78 @@ describe("ssh tunnel scripts", () => {
),
);

it.effect("recreates a stale tunnel after one probe timeout", () =>
Effect.gen(function* () {
let tunnelStarts = 0;
let tunnelStops = 0;
let remoteStops = 0;
let probeMode: "ready" | "refused" = "ready";
const reuseProbeStarted = yield* Deferred.make<void>();
let probeCount = 0;
const spawner = ChildProcessSpawner.make((command) =>
Effect.sync(() => {
const args = commandArgs(command);
if (args.includes("-N")) {
tunnelStarts += 1;
return makeRunningProcess(() => {
tunnelStops += 1;
});
}
if (args.includes("sh") && args.includes("--")) {
return makeSuccessfulProcess('{"remotePort":3773,"serverKind":"managed"}\n');
}
if (args.includes("sh")) {
remoteStops += 1;
return makeSuccessfulProcess('{"stopped":true}\n');
}
return makeSuccessfulProcess("\n");
}),
);
const client = HttpClient.make((request) =>
Effect.gen(function* () {
probeCount += 1;
if (probeMode === "refused") {
yield* Deferred.succeed(reuseProbeStarted, undefined);
return HttpClientResponse.fromWeb(request, new Response("", { status: 503 }));
}
return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
}),
);
const layer = Layer.mergeAll(
NodeServices.layer,
TestClock.layer(),
Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
Layer.succeed(HttpClient.HttpClient, client),
Layer.succeed(NetService.NetService, testNetService),
SshPasswordPrompt.disabledLayer,
SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }),
);
const target = {
alias: "stale-link",
hostname: "stale-link.example.com",
username: "tester",
port: 22,
} as const;

return yield* Effect.gen(function* () {
const manager = yield* SshEnvironmentManager;
yield* manager.ensureEnvironment(target);
probeMode = "refused";
const reconnect = yield* Effect.forkChild(manager.ensureEnvironment(target));
yield* Deferred.await(reuseProbeStarted);
yield* Effect.yieldNow;
yield* TestClock.adjust(Duration.seconds(8));
probeMode = "ready";
yield* Fiber.join(reconnect);

assert.isAtLeast(probeCount, 3);
assert.equal(tunnelStarts, 2);
assert.equal(tunnelStops, 1);
assert.equal(remoteStops, 1);
}).pipe(Effect.provide(layer), Effect.scoped);
}),
);

it("preserves primitive readiness reason values in diagnostic output", () => {
assert.deepEqual(
describeReadinessCause({
Expand Down
10 changes: 9 additions & 1 deletion packages/ssh/src/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ import {
const DEFAULT_REMOTE_PORT = 3773;
const REMOTE_PORT_SCAN_WINDOW = 200;
const SSH_READY_TIMEOUT_MS = 20_000;
// Remote loopback probes must fit within the shell's 2s reuse window.
const SSH_READY_PROBE_TIMEOUT_MS = 1_000;
// Probes through SSH cross the network, both when connecting and reusing a tunnel.
const SSH_TUNNEL_READY_PROBE_TIMEOUT_MS = 8_000;
const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000;
const REMOTE_READY_TIMEOUT_MS = 60_000;
const REMOTE_LAUNCH_TIMEOUT_MS = 90_000;
Expand Down Expand Up @@ -1260,6 +1263,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input:
waitForHttpReady({
baseUrl: input.httpBaseUrl,
timeoutMs: SSH_READY_TIMEOUT_MS,
probeTimeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS,
}),
exitFailure,
).pipe(
Expand Down Expand Up @@ -1615,7 +1619,11 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
remotePort: entry.remotePort,
});
const readinessExit = yield* Effect.exit(
waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeoutMs: 2_000 }),
waitForHttpReady({
baseUrl: entry.httpBaseUrl,
timeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS,
probeTimeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS,
}),
);
if (Exit.isSuccess(readinessExit)) {
yield* Effect.logDebug("ssh.environment.tunnel.reused", {
Expand Down
Loading