From f6f8ae227b4f57016f82685bb4d9f16d34b5e9ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 08:50:39 +0000 Subject: [PATCH 1/3] test(server): reproduce #33233 failed release download reason drops step, status, and host A self-update whose SHA256SUMS download answers 504 should report the failed step, the HTTP status, and the release host instead of only "Could not prepare t3@." --- apps/server/src/cloud/selfUpdate.test.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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(); From d727d0ba8f3e62b02f9a161f6fcbf5d89f3cf7bf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 08:56:00 +0000 Subject: [PATCH 2/3] fix(server): failed self-update names the step, HTTP status, and host A boot-service update that failed while preparing the target runtime reported only "Could not prepare t3@.", dropping the install step and, for a download, the status and release host. The reason now names the step, the host (never the URL), and the HTTP status, and says to try again for timeouts, refused or dropped connections, and 5xx/408/429 answers. Retrying is safe: staging is removed on failure and an incomplete version dir on the next attempt. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cloud/selfUpdate.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 58baa160adf71..e91183463a28c 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -18,7 +18,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 +122,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 error.step.endsWith("(timed out)") ? `${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 +313,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), ), ); From bac3a227cd5d0d1c0065cf4dd411d08d1e0a987e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 09:17:33 +0000 Subject: [PATCH 3/3] refactor(server): detect a release download timeout from its cause AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cloud/selfUpdate.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index e91183463a28c..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"; @@ -127,7 +128,7 @@ const prepareFailureReason = (targetVersion: string, error: PinnedRuntimeInstall const failed = `Could not prepare t3@${targetVersion} while ${error.step}`; const cause = error.cause; if (!HttpClientError.isHttpClientError(cause)) { - return error.step.endsWith("(timed out)") ? `${failed}. Try again.` : `${failed}.`; + return Cause.isTimeoutError(cause) ? `${failed}. Try again.` : `${failed}.`; } const host = URL.parse(cause.request.url)?.host ?? "the release host"; switch (cause.reason._tag) {