authorgravatar for 7121943+kvnloo@users.noreply.github.comKevin Rajan <7121943+kvnloo@users.noreply.github.com> 2026-10-08 15:31:53-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 20:31:53+00:00
log580948708b66f2a971199670f76cb6b43119e9a4
treeb2e6fc44156b12f69ae8a90ef6a2032220c19623
parentaf3d842a001d030ed528396bf59cf02bbf373cd0
signature Signed by PGP key B5690EEEBB952194

fix(shared): bound cloudflared download with 10-minute timeout (#14139)

Co-authored-by: Kevin Rajan <kevin@kvnloo.dev>

2 files changed, 57 insertions(+), 1 deletions(-)

packages/shared/src/relayClient.test.ts+50-1
...@@ -6,7 +6,6 @@ import * as Deferred from "effect/Deferred";...@@ -6,7 +6,6 @@ import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";7import * as Fiber from "effect/Fiber";
8import * as Hex from "effect/encoding/Hex";8import * as Hex from "effect/encoding/Hex";
9import * as Fiber from "effect/Fiber";
10import * as FileSystem from "effect/FileSystem";9import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
12import * as PlatformError from "effect/PlatformError";11import * as PlatformError from "effect/PlatformError";
...@@ -57,6 +56,16 @@ const layerHttpClient = (bytes: Uint8Array) =>...@@ -57,6 +56,16 @@ const layerHttpClient = (bytes: Uint8Array) =>
57 ),56 ),
58 );57 );
5958
59// Records each request and never responds, simulating a wedged endpoint.
60const layerStalledHttpClient = (requests: Array<unknown>) =>
61 Layer.succeed(
62 HttpClient.HttpClient,
63 HttpClient.make((request) => {
64 requests.push(request);
65 return Effect.never;
66 }),
67 );
68
60// Answers `cloudflared version` with the version recorded for that path, which69// Answers `cloudflared version` with the version recorded for that path, which
61// defaults to the pinned release; tests change it to simulate other binaries.70// defaults to the pinned release; tests change it to simulate other binaries.
62const layerSpawner = (commands: Array<string>, versions: Record<string, string> = {}) =>71const layerSpawner = (commands: Array<string>, versions: Record<string, string> = {}) =>
...@@ -767,4 +776,44 @@ describe("RelayClient", () => {...@@ -767,4 +776,44 @@ describe("RelayClient", () => {
767 expect(renames.attempts).toBe(1);776 expect(renames.attempts).toBe(1);
768 }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime(platform))),777 }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime(platform))),
769 );778 );
779
780 it.effect("fails a stalled download after the download timeout", () => {
781 const requests: Array<unknown> = [];
782 return Effect.gen(function* () {
783 const fileSystem = yield* FileSystem.FileSystem;
784 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
785 prefix: "t3-cloudflared-test-",
786 });
787 const manager = yield* RelayClient.makeCloudflaredRelayClient({
788 baseDir,
789 releaseAsset: {
790 url: "https://example.test/cloudflared",
791 sha256: "00".repeat(32),
792 archive: "binary",
793 },
794 });
795
796 const child = yield* Effect.forkChild(manager.install);
797 // Spin until the wedged download is in flight, so the clock
798 // adjustment below cannot run before the timeout is armed.
799 while (requests.length === 0) {
800 yield* Effect.yieldNow;
801 }
802 // The download timeout is 10 minutes; advance past it.
803 yield* TestClock.adjust("11 minutes");
804 const error = yield* Fiber.join(child).pipe(Effect.flip);
805 expect(error).toBeInstanceOf(RelayClient.RelayClientInstallError);
806 expect(error.reason).toBe("download_failed");
807 }).pipe(
808 Effect.scoped,
809 Effect.provide(
810 Layer.mergeAll(
811 NodeServices.layer,
812 layerStalledHttpClient(requests),
813 layerSpawner([]),
814 layerHostRuntime({ PATH: "" }),
815 ),
816 ),
817 );
818 });
770});819});
packages/shared/src/relayClient.ts+7
...@@ -30,6 +30,11 @@ export const CLOUDFLARED_VERSION = "2026.10.0";...@@ -30,6 +30,11 @@ export const CLOUDFLARED_VERSION = "2026.10.0";
30export const CLOUDFLARED_MIN_VERSION = "2025.6.1";30export const CLOUDFLARED_MIN_VERSION = "2025.6.1";
31const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";31const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";
3232
33// Generous bound for a ~40MB binary download; without it a stalled
34// connection parks the install forever (same unbounded-wait family as the
35// other relay/cloud network calls).
36const CLOUDFLARED_DOWNLOAD_TIMEOUT = "10 minutes";
37
33export type RelayClientExecutableSource = "override" | "managed" | "path";38export type RelayClientExecutableSource = "override" | "managed" | "path";
3439
35export type RelayClientStatus =40export type RelayClientStatus =
...@@ -441,6 +446,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -441,6 +446,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
441 ) {446 ) {
442 yield* report("downloading");447 yield* report("downloading");
443 const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe(448 const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe(
449 Effect.timeout(CLOUDFLARED_DOWNLOAD_TIMEOUT),
444 Effect.flatMap(HttpClientResponse.filterStatusOk),450 Effect.flatMap(HttpClientResponse.filterStatusOk),
445 Effect.mapError(451 Effect.mapError(
446 (cause) =>452 (cause) =>
...@@ -453,6 +459,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -453,6 +459,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
453 );459 );
454 const bytes = new Uint8Array(460 const bytes = new Uint8Array(
455 yield* response.arrayBuffer.pipe(461 yield* response.arrayBuffer.pipe(
462 Effect.timeout(CLOUDFLARED_DOWNLOAD_TIMEOUT),
456 Effect.mapError(463 Effect.mapError(
457 (cause) =>464 (cause) =>
458 new RelayClientInstallError({465 new RelayClientInstallError({