diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 2fbf1f4357b..ca95b255a7d 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -17,7 +17,7 @@ import { SshLaunchError, SshPairingError, SshPasswordPromptError, - SshReadinessError, + type SshReadinessError, } from "@t3tools/ssh/errors"; import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import * as Context from "effect/Context"; @@ -25,6 +25,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -79,11 +80,13 @@ function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { return discoverSshHosts(input ?? {}); } +const isSshPasswordPromptError = Schema.is(SshPasswordPromptError); + export function isDesktopSshPasswordPromptCancellation( error: unknown, ): error is SshPasswordPromptError { return ( - error instanceof SshPasswordPromptError && + isSshPasswordPromptError(error) && DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) ); } diff --git a/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts index 8b6798d38cb..08e536f320a 100644 --- a/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts +++ b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import { SshHttpBridgeError } from "@t3tools/ssh/errors"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts"; @@ -31,6 +32,8 @@ function makeLayer( ); } +const isSshHttpBridgeError = Schema.is(SshHttpBridgeError); + describe("DesktopSshRemoteApi", () => { it.effect("fetches and decodes the remote environment descriptor", () => { const requestUrls: string[] = []; @@ -73,7 +76,7 @@ describe("DesktopSshRemoteApi", () => { assert.instanceOf(error, DesktopSshRemoteApi.DesktopSshRemoteApiError); assert.equal(error.operation, "fetch-environment-descriptor"); - assert.equal(error.cause instanceof SshHttpBridgeError, false); + assert.equal(isSshHttpBridgeError(error.cause), false); }).pipe(Effect.provide(layer)); }); }); diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index 357aaef091d..8dfa7d229e8 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -1,46 +1,103 @@ -import * as Data from "effect/Data"; - -export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{ - readonly message: string; - readonly cause: unknown; -}> {} - -export class SshInvalidTargetError extends Data.TaggedError("SshInvalidTargetError")<{ - readonly message: string; -}> {} - -export class SshCommandError extends Data.TaggedError("SshCommandError")<{ - readonly message: string; - readonly command: readonly string[]; - readonly exitCode: number | null; - readonly stderr: string; - readonly cause?: unknown; -}> {} - -export class SshLaunchError extends Data.TaggedError("SshLaunchError")<{ - readonly message: string; - readonly stdout: string; - readonly cause?: unknown; -}> {} - -export class SshPairingError extends Data.TaggedError("SshPairingError")<{ - readonly message: string; - readonly stdout: string; - readonly cause?: unknown; -}> {} - -export class SshHttpBridgeError extends Data.TaggedError("SshHttpBridgeError")<{ - readonly message: string; - readonly status?: number; - readonly cause?: unknown; -}> {} - -export class SshReadinessError extends Data.TaggedError("SshReadinessError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export class SshPasswordPromptError extends Data.TaggedError("SshPasswordPromptError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +import * as Schema from "effect/Schema"; + +export class SshHostDiscoveryError extends Schema.TaggedErrorClass()( + "SshHostDiscoveryError", + { + message: Schema.String, + cause: Schema.Defect, + }, +) {} + +export class SshInvalidTargetError extends Schema.TaggedErrorClass()( + "SshInvalidTargetError", + { + message: Schema.String, + }, +) {} + +export class SshCommandError extends Schema.TaggedErrorClass()("SshCommandError", { + message: Schema.String, + command: Schema.Array(Schema.String), + exitCode: Schema.NullOr(Schema.Number), + stderr: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class SshLaunchError extends Schema.TaggedErrorClass()("SshLaunchError", { + message: Schema.String, + stdout: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class SshPairingError extends Schema.TaggedErrorClass()("SshPairingError", { + message: Schema.String, + stdout: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export class SshHttpBridgeError extends Schema.TaggedErrorClass()( + "SshHttpBridgeError", + { + message: Schema.String, + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class SshReadinessProbeFailedError extends Schema.TaggedErrorClass()( + "SshReadinessProbeFailedError", + { + message: Schema.String, + requestUrl: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export class SshReadinessProbeTimedOutError extends Schema.TaggedErrorClass()( + "SshReadinessProbeTimedOutError", + { + message: Schema.String, + requestUrl: Schema.String, + attempt: Schema.Number, + probeTimeoutMs: Schema.Number, + }, +) {} + +export class SshReadinessTimedOutError extends Schema.TaggedErrorClass()( + "SshReadinessTimedOutError", + { + message: Schema.String, + baseUrl: Schema.String, + requestUrl: Schema.String, + timeoutMs: Schema.Number, + intervalMs: Schema.Number, + probeTimeoutMs: Schema.Number, + attempts: Schema.Number, + cause: Schema.optional(Schema.Defect), + }, +) {} + +export type SshReadinessError = + | SshReadinessProbeFailedError + | SshReadinessProbeTimedOutError + | SshReadinessTimedOutError; + +const isSshReadinessProbeFailedError = Schema.is(SshReadinessProbeFailedError); +const isSshReadinessProbeTimedOutError = Schema.is(SshReadinessProbeTimedOutError); +const isSshReadinessTimedOutError = Schema.is(SshReadinessTimedOutError); + +export function isSshReadinessError(cause: unknown): cause is SshReadinessError { + return ( + isSshReadinessProbeFailedError(cause) || + isSshReadinessProbeTimedOutError(cause) || + isSshReadinessTimedOutError(cause) + ); +} + +export class SshPasswordPromptError extends Schema.TaggedErrorClass()( + "SshPasswordPromptError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 80e684d8611..a2e7ce86963 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 5ee5c684779..ff8f0f74319 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -45,16 +45,20 @@ import { SshLaunchError, SshPairingError, SshPasswordPromptError, - SshReadinessError, + SshReadinessProbeFailedError, + SshReadinessProbeTimedOutError, + SshReadinessTimedOutError, + isSshReadinessError, + type SshReadinessError, } from "./errors.ts"; 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; @@ -237,12 +241,37 @@ function applyScriptPlaceholders( } export function describeReadinessCause(cause: unknown): unknown { - if (cause instanceof SshReadinessError) { - return { + if (isSshReadinessError(cause)) { + const base = { _tag: cause._tag, message: cause.message, - ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), }; + switch (cause._tag) { + case "SshReadinessProbeFailedError": + return { + ...base, + requestUrl: cause.requestUrl, + ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), + }; + case "SshReadinessProbeTimedOutError": + return { + ...base, + requestUrl: cause.requestUrl, + attempt: cause.attempt, + probeTimeoutMs: cause.probeTimeoutMs, + }; + case "SshReadinessTimedOutError": + return { + ...base, + baseUrl: cause.baseUrl, + requestUrl: cause.requestUrl, + timeoutMs: cause.timeoutMs, + intervalMs: cause.intervalMs, + probeTimeoutMs: cause.probeTimeoutMs, + attempts: cause.attempts, + ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), + }; + } } if (cause instanceof Error) { return { @@ -686,9 +715,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)), }); } @@ -870,20 +899,21 @@ 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); const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString(); const client = yield* HttpClient.HttpClient; - const lastProbeFailure = yield* Ref.make(null); + const lastProbeFailure = yield* Ref.make>(Option.none()); let attempt = 0; yield* Effect.logDebug("ssh.tunnel.httpReady.start", { @@ -900,11 +930,12 @@ 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({ + new SshReadinessProbeFailedError({ message: `Backend readiness probe failed at ${requestUrl}.`, + requestUrl, cause, }), ), @@ -913,30 +944,32 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio onSome: Effect.succeed, onNone: () => Effect.fail( - new SshReadinessError({ + new SshReadinessProbeTimedOutError({ message: `Backend readiness probe exceeded ${probeTimeoutMs}ms at ${requestUrl}.`, - cause: { - kind: "probe-timeout", - attempt, - probeTimeoutMs, - }, + requestUrl, + attempt, + probeTimeoutMs, }), ), }); }).pipe( Effect.mapError((cause) => - cause instanceof SshReadinessError + isSshReadinessError(cause) ? cause - : new SshReadinessError({ + : new SshReadinessProbeFailedError({ message: `Backend readiness probe failed at ${requestUrl}.`, + requestUrl, cause, }), ), Effect.tapError((cause) => - Ref.set(lastProbeFailure, { - attempt, - cause: describeReadinessCause(cause), - }), + Ref.set( + lastProbeFailure, + Option.some({ + attempt, + cause: describeReadinessCause(cause), + }), + ), ), ), ), @@ -946,14 +979,15 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe( Effect.mapError((cause) => - cause instanceof SshReadinessError + isSshReadinessError(cause) ? cause - : new SshReadinessError({ + : new SshReadinessProbeFailedError({ message: `Backend readiness probe failed at ${requestUrl}.`, + requestUrl, cause, }), ), - Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.timeoutOption(timeout), ); return yield* Option.match(result, { @@ -973,11 +1007,17 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio intervalMs, probeTimeoutMs, attempts: attempt, - lastFailure, + lastFailure: Option.getOrUndefined(lastFailure), }); - return yield* new SshReadinessError({ + return yield* new SshReadinessTimedOutError({ message: `Timed out waiting ${timeoutMs}ms for backend readiness at ${input.baseUrl}.`, - cause: lastFailure, + baseUrl: input.baseUrl, + requestUrl, + timeoutMs, + intervalMs, + probeTimeoutMs, + attempts: attempt, + ...(Option.isSome(lastFailure) ? { cause: lastFailure.value } : {}), }); }), }); @@ -1236,7 +1276,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 +1336,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: : child .kill({ killSignal: "SIGTERM", - forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, + forceKillAfter: Duration.toMillis(TUNNEL_SHUTDOWN_TIMEOUT), }) .pipe(Effect.ignore), ), @@ -1540,7 +1580,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma [ tunnelEntry.process.kill({ killSignal: "SIGTERM", - forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, + forceKillAfter: Duration.toMillis(TUNNEL_SHUTDOWN_TIMEOUT), }), stopRemoteServer( tunnelEntry.target, @@ -1594,7 +1634,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", {