diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 85482f876c53..8d1a095f8dc2 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -2,7 +2,9 @@ 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 Fiber from "effect/Fiber"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -92,6 +94,133 @@ const managedPathFor = (baseDir: string, version: string) => `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`; describe("RelayClient", () => { + it.effect("cancels a contended install without removing the other installer's lock", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-" }); + const directory = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64`; + const lockPath = `${directory}/cloudflared.lock`; + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(lockPath, "other-installer"); + const contended = yield* Deferred.make(); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + writeFileString: (path, contents, options) => + fileSystem + .writeFileString(path, contents, options) + .pipe( + Effect.tapError(() => + path === lockPath ? Deferred.succeed(contended, undefined) : Effect.void, + ), + ), + }), + ); + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(contended); + yield* Fiber.interrupt(installing); + expect(yield* fileSystem.readFileString(lockPath)).toBe("other-installer"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(new Uint8Array()), + layerSpawner([]), + layerHostRuntime({ PATH: "" }), + ), + ), + ), + ); + + it.effect("releases the install lock when a download is cancelled", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-" }); + const lockPath = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`; + const downloading = yield* Deferred.make(); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); + const installing = yield* manager + .installWithProgress((event) => + event.type === "progress" && event.stage === "downloading" + ? Deferred.succeed(downloading, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + ) + .pipe(Effect.forkChild); + yield* Deferred.await(downloading); + expect(yield* fileSystem.exists(lockPath)).toBe(true); + yield* Fiber.interrupt(installing); + expect(yield* fileSystem.exists(lockPath)).toBe(false); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(new Uint8Array()), + layerSpawner([]), + layerHostRuntime({ PATH: "" }), + ), + ), + ), + ); + + it.effect.skipIf(windowsHost)("releases a lock acquired while installation is cancelled", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-" }); + const lockPath = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`; + const acquired = yield* Deferred.make(); + const completeWrite = yield* Deferred.make(); + let pauseWrite = true; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: { + url: "https://example.test/cloudflared", + sha256: Hex.encode(sha256(new TextEncoder().encode("test-binary"))), + archive: "binary", + }, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + writeFileString: (path, contents, options) => + fileSystem + .writeFileString(path, contents, options) + .pipe( + Effect.tap(() => + path === lockPath && pauseWrite + ? Deferred.succeed(acquired, undefined).pipe( + Effect.andThen(Deferred.await(completeWrite)), + ) + : Effect.void, + ), + ), + }), + ); + + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(acquired); + const cancelling = yield* Fiber.interrupt(installing).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(completeWrite, undefined); + yield* Fiber.join(cancelling); + expect(yield* fileSystem.exists(lockPath)).toBe(false); + + pauseWrite = false; + expect(yield* manager.install).toMatchObject({ status: "available" }); + expect(yield* fileSystem.exists(lockPath)).toBe(false); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(new TextEncoder().encode("test-binary")), + layerSpawner([]), + layerHostRuntime({ PATH: "" }), + ), + ), + ), + ); it.effect.skipIf(windowsHost)( "resolves explicit overrides before managed and PATH executables", () => @@ -212,6 +341,11 @@ describe("RelayClient", () => { const error = yield* manager.install.pipe(Effect.flip); expect(error).toBeInstanceOf(RelayClient.RelayClientInstallError); expect(error.reason).toBe("invalid_checksum"); + expect( + yield* fileSystem.exists( + `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`, + ), + ).toBe(false); }).pipe( Effect.scoped, Effect.provide( diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 67dee30bdcd5..5f0cabdadb34 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -466,7 +466,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function lockPath: string, ) { for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { - const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( + const acquired = yield* Effect.acquireRelease( + fileSystem.writeFileString(lockPath, "", { flag: "wx" }), + () => fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore), + ).pipe( Effect.as(true), Effect.catchIf(isAlreadyExists, () => Effect.succeed(false)), ); @@ -591,7 +594,6 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } satisfies AvailableRelayClient; }).pipe( Effect.scoped, - Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), Effect.catchIf( (cause) => !(cause instanceof RelayClientInstallError), (cause) => @@ -612,7 +614,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function type: "progress", stage, }), - ), + ).pipe(Effect.scoped), ); const install = installWithProgress(() => Effect.void);