| ... | ... | @@ -2,12 +2,16 @@ import { sha256 } from "@noble/hashes/sha2"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | 4 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 5 | import * as Deferred from "effect/Deferred"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | import * as Hex from "effect/encoding/Hex"; |
| 8 | import * as Fiber from "effect/Fiber"; |
| 7 | 9 | import * as FileSystem from "effect/FileSystem"; |
| 8 | 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as PlatformError from "effect/PlatformError"; |
| 9 | 12 | import * as Sink from "effect/Sink"; |
| 10 | 13 | import * as Stream from "effect/Stream"; |
| 14 | import * as TestClock from "effect/testing/TestClock"; |
| 11 | 15 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 12 | 16 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 13 | 17 | import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; |
| ... | ... | @@ -18,9 +22,9 @@ import * as RelayClient from "./relayClient.ts"; |
| 18 | 22 | // POSIX exec bits that NTFS never reports; the win32 branch skips that check. |
| 19 | 23 | const windowsHost = HostProcessPlatform.defaultValue() === "win32"; |
| 20 | 24 | |
| 21 | | const layerHostRuntime = (env: Record<string, string> = {}) => |
| 25 | const layerHostRuntime = (env: Record<string, string> = {}, platform: NodeJS.Platform = "linux") => |
| 22 | 26 | Layer.mergeAll( |
| 23 | | Layer.succeed(HostProcessPlatform, "linux"), |
| 27 | Layer.succeed(HostProcessPlatform, platform), |
| 24 | 28 | Layer.succeed(HostProcessArchitecture, "x64"), |
| 25 | 29 | ConfigProvider.layer(ConfigProvider.fromEnv({ env })), |
| 26 | 30 | ); |
| ... | ... | @@ -91,6 +95,64 @@ type RelayClientTestServices = |
| 91 | 95 | const managedPathFor = (baseDir: string, version: string) => |
| 92 | 96 | `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`; |
| 93 | 97 | |
| 98 | const renameError = (code: string, path: string) => |
| 99 | PlatformError.systemError({ |
| 100 | _tag: code === "EBUSY" ? "Busy" : code === "EACCES" ? "PermissionDenied" : "Unknown", |
| 101 | module: "FileSystem", |
| 102 | method: "rename", |
| 103 | pathOrDescriptor: path, |
| 104 | cause: Object.assign(new Error(code), { code }), |
| 105 | }); |
| 106 | |
| 107 | type InstallRename = "staging" | "activation"; |
| 108 | |
| 109 | /** |
| 110 | * Fails the first `failures` staging or activation renames with `code`, as Windows does while |
| 111 | * another process holds the file. Staging moves the binary to a `.tmp` name beside its final path. |
| 112 | */ |
| 113 | const makeLockedRenames = ( |
| 114 | fileSystem: FileSystem.FileSystem, |
| 115 | input: { readonly rename: InstallRename; readonly failures: number; readonly code: string }, |
| 116 | ) => |
| 117 | Effect.gen(function* () { |
| 118 | const firstAttempt = yield* Deferred.make<void>(); |
| 119 | const renames = { attempts: 0 }; |
| 120 | const locked = FileSystem.make({ |
| 121 | ...fileSystem, |
| 122 | rename: (from, to) => |
| 123 | Effect.suspend(() => { |
| 124 | if ((to.endsWith(".tmp") ? "staging" : "activation") !== input.rename) { |
| 125 | return fileSystem.rename(from, to); |
| 126 | } |
| 127 | renames.attempts += 1; |
| 128 | return renames.attempts <= input.failures |
| 129 | ? Deferred.succeed(firstAttempt, undefined).pipe( |
| 130 | Effect.andThen(Effect.fail(renameError(input.code, to))), |
| 131 | ) |
| 132 | : fileSystem.rename(from, to); |
| 133 | }), |
| 134 | }); |
| 135 | return { locked, renames, firstAttempt }; |
| 136 | }); |
| 137 | |
| 138 | const testBinary = new TextEncoder().encode("test-cloudflared-binary"); |
| 139 | const testReleaseAsset = { |
| 140 | url: "https://example.test/cloudflared", |
| 141 | sha256: Hex.encode(sha256(testBinary)), |
| 142 | archive: "binary", |
| 143 | } as const; |
| 144 | |
| 145 | const layerInstallRuntime = (platform: NodeJS.Platform) => |
| 146 | Layer.mergeAll( |
| 147 | NodeServices.layer, |
| 148 | layerHttpClient(testBinary), |
| 149 | layerSpawner([]), |
| 150 | layerHostRuntime({ PATH: "" }, platform), |
| 151 | ); |
| 152 | |
| 153 | const managedDirectory = (baseDir: string, platform: NodeJS.Platform) => |
| 154 | `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/${platform}-x64`; |
| 155 | |
| 94 | 156 | describe("RelayClient", () => { |
| 95 | 157 | it.effect.skipIf(windowsHost)( |
| 96 | 158 | "resolves explicit overrides before managed and PATH executables", |
| ... | ... | @@ -481,4 +543,95 @@ describe("RelayClient", () => { |
| 481 | 543 | expect(RelayClient.compareCloudflaredVersions("2025.10.0", "2025.6.1")).toBeGreaterThan(0); |
| 482 | 544 | expect(RelayClient.compareCloudflaredVersions("2023.8.2", "2025.6.1")).toBeLessThan(0); |
| 483 | 545 | }); |
| 546 | |
| 547 | it.effect.each([ |
| 548 | ["staging", "EBUSY"], |
| 549 | ["activation", "EACCES"], |
| 550 | ] as const)("retries %s on Windows while another process holds the binary", ([rename, code]) => |
| 551 | Effect.gen(function* () { |
| 552 | const fileSystem = yield* FileSystem.FileSystem; |
| 553 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 554 | prefix: "t3-cloudflared-test-", |
| 555 | }); |
| 556 | const { locked, renames, firstAttempt } = yield* makeLockedRenames(fileSystem, { |
| 557 | rename, |
| 558 | failures: 2, |
| 559 | code, |
| 560 | }); |
| 561 | const manager = yield* RelayClient.makeCloudflaredRelayClient({ |
| 562 | baseDir, |
| 563 | releaseAsset: testReleaseAsset, |
| 564 | }).pipe(Effect.provideService(FileSystem.FileSystem, locked)); |
| 565 | |
| 566 | const installing = yield* manager.install.pipe(Effect.forkChild); |
| 567 | yield* Deferred.await(firstAttempt); |
| 568 | yield* TestClock.adjust("1 second"); |
| 569 | const installed = yield* Fiber.join(installing); |
| 570 | |
| 571 | expect(renames.attempts).toBe(3); |
| 572 | expect(new TextDecoder().decode(yield* fileSystem.readFile(installed.executablePath))).toBe( |
| 573 | "test-cloudflared-binary", |
| 574 | ); |
| 575 | expect(yield* fileSystem.readDirectory(managedDirectory(baseDir, "win32"))).toEqual([ |
| 576 | "cloudflared.exe", |
| 577 | ]); |
| 578 | }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("win32"))), |
| 579 | ); |
| 580 | |
| 581 | it.effect.each([ |
| 582 | ["staging", "EPERM", "Could not stage the relay client."], |
| 583 | ["activation", "EBUSY", "Could not activate the relay client."], |
| 584 | ] as const)("gives up on a Windows %s lock that does not clear", ([rename, code, message]) => |
| 585 | Effect.gen(function* () { |
| 586 | const fileSystem = yield* FileSystem.FileSystem; |
| 587 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 588 | prefix: "t3-cloudflared-test-", |
| 589 | }); |
| 590 | const { locked, renames, firstAttempt } = yield* makeLockedRenames(fileSystem, { |
| 591 | rename, |
| 592 | failures: Infinity, |
| 593 | code, |
| 594 | }); |
| 595 | const manager = yield* RelayClient.makeCloudflaredRelayClient({ |
| 596 | baseDir, |
| 597 | releaseAsset: testReleaseAsset, |
| 598 | }).pipe(Effect.provideService(FileSystem.FileSystem, locked)); |
| 599 | |
| 600 | const installing = yield* manager.install.pipe(Effect.flip, Effect.forkChild); |
| 601 | yield* Deferred.await(firstAttempt); |
| 602 | yield* TestClock.adjust("1 minute"); |
| 603 | const error = yield* Fiber.join(installing); |
| 604 | |
| 605 | expect(error.message).toBe(message); |
| 606 | expect(renames.attempts).toBe(41); |
| 607 | // Nothing is installed, and the staged copy, download folder, and install lock are gone. |
| 608 | expect(yield* fileSystem.readDirectory(managedDirectory(baseDir, "win32"))).toEqual([]); |
| 609 | }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("win32"))), |
| 610 | ); |
| 611 | |
| 612 | it.effect.each([ |
| 613 | ["linux", "EBUSY"], |
| 614 | ["win32", "EXDEV"], |
| 615 | ] as const)("fails staging at once on %s for %s", ([platform, code]) => |
| 616 | Effect.gen(function* () { |
| 617 | const fileSystem = yield* FileSystem.FileSystem; |
| 618 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 619 | prefix: "t3-cloudflared-test-", |
| 620 | }); |
| 621 | const { locked, renames } = yield* makeLockedRenames(fileSystem, { |
| 622 | rename: "staging", |
| 623 | failures: Infinity, |
| 624 | code, |
| 625 | }); |
| 626 | const manager = yield* RelayClient.makeCloudflaredRelayClient({ |
| 627 | baseDir, |
| 628 | releaseAsset: testReleaseAsset, |
| 629 | }).pipe(Effect.provideService(FileSystem.FileSystem, locked)); |
| 630 | |
| 631 | const error = yield* manager.install.pipe(Effect.flip); |
| 632 | |
| 633 | expect(error.message).toBe("Could not stage the relay client."); |
| 634 | expect(renames.attempts).toBe(1); |
| 635 | }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime(platform))), |
| 636 | ); |
| 484 | 637 | }); |