Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 155 additions & 2 deletions packages/shared/src/relayClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string, string> = {}) =>
const layerHostRuntime = (env: Record<string, string> = {}, platform: NodeJS.Platform = "linux") =>
Layer.mergeAll(
Layer.succeed(HostProcessPlatform, "linux"),
Layer.succeed(HostProcessPlatform, platform),
Layer.succeed(HostProcessArchitecture, "x64"),
ConfigProvider.layer(ConfigProvider.fromEnv({ env })),
);
Expand Down Expand Up @@ -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<void>();
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",
Expand Down Expand Up @@ -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))),
);
});
37 changes: 28 additions & 9 deletions packages/shared/src/relayClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
schedule: Schedule.spaced(ACTIVATE_RETRY_DELAY),
while: (error) => platform === "win32" && isTransientWindowsLock(error),
}),
);

const runCommand = Effect.fn("cloudflared.runCommand")(function* (
command: string,
args: ReadonlyArray<string>,
Expand Down Expand Up @@ -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,
Expand Down
Loading