authorgravatar for snorteman@gmail.comScott Norteman <snorteman@gmail.com> 2026-10-08 16:19:09-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 13:19:09-07:00
logbd565bd1794f49f176ad137555faa46224716138
treef0e16ce24bbfefe6047ef0d8f797b57f9fd5d24c
parent2a93885bac5798a79d55069a0b5dc3e53c6176bc
signature Signed by PGP key B5690EEEBB952194

fix(shared): relay client install waits out a brief Windows file lock (#16998)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 183 insertions(+), 11 deletions(-)

packages/shared/src/relayClient.test.ts+155-2
...@@ -2,12 +2,16 @@ import { sha256 } from "@noble/hashes/sha2";...@@ -2,12 +2,16 @@ import { sha256 } from "@noble/hashes/sha2";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as Hex from "effect/encoding/Hex";7import * as Hex from "effect/encoding/Hex";
8import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";9import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as PlatformError from "effect/PlatformError";
9import * as Sink from "effect/Sink";12import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";13import * as Stream from "effect/Stream";
14import * as TestClock from "effect/testing/TestClock";
11import { HttpClient, HttpClientResponse } from "effect/http";15import { HttpClient, HttpClientResponse } from "effect/http";
12import { ChildProcess, ChildProcessSpawner } from "effect/process";16import { ChildProcess, ChildProcessSpawner } from "effect/process";
13import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts";17import { 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.
19const windowsHost = HostProcessPlatform.defaultValue() === "win32";23const windowsHost = HostProcessPlatform.defaultValue() === "win32";
2024
21const layerHostRuntime = (env: Record<string, string> = {}) =>25const 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 =
91const managedPathFor = (baseDir: string, version: string) =>95const managedPathFor = (baseDir: string, version: string) =>
92 `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`;96 `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`;
9397
98const 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
107type 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 */
113const 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
138const testBinary = new TextEncoder().encode("test-cloudflared-binary");
139const testReleaseAsset = {
140 url: "https://example.test/cloudflared",
141 sha256: Hex.encode(sha256(testBinary)),
142 archive: "binary",
143} as const;
144
145const layerInstallRuntime = (platform: NodeJS.Platform) =>
146 Layer.mergeAll(
147 NodeServices.layer,
148 layerHttpClient(testBinary),
149 layerSpawner([]),
150 layerHostRuntime({ PATH: "" }, platform),
151 );
152
153const managedDirectory = (baseDir: string, platform: NodeJS.Platform) =>
154 `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/${platform}-x64`;
155
94describe("RelayClient", () => {156describe("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});
packages/shared/src/relayClient.ts+28-9
...@@ -17,6 +17,7 @@ import * as Layer from "effect/Layer";...@@ -17,6 +17,7 @@ import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";17import * as Option from "effect/Option";
18import * as Path from "effect/Path";18import * as Path from "effect/Path";
19import * as PlatformError from "effect/PlatformError";19import * as PlatformError from "effect/PlatformError";
20import * as Schedule from "effect/Schedule";
20import * as Semaphore from "effect/Semaphore";21import * as Semaphore from "effect/Semaphore";
21import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";22import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
22import { ChildProcess, ChildProcessSpawner } from "effect/process";23import { ChildProcess, ChildProcessSpawner } from "effect/process";
...@@ -116,6 +117,9 @@ const INSTALL_LOCK_RETRY_DELAY = "100 millis";...@@ -116,6 +117,9 @@ const INSTALL_LOCK_RETRY_DELAY = "100 millis";
116const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;117const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;
117const VERSION_PROBE_TIMEOUT = "10 seconds";118const VERSION_PROBE_TIMEOUT = "10 seconds";
118119
120const ACTIVATE_RETRY_COUNT = 40;
121const ACTIVATE_RETRY_DELAY = "250 millis";
122
119const trimmedString = (name: string) =>123const trimmedString = (name: string) =>
120 Config.String(name).pipe(124 Config.String(name).pipe(
121 Config.option,125 Config.option,
...@@ -205,6 +209,13 @@ function isAlreadyExists(error: PlatformError.PlatformError): boolean {...@@ -205,6 +209,13 @@ function isAlreadyExists(error: PlatformError.PlatformError): boolean {
205 return error.reason._tag === "AlreadyExists";209 return error.reason._tag === "AlreadyExists";
206}210}
207211
212// Windows refuses to rename a file another process briefly holds open, such as a virus
213// scanner reading the binary that just ran. These codes clear once it lets go.
214function isTransientWindowsLock(error: PlatformError.PlatformError): boolean {
215 const code = (error.reason.cause as NodeJS.ErrnoException | undefined)?.code;
216 return code === "EBUSY" || code === "EPERM" || code === "EACCES";
217}
218
208const wrapInstallFailure =219const wrapInstallFailure =
209 (220 (
210 reason: RelayClientInstallError["reason"],221 reason: RelayClientInstallError["reason"],
...@@ -397,6 +408,16 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -397,6 +408,16 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
397 }408 }
398 }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions"));409 }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions"));
399410
411 /** Moves the new binary toward its final path, waiting out a brief Windows lock. */
412 const renameWhenUnlocked = (from: string, to: string) =>
413 fileSystem.rename(from, to).pipe(
414 Effect.retry({
415 times: ACTIVATE_RETRY_COUNT,
416 schedule: Schedule.spaced(ACTIVATE_RETRY_DELAY),
417 while: (error) => platform === "win32" && isTransientWindowsLock(error),
418 }),
419 );
420
400 const runCommand = Effect.fn("cloudflared.runCommand")(function* (421 const runCommand = Effect.fn("cloudflared.runCommand")(function* (
401 command: string,422 command: string,
402 args: ReadonlyArray<string>,423 args: ReadonlyArray<string>,
...@@ -574,15 +595,13 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -574,15 +595,13 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
574595
575 const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`;596 const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`;
576 yield* report("activating");597 yield* report("activating");
577 yield* fileSystem598 yield* renameWhenUnlocked(executablePath, stagedPath).pipe(
578 .rename(executablePath, stagedPath)599 wrapInstallFailure("write_failed", "Could not stage the relay client."),
579 .pipe(wrapInstallFailure("write_failed", "Could not stage the relay client."));600 );
580 yield* fileSystem601 yield* renameWhenUnlocked(stagedPath, managedPath).pipe(
581 .rename(stagedPath, managedPath)602 wrapInstallFailure("write_failed", "Could not activate the relay client."),
582 .pipe(603 Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)),
583 wrapInstallFailure("write_failed", "Could not activate the relay client."),604 );
584 Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)),
585 );
586 return {605 return {
587 status: "available",606 status: "available",
588 executablePath: managedPath,607 executablePath: managedPath,