diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 5319fe5596379..88559182143bf 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -471,6 +471,47 @@ describe("RelayClient", () => { ), ); + it.effect.skipIf(windowsHost)( + "installs when the downloaded binary's first launch is held by an antivirus scan", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const scanning = yield* Deferred.make(); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Deferred.succeed(scanning, undefined).pipe( + Effect.andThen(Effect.sleep("25 seconds")), + Effect.as( + makeHandle( + 0, + `cloudflared version ${RelayClient.CLOUDFLARED_VERSION} (built 2026-01-01-00:00 UTC)\n`, + ), + ), + ), + ), + ), + ); + + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(scanning); + yield* TestClock.adjust("25 seconds"); + + expect(yield* Fiber.join(installing)).toMatchObject({ + status: "available", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + }); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), + ); + it.effect("rejects downloads whose checksum does not match the pinned manifest", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index ae48676e6e171..e3cf71abbe1bf 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -141,6 +141,8 @@ const INSTALL_LOCK_RETRY_DELAY = "100 millis"; // Legacy locks have no owner; allow both ten-minute download stages and the install tail. const INSTALL_LOCK_LEGACY_STALE_MS = 30 * 60 * 1_000; const VERSION_PROBE_TIMEOUT = "10 seconds"; +// Antivirus scans can hold the first launch of a freshly downloaded binary for 20-30 s. +const INSTALL_VALIDATION_TIMEOUT = "2 minutes"; const ACTIVATE_RETRY_COUNT = 40; const ACTIVATE_RETRY_DELAY = "250 millis"; @@ -299,19 +301,23 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return platform === "win32" || (info.value.mode & 0o111) !== 0; }); + const runVersionProbe = Effect.fn("cloudflared.runVersionProbe")(function* ( + executablePath: string, + timeout: Duration.Input, + ) { + return yield* spawner + .string(ChildProcess.make(executablePath, ["version"], { stdin: "ignore", stderr: "ignore" })) + .pipe( + Effect.map(parseCloudflaredVersionOutput), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + }); + // Cache successful version probes per file revision; do not let transient failures hide valid binaries. const versionCache = yield* Cache.makeWith( - (key: VersionProbeKey) => - spawner - .string( - ChildProcess.make(key.executablePath, ["version"], { stdin: "ignore", stderr: "ignore" }), - ) - .pipe( - Effect.map(parseCloudflaredVersionOutput), - Effect.timeoutOption(VERSION_PROBE_TIMEOUT), - Effect.map(Option.getOrNull), - Effect.orElseSucceed(() => null), - ), + (key: VersionProbeKey) => runVersionProbe(key.executablePath, VERSION_PROBE_TIMEOUT), { capacity: 16, timeToLive: (exit) => @@ -648,7 +654,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } yield* report("validating"); // Verify the pin so mislabeled assets cannot cause repeated redownloads. - const installedVersion = yield* probeVersion(executablePath); + const installedVersion = yield* runVersionProbe(executablePath, INSTALL_VALIDATION_TIMEOUT); if (installedVersion !== CLOUDFLARED_VERSION) { return yield* new RelayClientInstallError({ reason: "validation_failed",