diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index a938d6778820f..3670204c9e70c 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -30,17 +30,26 @@ interface HarnessOptions { readonly preflightDuration?: Duration.Input; readonly preflightStarted?: Deferred.Deferred; readonly beforeDownload?: Effect.Effect; + readonly checksumsStatus?: number; readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; readonly policy?: ManagedPolicyDocument; } const archiveBytes = new TextEncoder().encode("not really a tarball"); -const releaseHttpClient = (order: string[], urls: string[], beforeDownload?: Effect.Effect) => +const releaseHttpClient = ( + order: string[], + urls: string[], + beforeDownload?: Effect.Effect, + checksumsStatus?: number, +) => HttpClient.make((request) => Effect.gen(function* () { urls.push(request.url); if (request.url.endsWith("/SHA256SUMS")) { + if (checksumsStatus !== undefined) { + return HttpClientResponse.fromWeb(request, new Response("", { status: checksumsStatus })); + } const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", archiveBytes)); const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0"), @@ -145,7 +154,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( ), Effect.provideService( HttpClient.HttpClient, - releaseHttpClient(order, urls, options.beforeDownload), + releaseHttpClient(order, urls, options.beforeDownload, options.checksumsStatus), ), Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HostProcess.Architecture, "x64"), @@ -257,6 +266,17 @@ it.layer(NodeServices.layer)("server self update", (it) => { }), ); + it.effect("names the failed step, HTTP status, and host when a release download fails", () => + Effect.gen(function* () { + const { selfUpdate, order } = yield* makeHarness({ checksumsStatus: 504 }); + const { reason } = yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip); + expect(reason).toContain("checksums"); + expect(reason).toContain("504"); + expect(reason).toContain("github.com"); + expect(order).toEqual([]); + }), + ); + it.effect("allows only one update at a time", () => Effect.gen(function* () { const requested = yield* Deferred.make(); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 58baa160adf71..95e2bd1dd423e 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -7,6 +7,7 @@ import { type ServerSelfUpdateResult, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -18,7 +19,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientError } from "effect/http"; import { CLI_RELEASE_BASE_URL_ENV, @@ -122,6 +123,29 @@ export function trackSwitchBlockReason(input: { : null; } +/** Names the failed install step and, for a download, the host and its answer, never the URL. */ +const prepareFailureReason = (targetVersion: string, error: PinnedRuntimeInstallError) => { + const failed = `Could not prepare t3@${targetVersion} while ${error.step}`; + const cause = error.cause; + if (!HttpClientError.isHttpClientError(cause)) { + return Cause.isTimeoutError(cause) ? `${failed}. Try again.` : `${failed}.`; + } + const host = URL.parse(cause.request.url)?.host ?? "the release host"; + switch (cause.reason._tag) { + case "StatusCodeError": { + const status = cause.reason.response.status; + const retryable = status >= 500 || status === 408 || status === 429; + return `${failed}: ${host} answered HTTP ${status}.${retryable ? " Try again." : ""}`; + } + // A refused connection, or one dropped while the body streams in. + case "TransportError": + case "DecodeError": + return `${failed}: the connection to ${host} failed. Try again.`; + default: + return `${failed} from ${host}.`; + } +}; + export const make = Effect.fn("cloud.server_self_update.make")(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate; @@ -290,7 +314,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { `${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`, error, ) - : failWith(`Could not prepare t3@${targetVersion}.`, error), + : failWith(prepareFailureReason(targetVersion, error), error), ), );