diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 291cd347039b287f8f40e8ffff122a575fa93672..23ae8eb2d33d47425520fbf5ac7d82df66fd5a9e 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -4,6 +4,7 @@ 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 Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -154,6 +155,133 @@ const managedDirectory = (baseDir: string, platform: NodeJS.Platform) => `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/${platform}-x64`; 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", () => @@ -274,6 +402,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 e75133256ef569a4600069bf5f534728b07159fa..9e2f4bea8229417bbfad4917cc3a1b7accbf09e4 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -487,7 +487,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)), ); @@ -610,7 +613,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) => @@ -631,7 +633,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function type: "progress", stage, }), - ), + ).pipe(Effect.scoped), ); const install = installWithProgress(() => Effect.void);