| ... | @@ -2,12 +2,16 @@ import { sha256 } from "@noble/hashes/sha2"; | ... | @@ -2,12 +2,16 @@ import { sha256 } from "@noble/hashes/sha2"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { describe, expect, it } from "@effect/vitest"; | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | 4 | import * as ConfigProvider from "effect/ConfigProvider"; |
| | 5 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as Hex from "effect/encoding/Hex"; | 7 | import * as Hex from "effect/encoding/Hex"; |
| | 8 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | 9 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| | 11 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import * as Sink from "effect/Sink"; | 12 | import * as Sink from "effect/Sink"; |
| 10 | import * as Stream from "effect/Stream"; | 13 | import * as Stream from "effect/Stream"; |
| | 14 | import * as TestClock from "effect/testing/TestClock"; |
| 11 | import { HttpClient, HttpClientResponse } from "effect/http"; | 15 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | 16 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 13 | import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; | 17 | import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; |
| ... | @@ -18,9 +22,9 @@ import * as RelayClient from "./relayClient.ts"; | ... | @@ -18,9 +22,9 @@ import * as RelayClient from "./relayClient.ts"; |
| 18 | // POSIX exec bits that NTFS never reports; the win32 branch skips that check. | 22 | // POSIX exec bits that NTFS never reports; the win32 branch skips that check. |
| 19 | const windowsHost = HostProcessPlatform.defaultValue() === "win32"; | 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 | Layer.mergeAll( | 26 | Layer.mergeAll( |
| 23 | Layer.succeed(HostProcessPlatform, "linux"), | 27 | Layer.succeed(HostProcessPlatform, platform), |
| 24 | Layer.succeed(HostProcessArchitecture, "x64"), | 28 | Layer.succeed(HostProcessArchitecture, "x64"), |
| 25 | ConfigProvider.layer(ConfigProvider.fromEnv({ env })), | 29 | ConfigProvider.layer(ConfigProvider.fromEnv({ env })), |
| 26 | ); | 30 | ); |
| ... | @@ -91,6 +95,64 @@ type RelayClientTestServices = | ... | @@ -91,6 +95,64 @@ type RelayClientTestServices = |
| 91 | const managedPathFor = (baseDir: string, version: string) => | 95 | const managedPathFor = (baseDir: string, version: string) => |
| 92 | `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`; | 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 | describe("RelayClient", () => { | 156 | describe("RelayClient", () => { |
| 95 | it.effect.skipIf(windowsHost)( | 157 | it.effect.skipIf(windowsHost)( |
| 96 | "resolves explicit overrides before managed and PATH executables", | 158 | "resolves explicit overrides before managed and PATH executables", |
| ... | @@ -481,4 +543,95 @@ describe("RelayClient", () => { | ... | @@ -481,4 +543,95 @@ describe("RelayClient", () => { |
| 481 | expect(RelayClient.compareCloudflaredVersions("2025.10.0", "2025.6.1")).toBeGreaterThan(0); | 543 | expect(RelayClient.compareCloudflaredVersions("2025.10.0", "2025.6.1")).toBeGreaterThan(0); |
| 482 | expect(RelayClient.compareCloudflaredVersions("2023.8.2", "2025.6.1")).toBeLessThan(0); | 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 | }); |