| @@ -1,4 +1,3 @@ |
| 1 | | −import * as Clock from "effect/Clock"; |
| 2 | 1 | import type { |
| 3 | 2 | RelayClientInstallProgressEvent, |
| 4 | 3 | RelayClientInstallProgressStage, |
| @@ -8,6 +7,8 @@ import * as ConfigProvider from "effect/ConfigProvider"; · 3 unmodified lines |
| 8 | 7 | import * as Context from "effect/Context"; |
| 9 | 8 | import * as Crypto from "effect/Crypto"; |
| 10 | 9 | import * as Data from "effect/Data"; |
| 10 | +import * as DateTime from "effect/DateTime"; |
| 11 | +import * as Duration from "effect/Duration"; |
| 11 | 12 | import * as Effect from "effect/Effect"; |
| 12 | 13 | import * as Encoding from "effect/Encoding"; |
| 13 | 14 | import * as FileSystem from "effect/FileSystem"; |
| @@ -15,6 +16,7 @@ import * as Layer from "effect/Layer"; · 1 unmodified line |
| 15 | 16 | import * as Option from "effect/Option"; |
| 16 | 17 | import * as Path from "effect/Path"; |
| 17 | 18 | import * as PlatformError from "effect/PlatformError"; |
| 19 | +import * as Schedule from "effect/Schedule"; |
| 18 | 20 | import * as Semaphore from "effect/Semaphore"; |
| 19 | 21 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 20 | 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| @@ -99,8 +101,18 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< · 78 unmodified lines |
| 99 | 101 | }; |
| 100 | 102 | |
| 101 | 103 | const INSTALL_LOCK_RETRY_COUNT = 100; |
| 102 | | −const INSTALL_LOCK_RETRY_DELAY = "100 millis"; |
| 103 | | −const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; |
| 104 | +const INSTALL_LOCK_RETRY_DELAY = Duration.millis(100); |
| 105 | +const INSTALL_LOCK_TIMEOUT = Duration.times(INSTALL_LOCK_RETRY_DELAY, INSTALL_LOCK_RETRY_COUNT); |
| 106 | +const INSTALL_LOCK_STALE_AGE = Duration.minutes(5); |
| 107 | + |
| 108 | +class RelayClientInstallLockBusy extends Data.TaggedError("RelayClientInstallLockBusy")<{ |
| 109 | + readonly lockPath: string; |
| 110 | +}> {} |
| 111 | + |
| 112 | +const retryWhileInstallLockBusy = Schedule.spaced(INSTALL_LOCK_RETRY_DELAY).pipe( |
| 113 | + Schedule.setInputType<RelayClientInstallLockBusy | PlatformError.PlatformError>(), |
| 114 | + Schedule.while(({ input }) => input._tag === "RelayClientInstallLockBusy"), |
| 115 | +); |
| 104 | 116 | |
| 105 | 117 | const trimmedString = (name: string) => |
| 106 | 118 | Config.string(name).pipe( |
| @@ -161,8 +173,8 @@ export function resolveManagedCloudflaredPath(input: { · 54 unmodified lines |
| 161 | 173 | function resolveReleaseAsset( |
| 162 | 174 | platform: NodeJS.Platform, |
| 163 | 175 | arch: string, |
| 164 | | −): CloudflaredReleaseAsset | null { |
| 165 | | − return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null; |
| 176 | +): Option.Option<CloudflaredReleaseAsset> { |
| 177 | + return Option.fromUndefinedOr(CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`]); |
| 166 | 178 | } |
| 167 | 179 | |
| 168 | 180 | function isAlreadyExists(error: PlatformError.PlatformError): boolean { |
| @@ -207,7 +219,9 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 38 unmodified lines |
| 207 | 219 | const installSemaphore = yield* Semaphore.make(1); |
| 208 | 220 | const platform = options.platform ?? process.platform; |
| 209 | 221 | const arch = options.arch ?? process.arch; |
| 210 | | − const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); |
| 222 | + const releaseAsset = Option.fromUndefinedOr(options.releaseAsset).pipe( |
| 223 | + Option.orElse(() => resolveReleaseAsset(platform, arch)), |
| 224 | + ); |
| 211 | 225 | const loadCloudflaredConfig = Effect.suspend(() => |
| 212 | 226 | CloudflaredConfig.pipe( |
| 213 | 227 | Effect.provideService( |
| @@ -236,15 +250,15 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 22 unmodified lines |
| 236 | 250 | const resolvePathExecutable = Effect.gen(function* () { |
| 237 | 251 | const config = yield* loadCloudflaredConfig; |
| 238 | 252 | const pathValue = Option.getOrUndefined(config.path); |
| 239 | | − if (!pathValue) return null; |
| 253 | + if (!pathValue) return Option.none(); |
| 240 | 254 | const delimiter = platform === "win32" ? ";" : ":"; |
| 241 | 255 | for (const directory of pathValue.split(delimiter)) { |
| 242 | 256 | const trimmed = directory.trim().replace(/^"|"$/gu, ""); |
| 243 | 257 | if (trimmed.length === 0) continue; |
| 244 | 258 | const candidate = path.join(trimmed, executableFileName(platform)); |
| 245 | | − if (yield* isExecutableFile(candidate)) return candidate; |
| 259 | + if (yield* isExecutableFile(candidate)) return Option.some(candidate); |
| 246 | 260 | } |
| 247 | | − return null; |
| 261 | + return Option.none(); |
| 248 | 262 | }); |
| 249 | 263 | |
| 250 | 264 | const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { |
| @@ -268,15 +282,15 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 17 unmodified lines |
| 268 | 282 | }; |
| 269 | 283 | } |
| 270 | 284 | const pathExecutable = yield* resolvePathExecutable; |
| 271 | | − if (pathExecutable) { |
| 285 | + if (Option.isSome(pathExecutable)) { |
| 272 | 286 | return { |
| 273 | 287 | status: "available", |
| 274 | | − executablePath: pathExecutable, |
| 288 | + executablePath: pathExecutable.value, |
| 275 | 289 | source: "path", |
| 276 | 290 | version: CLOUDFLARED_VERSION, |
| 277 | 291 | }; |
| 278 | 292 | } |
| 279 | | − return releaseAsset |
| 293 | + return Option.isSome(releaseAsset) |
| 280 | 294 | ? { status: "missing", version: CLOUDFLARED_VERSION } |
| 281 | 295 | : { |
| 282 | 296 | status: "unsupported", |
| @@ -351,31 +365,59 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 68 unmodified lines |
| 351 | 365 | return bytes; |
| 352 | 366 | }); |
| 353 | 367 | |
| 368 | + const isInstallLockStale = Effect.fn("cloudflared.isInstallLockStale")(function* ( |
| 369 | + lockPath: string, |
| 370 | + ) { |
| 371 | + const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); |
| 372 | + const lockModifiedAt = Option.flatMap(lockInfo, (info) => info.mtime); |
| 373 | + if (Option.isNone(lockModifiedAt)) return false; |
| 374 | + |
| 375 | + const now = yield* DateTime.now; |
| 376 | + const lockAge = DateTime.distance(DateTime.makeUnsafe(lockModifiedAt.value), now); |
| 377 | + return Duration.isGreaterThan(lockAge, INSTALL_LOCK_STALE_AGE); |
| 378 | + }); |
| 379 | + |
| 380 | + const attemptAcquireInstallLock = Effect.fn("cloudflared.attemptAcquireInstallLock")(function* ( |
| 381 | + lockPath: string, |
| 382 | + ) { |
| 383 | + const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( |
| 384 | + Effect.as(true), |
| 385 | + Effect.catch((error) => (isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error))), |
| 386 | + ); |
| 387 | + if (acquired) return; |
| 388 | + |
| 389 | + if (yield* isInstallLockStale(lockPath)) { |
| 390 | + yield* fileSystem.remove(lockPath, { force: true }); |
| 391 | + return yield* attemptAcquireInstallLock(lockPath); |
| 392 | + } |
| 393 | + |
| 394 | + return yield* new RelayClientInstallLockBusy({ lockPath }); |
| 395 | + }); |
| 396 | + |
| 354 | 397 | const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* ( |
| 355 | 398 | lockPath: string, |
| 356 | 399 | ) { |
| 357 | | − for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { |
| 358 | | − const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( |
| 359 | | − Effect.as(true), |
| 360 | | − Effect.catch((error) => |
| 361 | | − isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error), |
| 400 | + return yield* attemptAcquireInstallLock(lockPath).pipe( |
| 401 | + Effect.retry(retryWhileInstallLockBusy), |
| 402 | + Effect.timeoutOrElse({ |
| 403 | + duration: INSTALL_LOCK_TIMEOUT, |
| 404 | + orElse: () => |
| 405 | + Effect.fail( |
| 406 | + new RelayClientInstallError({ |
| 407 | + reason: "install_locked", |
| 408 | + message: "Another relay client installation is still in progress.", |
| 409 | + }), |
| 410 | + ), |
| 411 | + }), |
| 412 | + Effect.catchTag("RelayClientInstallLockBusy", () => |
| 413 | + Effect.fail( |
| 414 | + new RelayClientInstallError({ |
| 415 | + reason: "install_locked", |
| 416 | + message: "Another relay client installation is still in progress.", |
| 417 | + }), |
| 362 | 418 | ), |
| 363 | | − ); |
| 364 | | − if (acquired) return; |
| 365 | | − |
| 366 | | − const now = yield* Clock.currentTimeMillis; |
| 367 | | − const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); |
| 368 | | − const mtime = Option.flatMap(lockInfo, (info) => info.mtime); |
| 369 | | − if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { |
| 370 | | − yield* fileSystem.remove(lockPath, { force: true }); |
| 371 | | − continue; |
| 372 | | − } |
| 373 | | − yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); |
| 374 | | − } |
| 375 | | − return yield* new RelayClientInstallError({ |
| 376 | | − reason: "install_locked", |
| 377 | | − message: "Another relay client installation is still in progress.", |
| 378 | | − }); |
| 419 | + ), |
| 420 | + ); |
| 379 | 421 | }); |
| 380 | 422 | |
| 381 | 423 | const installUnlocked = Effect.fn("cloudflared.installUnlocked")(function* ( |
| @@ -391,7 +433,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 9 unmodified lines |
| 391 | 433 | message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, |
| 392 | 434 | }); |
| 393 | 435 | } |
| 394 | | − if (!releaseAsset) { |
| 436 | + if (Option.isNone(releaseAsset)) { |
| 395 | 437 | return yield* new RelayClientInstallError({ |
| 396 | 438 | reason: "unsupported_platform", |
| 397 | 439 | message: `T3 Code does not provide a managed relay client binary for ${platform}-${arch}.`, |
| @@ -427,16 +469,16 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function · 29 unmodified lines |
| 427 | 469 | }); |
| 428 | 470 | const archivePath = path.join( |
| 429 | 471 | tempDirectory, |
| 430 | | − releaseAsset.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform), |
| 472 | + releaseAsset.value.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform), |
| 431 | 473 | ); |
| 432 | | − const download = yield* downloadAsset(releaseAsset, report); |
| 474 | + const download = yield* downloadAsset(releaseAsset.value, report); |
| 433 | 475 | yield* report("installing"); |
| 434 | 476 | yield* fileSystem |
| 435 | 477 | .writeFile(archivePath, download) |
| 436 | 478 | .pipe(wrapInstallFailure("write_failed", "Could not write the relay client download.")); |
| 437 | 479 | |
| 438 | 480 | const executablePath = path.join(tempDirectory, executableFileName(platform)); |
| 439 | | − if (releaseAsset.archive === "tgz") { |
| 481 | + if (releaseAsset.value.archive === "tgz") { |
| 440 | 482 | yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe( |
| 441 | 483 | wrapInstallFailure("write_failed", "Could not extract the relay client."), |
| 442 | 484 | ); |