From d49e8ee0f13114789f7c7a43480f1d0b51626259 Mon Sep 17 00:00:00 2001 From: tanav29 Date: Mon, 9 Nov 2026 13:46:42 +0530 Subject: [PATCH] fix(server): failed updates report download diagnostics --- apps/server/src/cloud/pinnedRuntime.ts | 9 +++++ apps/server/src/cloud/selfUpdate.test.ts | 42 ++++++++++++++++++++++-- apps/server/src/cloud/selfUpdate.ts | 31 +++++++++++------ 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 0df57f10f3bd..90069f46f917 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -71,6 +71,8 @@ export class PinnedRuntimeInstallError extends Schema.TaggedError new PinnedRuntimeInstallError({ step: cause._tag === "TimeoutError" ? `${step} (timed out)` : step, + httpStatus: + HttpClientError.isHttpClientError(cause) && cause.reason._tag === "StatusCodeError" + ? cause.reason.response.status + : undefined, + downloadHost: URL.parse( + HttpClientError.isHttpClientError(cause) ? cause.request.url : url, + )?.host, cause, }), ), diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index a938d6778820..6be4288ce35e 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import packageJson from "../../package.json" with { type: "json" }; @@ -30,6 +30,7 @@ interface HarnessOptions { readonly preflightDuration?: Duration.Input; readonly preflightStarted?: Deferred.Deferred; readonly beforeDownload?: Effect.Effect; + readonly httpClient?: HttpClient.HttpClient; readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; readonly policy?: ManagedPolicyDocument; @@ -145,7 +146,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( ), Effect.provideService( HttpClient.HttpClient, - releaseHttpClient(order, urls, options.beforeDownload), + options.httpClient ?? releaseHttpClient(order, urls, options.beforeDownload), ), Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HostProcess.Architecture, "x64"), @@ -156,10 +157,45 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( ), ), ); - return { selfUpdate, order, urls }; + return { selfUpdate, order, urls, baseDir }; }); it.layer(NodeServices.layer)("server self update", (it) => { + it.effect.each([ + { asset: "checksums", status: 504 }, + { asset: "archive", status: 503 }, + { asset: "checksums", status: 403 }, + ])("reports an HTTP $status downloading $asset and allows retry", ({ asset, status }) => + Effect.gen(function* () { + let failDownload = true; + const { selfUpdate, order, baseDir } = yield* makeHarness({ + httpClient: releaseHttpClient([], []).pipe( + HttpClient.transform((response, request) => + failDownload && request.url.endsWith("/SHA256SUMS") === (asset === "checksums") + ? Effect.succeed( + HttpClientResponse.fromWeb( + HttpClientRequest.get("https://user:password@cdn.example/private?token=secret"), + new Response("private response body", { status }), + ), + ) + : response, + ), + ), + }); + const error = yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip); + expect(error.reason).toBe( + `Could not prepare t3@1.1.0 while downloading the t3 release ${asset} (HTTP ${status} from cdn.example).`, + ); + expect(order).toEqual([]); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions"))).toEqual([]); + failDownload = false; + expect((yield* selfUpdate.update({ targetVersion: "1.1.0" })).updateId).toBe("launcher-id"); + expect(order).toEqual(["extract", "preflight", "accept"]); + }), + ); + // regression: #5181 make remote updates rollback-safe it.effect("rejects invalid versions and desktop-managed servers before staging", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 58baa160adf7..5e299964138d 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -282,16 +282,27 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { ), ), }).pipe( - Effect.mapError((error) => - error._tag === "PinnedRuntimePreflightBlockedError" - ? failWith(error.reason, error) - : mirror !== undefined && isUnpublishedRelease(error) - ? failWith( - `${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`, - error, - ) - : failWith(`Could not prepare t3@${targetVersion}.`, error), - ), + Effect.mapError((error) => { + if (error._tag === "PinnedRuntimePreflightBlockedError") { + return failWith(error.reason, error); + } + if (mirror !== undefined && isUnpublishedRelease(error)) { + return failWith( + `${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`, + error, + ); + } + const downloadFailure = [ + error.httpStatus === undefined ? undefined : `HTTP ${error.httpStatus}`, + error.downloadHost === undefined ? undefined : `from ${error.downloadHost}`, + ] + .filter((part) => part !== undefined) + .join(" "); + return failWith( + `Could not prepare t3@${targetVersion} while ${error.step}${downloadFailure ? ` (${downloadFailure})` : ""}.`, + error, + ); + }), ); yield* reportProgress("installing");