diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 85482f876c53398b94dadb20ff25c25abbe5df4a..291cd347039b287f8f40e8ffff122a575fa93672 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -2,12 +2,16 @@ import { sha256 } from "@noble/hashes/sha2"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; 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"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; @@ -18,9 +22,9 @@ import * as RelayClient from "./relayClient.ts"; // POSIX exec bits that NTFS never reports; the win32 branch skips that check. const windowsHost = HostProcessPlatform.defaultValue() === "win32"; -const layerHostRuntime = (env: Record = {}) => +const layerHostRuntime = (env: Record = {}, platform: NodeJS.Platform = "linux") => Layer.mergeAll( - Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcessPlatform, platform), Layer.succeed(HostProcessArchitecture, "x64"), ConfigProvider.layer(ConfigProvider.fromEnv({ env })), ); @@ -91,6 +95,64 @@ type RelayClientTestServices = const managedPathFor = (baseDir: string, version: string) => `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`; +const renameError = (code: string, path: string) => + PlatformError.systemError({ + _tag: code === "EBUSY" ? "Busy" : code === "EACCES" ? "PermissionDenied" : "Unknown", + module: "FileSystem", + method: "rename", + pathOrDescriptor: path, + cause: Object.assign(new Error(code), { code }), + }); + +type InstallRename = "staging" | "activation"; + +/** + * Fails the first `failures` staging or activation renames with `code`, as Windows does while + * another process holds the file. Staging moves the binary to a `.tmp` name beside its final path. + */ +const makeLockedRenames = ( + fileSystem: FileSystem.FileSystem, + input: { readonly rename: InstallRename; readonly failures: number; readonly code: string }, +) => + Effect.gen(function* () { + const firstAttempt = yield* Deferred.make(); + const renames = { attempts: 0 }; + const locked = FileSystem.make({ + ...fileSystem, + rename: (from, to) => + Effect.suspend(() => { + if ((to.endsWith(".tmp") ? "staging" : "activation") !== input.rename) { + return fileSystem.rename(from, to); + } + renames.attempts += 1; + return renames.attempts <= input.failures + ? Deferred.succeed(firstAttempt, undefined).pipe( + Effect.andThen(Effect.fail(renameError(input.code, to))), + ) + : fileSystem.rename(from, to); + }), + }); + return { locked, renames, firstAttempt }; + }); + +const testBinary = new TextEncoder().encode("test-cloudflared-binary"); +const testReleaseAsset = { + url: "https://example.test/cloudflared", + sha256: Hex.encode(sha256(testBinary)), + archive: "binary", +} as const; + +const layerInstallRuntime = (platform: NodeJS.Platform) => + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(testBinary), + layerSpawner([]), + layerHostRuntime({ PATH: "" }, platform), + ); + +const managedDirectory = (baseDir: string, platform: NodeJS.Platform) => + `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/${platform}-x64`; + describe("RelayClient", () => { it.effect.skipIf(windowsHost)( "resolves explicit overrides before managed and PATH executables", @@ -481,4 +543,95 @@ describe("RelayClient", () => { expect(RelayClient.compareCloudflaredVersions("2025.10.0", "2025.6.1")).toBeGreaterThan(0); expect(RelayClient.compareCloudflaredVersions("2023.8.2", "2025.6.1")).toBeLessThan(0); }); + + it.effect.each([ + ["staging", "EBUSY"], + ["activation", "EACCES"], + ] as const)("retries %s on Windows while another process holds the binary", ([rename, code]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const { locked, renames, firstAttempt } = yield* makeLockedRenames(fileSystem, { + rename, + failures: 2, + code, + }); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }).pipe(Effect.provideService(FileSystem.FileSystem, locked)); + + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(firstAttempt); + yield* TestClock.adjust("1 second"); + const installed = yield* Fiber.join(installing); + + expect(renames.attempts).toBe(3); + expect(new TextDecoder().decode(yield* fileSystem.readFile(installed.executablePath))).toBe( + "test-cloudflared-binary", + ); + expect(yield* fileSystem.readDirectory(managedDirectory(baseDir, "win32"))).toEqual([ + "cloudflared.exe", + ]); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("win32"))), + ); + + it.effect.each([ + ["staging", "EPERM", "Could not stage the relay client."], + ["activation", "EBUSY", "Could not activate the relay client."], + ] as const)("gives up on a Windows %s lock that does not clear", ([rename, code, message]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const { locked, renames, firstAttempt } = yield* makeLockedRenames(fileSystem, { + rename, + failures: Infinity, + code, + }); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }).pipe(Effect.provideService(FileSystem.FileSystem, locked)); + + const installing = yield* manager.install.pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(firstAttempt); + yield* TestClock.adjust("1 minute"); + const error = yield* Fiber.join(installing); + + expect(error.message).toBe(message); + expect(renames.attempts).toBe(41); + // Nothing is installed, and the staged copy, download folder, and install lock are gone. + expect(yield* fileSystem.readDirectory(managedDirectory(baseDir, "win32"))).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("win32"))), + ); + + it.effect.each([ + ["linux", "EBUSY"], + ["win32", "EXDEV"], + ] as const)("fails staging at once on %s for %s", ([platform, code]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const { locked, renames } = yield* makeLockedRenames(fileSystem, { + rename: "staging", + failures: Infinity, + code, + }); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }).pipe(Effect.provideService(FileSystem.FileSystem, locked)); + + const error = yield* manager.install.pipe(Effect.flip); + + expect(error.message).toBe("Could not stage the relay client."); + expect(renames.attempts).toBe(1); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime(platform))), + ); }); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 67dee30bdcd576a218a076973d7c3016e3c15e0d..e75133256ef569a4600069bf5f534728b07159fa 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -17,6 +17,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schedule from "effect/Schedule"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -116,6 +117,9 @@ const INSTALL_LOCK_RETRY_DELAY = "100 millis"; const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; const VERSION_PROBE_TIMEOUT = "10 seconds"; +const ACTIVATE_RETRY_COUNT = 40; +const ACTIVATE_RETRY_DELAY = "250 millis"; + const trimmedString = (name: string) => Config.String(name).pipe( Config.option, @@ -205,6 +209,13 @@ function isAlreadyExists(error: PlatformError.PlatformError): boolean { return error.reason._tag === "AlreadyExists"; } +// Windows refuses to rename a file another process briefly holds open, such as a virus +// scanner reading the binary that just ran. These codes clear once it lets go. +function isTransientWindowsLock(error: PlatformError.PlatformError): boolean { + const code = (error.reason.cause as NodeJS.ErrnoException | undefined)?.code; + return code === "EBUSY" || code === "EPERM" || code === "EACCES"; +} + const wrapInstallFailure = ( reason: RelayClientInstallError["reason"], @@ -397,6 +408,16 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions")); + /** Moves the new binary toward its final path, waiting out a brief Windows lock. */ + const renameWhenUnlocked = (from: string, to: string) => + fileSystem.rename(from, to).pipe( + Effect.retry({ + times: ACTIVATE_RETRY_COUNT, + schedule: Schedule.spaced(ACTIVATE_RETRY_DELAY), + while: (error) => platform === "win32" && isTransientWindowsLock(error), + }), + ); + const runCommand = Effect.fn("cloudflared.runCommand")(function* ( command: string, args: ReadonlyArray, @@ -574,15 +595,13 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; yield* report("activating"); - yield* fileSystem - .rename(executablePath, stagedPath) - .pipe(wrapInstallFailure("write_failed", "Could not stage the relay client.")); - yield* fileSystem - .rename(stagedPath, managedPath) - .pipe( - wrapInstallFailure("write_failed", "Could not activate the relay client."), - Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), - ); + yield* renameWhenUnlocked(executablePath, stagedPath).pipe( + wrapInstallFailure("write_failed", "Could not stage the relay client."), + ); + yield* renameWhenUnlocked(stagedPath, managedPath).pipe( + wrapInstallFailure("write_failed", "Could not activate the relay client."), + Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), + ); return { status: "available", executablePath: managedPath,