diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index 87b2f3416f..886bfb5166 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -8,7 +8,10 @@ import * as Random from "effect/Random"; import { ServerConfig } from "../../config.ts"; import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; import packageJson from "../../../package.json" with { type: "json" }; -import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; +import { + ServerEnvironmentLabelCommandRunnerLive, + resolveServerEnvironmentLabel, +} from "./ServerEnvironmentLabel.ts"; function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { switch (process.platform) { @@ -93,4 +96,6 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function } satisfies ServerEnvironmentShape; }); -export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); +export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()).pipe( + Layer.provide(ServerEnvironmentLabelCommandRunnerLive), +); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts index 26637b05ff..298f42eb50 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts @@ -1,21 +1,43 @@ -import { afterEach, describe, expect, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import { vi } from "vitest"; +import * as Layer from "effect/Layer"; -vi.mock("../../processRunner.ts", () => ({ - runProcess: vi.fn(), -})); - -import { runProcess } from "../../processRunner.ts"; -import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; - -const mockedRunProcess = vi.mocked(runProcess); +import { + ServerEnvironmentLabelCommandError, + ServerEnvironmentLabelCommandRunner, + resolveServerEnvironmentLabel, +} from "./ServerEnvironmentLabel.ts"; const NoopFileSystemLayer = FileSystem.layerNoop({}); - -afterEach(() => { - mockedRunProcess.mockReset(); -}); +const NoopCommandRunnerLayer = Layer.mock(ServerEnvironmentLabelCommandRunner)({}); + +interface CommandCall { + readonly command: string; + readonly args: readonly string[]; +} + +function commandRunnerLayer(input: { + readonly calls?: CommandCall[]; + readonly run: ( + command: string, + args: readonly string[], + ) => Effect.Effect< + { readonly stdout: string; readonly exitCode: number }, + ServerEnvironmentLabelCommandError + >; +}) { + return Layer.mock(ServerEnvironmentLabelCommandRunner)({ + run: (command, args) => + Effect.gen(function* () { + input.calls?.push({ command, args }); + return yield* input.run(command, args); + }), + }); +} + +function testLayer(commandLayer = NoopCommandRunnerLayer) { + return Layer.merge(NoopFileSystemLayer, commandLayer); +} describe("resolveServerEnvironmentLabel", () => { it.effect("uses hostname fallback regardless of launch mode", () => @@ -24,34 +46,33 @@ describe("resolveServerEnvironmentLabel", () => { cwdBaseName: "t3code", platform: "win32", hostname: "macbook-pro", - }).pipe(Effect.provide(NoopFileSystemLayer)); + }).pipe(Effect.provide(testLayer())); - expect(result).toBe("macbook-pro"); + assert.equal(result, "macbook-pro"); }), ); it.effect("prefers the macOS ComputerName", () => Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: " Julius's MacBook Pro \n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + const calls: CommandCall[] = []; const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", platform: "darwin", hostname: "macbook-pro", - }).pipe(Effect.provide(NoopFileSystemLayer)); - - expect(result).toBe("Julius's MacBook Pro"); - expect(mockedRunProcess).toHaveBeenCalledWith( - "scutil", - ["--get", "ComputerName"], - expect.objectContaining({ allowNonZeroExit: true }), + }).pipe( + Effect.provide( + testLayer( + commandRunnerLayer({ + calls, + run: () => Effect.succeed({ stdout: " Julius's MacBook Pro \n", exitCode: 0 }), + }), + ), + ), ); + + assert.equal(result, "Julius's MacBook Pro"); + assert.deepEqual(calls, [{ command: "scutil", args: ["--get", "ComputerName"] }]); }), ); @@ -63,43 +84,44 @@ describe("resolveServerEnvironmentLabel", () => { hostname: "buildbox", }).pipe( Effect.provide( - FileSystem.layerNoop({ - exists: (path) => Effect.succeed(path === "/etc/machine-info"), - readFileString: (path) => - path === "/etc/machine-info" - ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') - : Effect.succeed(""), - }), + Layer.merge( + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === "/etc/machine-info"), + readFileString: (path) => + path === "/etc/machine-info" + ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') + : Effect.succeed(""), + }), + NoopCommandRunnerLayer, + ), ), ); - expect(result).toBe("Build Agent 01"); - expect(mockedRunProcess).not.toHaveBeenCalled(); + assert.equal(result, "Build Agent 01"); }), ); it.effect("falls back to hostnamectl pretty hostname on Linux", () => Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: "CI Runner\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + const calls: CommandCall[] = []; const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", platform: "linux", hostname: "runner-01", - }).pipe(Effect.provide(NoopFileSystemLayer)); - - expect(result).toBe("CI Runner"); - expect(mockedRunProcess).toHaveBeenCalledWith( - "hostnamectl", - ["--pretty"], - expect.objectContaining({ allowNonZeroExit: true }), + }).pipe( + Effect.provide( + testLayer( + commandRunnerLayer({ + calls, + run: () => Effect.succeed({ stdout: "CI Runner\n", exitCode: 0 }), + }), + ), + ), ); + + assert.equal(result, "CI Runner"); + assert.deepEqual(calls, [{ command: "hostnamectl", args: ["--pretty"] }]); }), ); @@ -109,43 +131,56 @@ describe("resolveServerEnvironmentLabel", () => { cwdBaseName: "t3code", platform: "win32", hostname: "JULIUS-LAPTOP", - }).pipe(Effect.provide(NoopFileSystemLayer)); + }).pipe(Effect.provide(testLayer())); - expect(result).toBe("JULIUS-LAPTOP"); + assert.equal(result, "JULIUS-LAPTOP"); }), ); it.effect("falls back to the hostname when the friendly-label command is missing", () => Effect.gen(function* () { - mockedRunProcess.mockRejectedValueOnce(new Error("spawn scutil ENOENT")); - const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", platform: "darwin", hostname: "macbook-pro", - }).pipe(Effect.provide(NoopFileSystemLayer)); + }).pipe( + Effect.provide( + testLayer( + commandRunnerLayer({ + run: (command, args) => + Effect.fail( + new ServerEnvironmentLabelCommandError({ + command, + args: [...args], + message: "spawn scutil ENOENT", + }), + ), + }), + ), + ), + ); - expect(result).toBe("macbook-pro"); + assert.equal(result, "macbook-pro"); }), ); it.effect("falls back to the cwd basename when the hostname is blank", () => Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: " ", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", platform: "linux", hostname: " ", - }).pipe(Effect.provide(NoopFileSystemLayer)); + }).pipe( + Effect.provide( + testLayer( + commandRunnerLayer({ + run: () => Effect.succeed({ stdout: " ", exitCode: 0 }), + }), + ), + ), + ); - expect(result).toBe("t3code"); + assert.equal(result, "t3code"); }), ); }); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts index 16fa929521..4e0076a7b1 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts @@ -1,9 +1,14 @@ import * as OS from "node:os"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { runProcess } from "../../processRunner.ts"; +import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; interface ResolveServerEnvironmentLabelInput { readonly cwdBaseName: string; @@ -11,12 +16,82 @@ interface ResolveServerEnvironmentLabelInput { readonly hostname?: string | null; } -function normalizeLabel(value: string | null | undefined): string | null { +interface ServerEnvironmentLabelCommandResult { + readonly stdout: string; + readonly exitCode: number; +} + +interface ServerEnvironmentLabelCommandRunnerShape { + readonly run: ( + command: string, + args: readonly string[], + ) => Effect.Effect; +} + +export class ServerEnvironmentLabelCommandError extends Schema.TaggedErrorClass()( + "ServerEnvironmentLabelCommandError", + { + command: Schema.String, + args: Schema.Array(Schema.String), + message: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class ServerEnvironmentLabelCommandRunner extends Context.Service< + ServerEnvironmentLabelCommandRunner, + ServerEnvironmentLabelCommandRunnerShape +>()("t3/environment/Layers/ServerEnvironmentLabel/CommandRunner") {} + +export const ServerEnvironmentLabelCommandRunnerLive = Layer.effect( + ServerEnvironmentLabelCommandRunner, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + return ServerEnvironmentLabelCommandRunner.of({ + run: (command, args) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(command, [...args], { + shell: process.platform === "win32", + }), + ); + const [stdout, , exitCode] = yield* Effect.all( + [ + collectUint8StreamText({ stream: child.stdout }), + collectUint8StreamText({ stream: child.stderr }), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + + return { + stdout: stdout.text, + exitCode: Number(exitCode), + }; + }).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentLabelCommandError({ + command, + args: [...args], + message: `Failed to run friendly host label command: ${command}.`, + cause, + }), + ), + ), + ), + }); + }), +); + +function normalizeLabel(value: string | null | undefined): Option.Option { const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : null; + return trimmed && trimmed.length > 0 ? Option.some(trimmed) : Option.none(); } -function parseMachineInfoValue(raw: string, key: string): string | null { +function parseMachineInfoValue(raw: string, key: string): Option.Option { for (const line of raw.split(/\r?\n/g)) { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { @@ -31,7 +106,7 @@ function parseMachineInfoValue(raw: string, key: string): string | null { } return normalizeLabel(value); } - return null; + return Option.none(); } const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { @@ -40,31 +115,26 @@ const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { .exists("/etc/machine-info") .pipe(Effect.orElseSucceed(() => false)); if (!exists) { - return null; + return Option.none(); } - return yield* fileSystem - .readFileString("/etc/machine-info") - .pipe(Effect.orElseSucceed(() => null)); + const raw = yield* fileSystem.readFileString("/etc/machine-info").pipe(Effect.option); + + return Option.flatMap(raw, normalizeLabel); }); const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( command: string, args: readonly string[], ) { - const result = yield* Effect.tryPromise({ - try: () => - runProcess(command, args, { - allowNonZeroExit: true, - }), - catch: () => null, - }).pipe(Effect.orElseSucceed(() => null)); - - if (!result || result.code !== 0) { - return null; + const commandRunner = yield* ServerEnvironmentLabelCommandRunner; + const result = yield* commandRunner.run(command, args).pipe(Effect.option); + + if (Option.isNone(result) || result.value.exitCode !== 0) { + return Option.none(); } - return normalizeLabel(result.stdout); + return normalizeLabel(result.value.stdout); }); const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( @@ -75,10 +145,10 @@ const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* } if (platform === "linux") { - const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); - if (machineInfo) { - const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); - if (prettyHostname) { + const machineInfo = yield* readLinuxMachineInfo(); + if (Option.isSome(machineInfo)) { + const prettyHostname = parseMachineInfoValue(machineInfo.value, "PRETTY_HOSTNAME"); + if (Option.isSome(prettyHostname)) { return prettyHostname; } } @@ -86,7 +156,7 @@ const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); } - return null; + return Option.none(); }); export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( @@ -94,14 +164,14 @@ export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironment ) { const platform = input.platform ?? process.platform; const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform); - if (friendlyHostLabel) { - return friendlyHostLabel; + if (Option.isSome(friendlyHostLabel)) { + return friendlyHostLabel.value; } const hostname = normalizeLabel(input.hostname ?? OS.hostname()); - if (hostname) { - return hostname; + if (Option.isSome(hostname)) { + return hostname.value; } - return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; + return Option.getOrElse(normalizeLabel(input.cwdBaseName), () => "T3 environment"); }); diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index d95ffed49d..906cc293b3 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -139,7 +139,7 @@ describe("ssh command", () => { username: "julius", port: 2222, }, - { timeoutMs: 1 }, + { timeout: Duration.millis(1) }, ), ), ); diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index dc8839378c..44ef5bb8d6 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -14,7 +14,7 @@ import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; -const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; +const DEFAULT_SSH_COMMAND_TIMEOUT = Duration.seconds(60); const encoder = new TextEncoder(); @@ -27,7 +27,7 @@ export interface RunSshCommandOptions extends SshAuthOptions { readonly preHostArgs?: ReadonlyArray; readonly remoteCommandArgs?: ReadonlyArray; readonly stdin?: string; - readonly timeoutMs?: number; + readonly timeout?: Duration.Input; } export function parseSshResolveOutput(alias: string, stdout: string): DesktopSshEnvironmentTarget { @@ -132,6 +132,10 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { }; } +function resolveSshCommandTimeout(input: RunSshCommandOptions): Duration.Duration { + return Duration.fromInputUnsafe(input.timeout ?? DEFAULT_SSH_COMMAND_TIMEOUT); +} + function stdinStream(input: string | undefined) { return input === undefined ? Stream.empty : Stream.make(encoder.encode(input)); } @@ -170,11 +174,12 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ...(input.remoteCommandArgs ?? []), ]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const timeout = resolveSshCommandTimeout(input); yield* Effect.logDebug("ssh.command.start", { ...sshTargetLogFields(target), command: ["ssh", ...args], hasStdin: input.stdin !== undefined, - timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, + timeoutMs: Duration.toMillis(timeout), }); const child = yield* spawner .spawn( @@ -258,10 +263,12 @@ export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( SshCommandError | SshInvalidTargetError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { + const timeout = resolveSshCommandTimeout(input); + const timeoutMs = Duration.toMillis(timeout); return yield* Effect.scopedWith((commandScope) => runSshCommandInScope(target, input, commandScope), ).pipe( - Effect.timeoutOption(Duration.millis(input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS)), + Effect.timeoutOption(timeout), Effect.flatMap((result) => Option.match(result, { onSome: Effect.succeed, @@ -269,7 +276,7 @@ export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( Effect.gen(function* () { yield* Effect.logWarning("ssh.command.timedOut", { ...sshTargetLogFields(target), - timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, + timeoutMs, remoteCommandArgs: input.remoteCommandArgs ?? [], preHostArgs: input.preHostArgs ?? [], hasStdin: input.stdin !== undefined, @@ -278,7 +285,7 @@ export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( command: ["ssh"], exitCode: null, stderr: "", - message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`, + message: `SSH command timed out after ${timeoutMs}ms.`, }); }), }), diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 80e684d861..a2e7ce8696 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -242,9 +242,9 @@ describe("ssh tunnel scripts", () => { Effect.result( waitForHttpReady({ baseUrl: "http://127.0.0.1:41773/", - timeoutMs: 1_000, - intervalMs: 100, - probeTimeoutMs: 250, + timeout: Duration.seconds(1), + interval: Duration.millis(100), + probeTimeout: Duration.millis(250), }), ), ); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 5ee5c68477..870986529e 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -50,11 +50,11 @@ import { export const DEFAULT_REMOTE_PORT = 3773; const REMOTE_PORT_SCAN_WINDOW = 200; -const SSH_READY_TIMEOUT_MS = 20_000; -const SSH_READY_PROBE_TIMEOUT_MS = 1_000; -const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; -const REMOTE_READY_TIMEOUT_MS = 15_000; -const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; +const SSH_READY_TIMEOUT = Duration.seconds(20); +const SSH_READY_PROBE_TIMEOUT = Duration.seconds(1); +const TUNNEL_SHUTDOWN_TIMEOUT = Duration.seconds(2); +const REMOTE_READY_TIMEOUT = Duration.seconds(15); +const REMOTE_REUSE_READY_TIMEOUT = Duration.seconds(2); export interface RemoteT3RunnerOptions { readonly packageSpec?: string; @@ -686,9 +686,9 @@ export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT), T3_DEFAULT_REMOTE_PORT: String(DEFAULT_REMOTE_PORT), T3_REMOTE_PORT_SCAN_WINDOW: String(REMOTE_PORT_SCAN_WINDOW), - T3_READY_TIMEOUT_MS: String(REMOTE_READY_TIMEOUT_MS), - T3_REUSE_READY_TIMEOUT_MS: String(REMOTE_REUSE_READY_TIMEOUT_MS), - T3_READY_PROBE_TIMEOUT_MS: String(SSH_READY_PROBE_TIMEOUT_MS), + T3_READY_TIMEOUT_MS: String(Duration.toMillis(REMOTE_READY_TIMEOUT)), + T3_REUSE_READY_TIMEOUT_MS: String(Duration.toMillis(REMOTE_REUSE_READY_TIMEOUT)), + T3_READY_PROBE_TIMEOUT_MS: String(Duration.toMillis(SSH_READY_PROBE_TIMEOUT)), }); } @@ -860,7 +860,7 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")( const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s"], stdin: buildRemoteLogTailScript(target), - timeoutMs: 10_000, + timeout: Duration.seconds(10), ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -870,16 +870,19 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")( export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(function* (input: { readonly baseUrl: string; - readonly timeoutMs?: number; - readonly intervalMs?: number; - readonly probeTimeoutMs?: number; + readonly timeout?: Duration.Input; + readonly interval?: Duration.Input; + readonly probeTimeout?: Duration.Input; readonly path?: string; }): Effect.fn.Return { - const timeoutMs = input.timeoutMs ?? 30_000; - const intervalMs = input.intervalMs ?? 100; - const probeTimeoutMs = input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS; - const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe( - Schedule.take(Math.max(0, Math.ceil(timeoutMs / intervalMs))), + const timeout = Duration.fromInputUnsafe(input.timeout ?? Duration.seconds(30)); + const interval = Duration.fromInputUnsafe(input.interval ?? Duration.millis(100)); + const probeTimeout = Duration.fromInputUnsafe(input.probeTimeout ?? SSH_READY_PROBE_TIMEOUT); + const timeoutMs = Duration.toMillis(timeout); + const intervalMs = Duration.toMillis(interval); + const probeTimeoutMs = Duration.toMillis(probeTimeout); + const retryPolicy = Schedule.spaced(interval).pipe( + Schedule.take(Math.max(0, Math.ceil(timeoutMs / Math.max(intervalMs, 1)))), ); const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString(); const client = yield* HttpClient.HttpClient; @@ -900,7 +903,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio Effect.gen(function* () { attempt += 1; const responseOption = yield* effect.pipe( - Effect.timeoutOption(Duration.millis(probeTimeoutMs)), + Effect.timeoutOption(probeTimeout), Effect.mapError( (cause) => new SshReadinessError({ @@ -953,7 +956,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio cause, }), ), - Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.timeoutOption(timeout), ); return yield* Option.match(result, { @@ -1236,7 +1239,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: yield* Effect.raceFirst( waitForHttpReady({ baseUrl: input.httpBaseUrl, - timeoutMs: SSH_READY_TIMEOUT_MS, + timeout: SSH_READY_TIMEOUT, }), exitFailure, ).pipe( @@ -1296,7 +1299,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: : child .kill({ killSignal: "SIGTERM", - forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, + forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT, }) .pipe(Effect.ignore), ), @@ -1540,7 +1543,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma [ tunnelEntry.process.kill({ killSignal: "SIGTERM", - forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, + forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT, }), stopRemoteServer( tunnelEntry.target, @@ -1594,7 +1597,7 @@ 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, timeout: Duration.seconds(2) }), ); if (Exit.isSuccess(readinessExit)) { yield* Effect.logDebug("ssh.environment.tunnel.reused", {