diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 23ae8eb2d33d47425520fbf5ac7d82df66fd5a9e..a96f3f8d864ba6e694609bd5134dfbd2282308b2 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -6,7 +6,6 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Hex from "effect/encoding/Hex"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; @@ -57,6 +56,16 @@ const layerHttpClient = (bytes: Uint8Array) => ), ); +// Records each request and never responds, simulating a wedged endpoint. +const layerStalledHttpClient = (requests: Array) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request); + return Effect.never; + }), + ); + // Answers `cloudflared version` with the version recorded for that path, which // defaults to the pinned release; tests change it to simulate other binaries. const layerSpawner = (commands: Array, versions: Record = {}) => @@ -767,4 +776,44 @@ describe("RelayClient", () => { expect(renames.attempts).toBe(1); }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime(platform))), ); + + it.effect("fails a stalled download after the download timeout", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: { + url: "https://example.test/cloudflared", + sha256: "00".repeat(32), + archive: "binary", + }, + }); + + const child = yield* Effect.forkChild(manager.install); + // Spin until the wedged download is in flight, so the clock + // adjustment below cannot run before the timeout is armed. + while (requests.length === 0) { + yield* Effect.yieldNow; + } + // The download timeout is 10 minutes; advance past it. + yield* TestClock.adjust("11 minutes"); + const error = yield* Fiber.join(child).pipe(Effect.flip); + expect(error).toBeInstanceOf(RelayClient.RelayClientInstallError); + expect(error.reason).toBe("download_failed"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerStalledHttpClient(requests), + layerSpawner([]), + layerHostRuntime({ PATH: "" }), + ), + ), + ); + }); }); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 994c735710da004fcd619e13a881ab849b1926d1..3705e7677cdda5a0623631fce6c0f242e704b632 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -30,6 +30,11 @@ export const CLOUDFLARED_VERSION = "2026.10.0"; export const CLOUDFLARED_MIN_VERSION = "2025.6.1"; const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; +// Generous bound for a ~40MB binary download; without it a stalled +// connection parks the install forever (same unbounded-wait family as the +// other relay/cloud network calls). +const CLOUDFLARED_DOWNLOAD_TIMEOUT = "10 minutes"; + export type RelayClientExecutableSource = "override" | "managed" | "path"; export type RelayClientStatus = @@ -441,6 +446,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ) { yield* report("downloading"); const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe( + Effect.timeout(CLOUDFLARED_DOWNLOAD_TIMEOUT), Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.mapError( (cause) => @@ -453,6 +459,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ); const bytes = new Uint8Array( yield* response.arrayBuffer.pipe( + Effect.timeout(CLOUDFLARED_DOWNLOAD_TIMEOUT), Effect.mapError( (cause) => new RelayClientInstallError({