From 978c5e75c5753f2956be72786dcd6a5e5c02461e Mon Sep 17 00:00:00 2001 From: jooyyy Date: Tue, 20 Oct 2026 18:12:49 +0800 Subject: [PATCH 1/2] fix(connect): a quit mid-download no longer wedges the relay client install Quitting T3 Code while cloudflared was downloading left an empty install lock. The next install gave up after a few seconds, long before that lock was treated as stale, so the download never started. The lock now records the installer pid. A dead owner is cleared immediately, and an empty lock from the previous format is cleared after a short grace. Grok 4.7 via Cursor Co-authored-by: Cursor --- packages/shared/src/relayClient.test.ts | 88 +++++++++++++++++++++++++ packages/shared/src/relayClient.ts | 67 +++++++++++++++++-- 2 files changed, 149 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index b32ab8138427b..8e0acdd23e9e0 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -8,6 +8,7 @@ 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"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -197,6 +198,93 @@ describe("RelayClient", () => { ), ); + it.effect("keeps an install lock whose owner is still running", () => + 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`; + const token = `${process.pid}:still-running`; + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(lockPath, token); + 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(token); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + layerHttpClient(new Uint8Array()), + layerSpawner([]), + layerHostRuntime({ PATH: "" }), + ), + ), + ), + ); + + it.effect.skipIf(windowsHost)("replaces an install lock left by an exited installer", () => + 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`; + yield* fileSystem.makeDirectory(lockPath.slice(0, lockPath.lastIndexOf("/")), { + recursive: true, + }); + yield* fileSystem.writeFileString(lockPath, "2147483647:exited-installer"); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }); + + expect(yield* manager.install).toMatchObject({ status: "available" }); + expect(yield* fileSystem.exists(lockPath)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), + ); + + it.effect.skipIf(windowsHost)("replaces an empty install lock left by an exited installer", () => + 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`; + yield* fileSystem.makeDirectory(lockPath.slice(0, lockPath.lastIndexOf("/")), { + recursive: true, + }); + yield* fileSystem.writeFileString(lockPath, ""); + const written = yield* fileSystem.stat(lockPath); + const mtime = Option.getOrElse(written.mtime, () => new Date(0)).getTime(); + // The test clock starts at the epoch, so a lock just written looks + // newer than the clock. Move past its mtime by more than the grace. + yield* TestClock.setTime(mtime + 60 * 60 * 1_000); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }); + + expect(yield* manager.install).toMatchObject({ status: "available" }); + expect(yield* fileSystem.exists(lockPath)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), + ); + it.effect("releases the install lock when a download is cancelled", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 09a901d1068b3..1f9e5bd6cfd95 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -117,7 +117,15 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< const INSTALL_LOCK_RETRY_COUNT = 100; const INSTALL_LOCK_RETRY_DELAY = "100 millis"; -const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; +// Locks record `pid:token`. A dead pid is an installer that exited, including +// a quit mid-download, and is removed immediately. A lock with no pid is the +// previous format and names no owner, so it only blocks for a short grace. +// The retry loop outlasts that grace. +const INSTALL_LOCK_LEGACY_GRACE_MS = 5_000; +// Longer than the download timeout, so a live installer is not stolen while a +// slow transfer is still running. A pid that is still alive after this is +// wedged, or the pid was reused. +const INSTALL_LOCK_LIVE_STALE_MS = 12 * 60 * 1_000; const VERSION_PROBE_TIMEOUT = "10 seconds"; const ACTIVATE_RETRY_COUNT = 40; @@ -200,6 +208,24 @@ function isAlreadyExists(error: PlatformError.PlatformError): boolean { return error.reason._tag === "AlreadyExists"; } +function parseLockOwnerPid(contents: string): number | null { + const separator = contents.indexOf(":"); + if (separator <= 0) return null; + const pid = Number(contents.slice(0, separator)); + return Number.isSafeInteger(pid) && pid > 0 ? pid : null; +} + +// `EPERM` means the process exists but belongs to someone else. Any other +// failure, including `ESRCH`, means the installer that wrote the lock is gone. +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as { readonly code?: unknown }).code === "EPERM"; + } +} + const wrapInstallFailure = ( reason: RelayClientInstallError["reason"], @@ -465,13 +491,22 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return bytes; }); + const releaseOwnedLock = (lockPath: string, token: string) => + fileSystem.readFileString(lockPath).pipe( + Effect.flatMap((contents) => + contents.trim() === token ? fileSystem.remove(lockPath, { force: true }) : Effect.void, + ), + Effect.ignore, + ); + const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* ( lockPath: string, ) { + const token = `${process.pid}:${yield* crypto.randomUUIDv4()}`; for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { const acquired = yield* Effect.acquireRelease( - fileSystem.writeFileString(lockPath, "", { flag: "wx" }), - () => fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore), + fileSystem.writeFileString(lockPath, token, { flag: "wx" }).pipe(Effect.as(token)), + (owned) => releaseOwnedLock(lockPath, owned), ).pipe( Effect.as(true), Effect.catchIf(isAlreadyExists, () => Effect.succeed(false)), @@ -480,9 +515,29 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const now = yield* Clock.currentTimeMillis; const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); - const mtime = Option.flatMap(lockInfo, (info) => info.mtime); - if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { - yield* fileSystem.remove(lockPath, { force: true }); + // The holder removed it between the exclusive create and this stat. + if (Option.isNone(lockInfo)) continue; + const mtime = lockInfo.value.mtime; + const age = Option.isSome(mtime) ? now - mtime.value.getTime() : Number.POSITIVE_INFINITY; + const contents = yield* fileSystem.readFileString(lockPath).pipe( + Effect.map((value) => value.trim()), + Effect.orElseSucceed(() => ""), + ); + const ownerPid = parseLockOwnerPid(contents); + const abandoned = + ownerPid === null + ? age > INSTALL_LOCK_LEGACY_GRACE_MS + : !processIsAlive(ownerPid) || age > INSTALL_LOCK_LIVE_STALE_MS; + if (abandoned) { + // Only unlink the contents just judged abandoned. A replacement belongs + // to whoever won the race. + const current = yield* fileSystem.readFileString(lockPath).pipe( + Effect.map((value) => value.trim()), + Effect.option, + ); + if (Option.isSome(current) && current.value === contents) { + yield* fileSystem.remove(lockPath, { force: true }); + } continue; } yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); From da3a9c39b05ad98486038f2ea09efa9c82a30757 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 13:59:34 +0000 Subject: [PATCH 2/2] fix(connect): reclaim installer locks without removing replacements --- packages/shared/src/relayClient.test.ts | 128 ++++++++++++++++-------- packages/shared/src/relayClient.ts | 114 +++++++++++---------- 2 files changed, 148 insertions(+), 94 deletions(-) diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 83b6e28bc91fb..d2719c9ac33ff 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -1,6 +1,7 @@ import { sha256 } from "@noble/hashes/sha2"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -12,6 +13,7 @@ import * as Option from "effect/Option"; 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 * as HostProcess from "./HostProcess.ts"; @@ -157,12 +159,12 @@ describe("RelayClient", () => { const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }).pipe( Effect.provideService(FileSystem.FileSystem, { ...fileSystem, - writeFileString: (path, contents, options) => + rename: (from, to) => fileSystem - .writeFileString(path, contents, options) + .rename(from, to) .pipe( Effect.tapError(() => - path === lockPath ? Deferred.succeed(contended, undefined) : Effect.void, + to === lockPath ? Deferred.succeed(contended, undefined) : Effect.void, ), ), }), @@ -184,44 +186,46 @@ describe("RelayClient", () => { ), ); - it.effect("keeps an install lock whose owner is still running", () => + it.effect.each(["live owner", "recent legacy"] as const)("keeps a lock with a %s", (kind) => 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`; - const token = `${process.pid}:still-running`; - yield* fileSystem.makeDirectory(directory, { recursive: true }); - yield* fileSystem.writeFileString(lockPath, token); - 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 lockPath = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared.lock`; + const owner = `${process.pid}-still-running`; + yield* fileSystem.makeDirectory(lockPath.slice(0, lockPath.lastIndexOf("/")), { + recursive: true, + }); + if (kind === "live owner") { + yield* fileSystem.makeDirectory(lockPath); + yield* fileSystem.writeFileString(`${lockPath}/${owner}`, ""); + } else { + yield* fileSystem.writeFileString(lockPath, ""); + } + const written = yield* fileSystem.stat(lockPath); + yield* TestClock.setTime( + Option.getOrThrow(written.mtime).getTime() + (kind === "live owner" ? 3_600_000 : 6_000), + ); + const waiting = yield* Deferred.make(); + const clock = yield* Clock.Clock; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }); + const installing = yield* manager.install.pipe( + Effect.provideService(Clock.Clock, { + ...clock, + sleep: () => Deferred.succeed(waiting, undefined).pipe(Effect.andThen(Effect.never)), }), + Effect.forkChild, ); - const installing = yield* manager.install.pipe(Effect.forkChild); - yield* Deferred.await(contended); + yield* Deferred.await(waiting); yield* Fiber.interrupt(installing); - expect(yield* fileSystem.readFileString(lockPath)).toBe(token); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerHttpClient(new Uint8Array()), - layerSpawner([]), - layerHostRuntime({ PATH: "" }), - ), - ), - ), + if (kind === "live owner") { + expect(yield* fileSystem.readDirectory(lockPath)).toEqual([owner]); + } else { + expect(yield* fileSystem.readFileString(lockPath)).toBe(""); + } + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), ); it.effect.skipIf(windowsHost)("replaces an install lock left by an exited installer", () => @@ -234,7 +238,8 @@ describe("RelayClient", () => { yield* fileSystem.makeDirectory(lockPath.slice(0, lockPath.lastIndexOf("/")), { recursive: true, }); - yield* fileSystem.writeFileString(lockPath, "2147483647:exited-installer"); + yield* fileSystem.makeDirectory(lockPath); + yield* fileSystem.writeFileString(`${lockPath}/2147483647-exited-installer`, ""); const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir, releaseAsset: testReleaseAsset, @@ -257,9 +262,8 @@ describe("RelayClient", () => { }); yield* fileSystem.writeFileString(lockPath, ""); const written = yield* fileSystem.stat(lockPath); - const mtime = Option.getOrElse(written.mtime, () => new Date(0)).getTime(); - // The test clock starts at the epoch, so a lock just written looks - // newer than the clock. Move past its mtime by more than the grace. + const mtime = Option.getOrThrow(written.mtime).getTime(); + // File mtimes use wall time; advance the test clock past legacy expiry. yield* TestClock.setTime(mtime + 60 * 60 * 1_000); const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir, @@ -271,6 +275,46 @@ describe("RelayClient", () => { }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), ); + it.effect("keeps a replacement lock when a stale contender resumes cleanup", () => + 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 abandoned = "2147483647-exited-installer"; + const replacement = `${process.pid}-replacement`; + yield* fileSystem.makeDirectory(lockPath, { recursive: true }); + yield* fileSystem.writeFileString(`${lockPath}/${abandoned}`, ""); + const checked = yield* Deferred.make(); + let replaced = false; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readDirectory: (path) => + fileSystem.readDirectory(path).pipe( + Effect.tap((entries) => + Effect.gen(function* () { + if (path !== lockPath) return; + if (replaced) { + expect(entries).toEqual([replacement]); + yield* Deferred.succeed(checked, undefined); + return; + } + replaced = true; + yield* fileSystem.rename(lockPath, `${lockPath}.old`); + yield* fileSystem.makeDirectory(lockPath); + yield* fileSystem.writeFileString(`${lockPath}/${replacement}`, ""); + }), + ), + ), + }), + ); + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(checked); + yield* Fiber.interrupt(installing); + expect(yield* fileSystem.readDirectory(lockPath)).toEqual([replacement]); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), + ); + it.effect("releases the install lock when a download is cancelled", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -320,12 +364,12 @@ describe("RelayClient", () => { }).pipe( Effect.provideService(FileSystem.FileSystem, { ...fileSystem, - writeFileString: (path, contents, options) => + rename: (from, to) => fileSystem - .writeFileString(path, contents, options) + .rename(from, to) .pipe( Effect.tap(() => - path === lockPath && pauseWrite + to === lockPath && pauseWrite ? Deferred.succeed(acquired, undefined).pipe( Effect.andThen(Deferred.await(completeWrite)), ) diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index f027c6c72bdae..904b9cf9536cf 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.remove cannot remove an empty directory nonrecursively. +import * as NodeFSP from "node:fs/promises"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import type { @@ -16,7 +18,7 @@ import * as FileSystem from "effect/FileSystem"; 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 type * as PlatformError from "effect/PlatformError"; import * as Schedule from "effect/Schedule"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; @@ -115,15 +117,8 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< const INSTALL_LOCK_RETRY_COUNT = 100; const INSTALL_LOCK_RETRY_DELAY = "100 millis"; -// Locks record `pid:token`. A dead pid is an installer that exited, including -// a quit mid-download, and is removed immediately. A lock with no pid is the -// previous format and names no owner, so it only blocks for a short grace. -// The retry loop outlasts that grace. -const INSTALL_LOCK_LEGACY_GRACE_MS = 5_000; -// Longer than the download timeout, so a live installer is not stolen while a -// slow transfer is still running. A pid that is still alive after this is -// wedged, or the pid was reused. -const INSTALL_LOCK_LIVE_STALE_MS = 12 * 60 * 1_000; +// Legacy locks have no owner; allow both ten-minute download stages and the install tail. +const INSTALL_LOCK_LEGACY_STALE_MS = 30 * 60 * 1_000; const VERSION_PROBE_TIMEOUT = "10 seconds"; const ACTIVATE_RETRY_COUNT = 40; @@ -202,25 +197,29 @@ class VersionProbeKey extends Data.Class<{ readonly mtimeMillis: number; }> {} -function isAlreadyExists(error: PlatformError.PlatformError): boolean { - return error.reason._tag === "AlreadyExists"; +function isLockContention(error: PlatformError.PlatformError): boolean { + const code = (error.reason.cause as NodeJS.ErrnoException | undefined)?.code; + return ( + error.reason._tag === "AlreadyExists" || + error.reason._tag === "BadResource" || + code === "ENOTEMPTY" || + isWindowsRenameLock(error) + ); } -function parseLockOwnerPid(contents: string): number | null { - const separator = contents.indexOf(":"); +function parseLockOwnerPid(owner: string): number | null { + const separator = owner.indexOf("-"); if (separator <= 0) return null; - const pid = Number(contents.slice(0, separator)); + const pid = Number(owner.slice(0, separator)); return Number.isSafeInteger(pid) && pid > 0 ? pid : null; } -// `EPERM` means the process exists but belongs to someone else. Any other -// failure, including `ESRCH`, means the installer that wrote the lock is gone. function processIsAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { - return (error as { readonly code?: unknown }).code === "EPERM"; + return (error as { readonly code?: unknown }).code !== "ESRCH"; } } @@ -482,54 +481,65 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return bytes; }); - const releaseOwnedLock = (lockPath: string, token: string) => - fileSystem.readFileString(lockPath).pipe( - Effect.flatMap((contents) => - contents.trim() === token ? fileSystem.remove(lockPath, { force: true }) : Effect.void, - ), - Effect.ignore, - ); + const removeEmptyLock = (lockPath: string) => + Effect.tryPromise(() => NodeFSP.rmdir(lockPath)).pipe(Effect.ignore); + + const releaseOwnedLock = Effect.fn("cloudflared.releaseOwnedLock")(function* ( + lockPath: string, + owner: string, + ) { + yield* fileSystem.remove(path.join(lockPath, owner), { force: true }).pipe(Effect.ignore); + // A replacement contains a different owner entry, so rmdir cannot remove it. + yield* removeEmptyLock(lockPath); + }); const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* ( lockPath: string, ) { - const token = `${process.pid}:${yield* crypto.randomUUIDv4()}`; + const owner = `${process.pid}-${yield* crypto.randomUUIDv4()}`; + const candidate = yield* Effect.acquireRelease( + fileSystem.makeTempDirectory({ directory: path.dirname(lockPath), prefix: ".lock-" }), + (directory) => + fileSystem.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ); + yield* fileSystem.writeFileString(path.join(candidate, owner), ""); for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { const acquired = yield* Effect.acquireRelease( - fileSystem.writeFileString(lockPath, token, { flag: "wx" }).pipe(Effect.as(token)), - (owned) => releaseOwnedLock(lockPath, owned), + // Publish a populated directory atomically, with no ownerless acquisition window. + fileSystem.rename(candidate, lockPath), + () => releaseOwnedLock(lockPath, owner), ).pipe( Effect.as(true), - Effect.catchIf(isAlreadyExists, () => Effect.succeed(false)), + Effect.catchIf(isLockContention, () => Effect.succeed(false)), ); if (acquired) return; - const now = yield* Clock.currentTimeMillis; const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); - // The holder removed it between the exclusive create and this stat. if (Option.isNone(lockInfo)) continue; - const mtime = lockInfo.value.mtime; - const age = Option.isSome(mtime) ? now - mtime.value.getTime() : Number.POSITIVE_INFINITY; - const contents = yield* fileSystem.readFileString(lockPath).pipe( - Effect.map((value) => value.trim()), - Effect.orElseSucceed(() => ""), - ); - const ownerPid = parseLockOwnerPid(contents); - const abandoned = - ownerPid === null - ? age > INSTALL_LOCK_LEGACY_GRACE_MS - : !processIsAlive(ownerPid) || age > INSTALL_LOCK_LIVE_STALE_MS; - if (abandoned) { - // Only unlink the contents just judged abandoned. A replacement belongs - // to whoever won the race. - const current = yield* fileSystem.readFileString(lockPath).pipe( - Effect.map((value) => value.trim()), - Effect.option, - ); - if (Option.isSome(current) && current.value === contents) { - yield* fileSystem.remove(lockPath, { force: true }); + if (lockInfo.value.type === "Directory") { + const owners = yield* fileSystem + .readDirectory(lockPath) + .pipe(Effect.orElseSucceed(() => [])); + let recovered = owners.length === 0; + for (const entry of owners) { + const pid = parseLockOwnerPid(entry); + if (pid !== null && !processIsAlive(pid)) { + yield* releaseOwnedLock(lockPath, entry); + recovered = true; + } + } + if (recovered) { + yield* removeEmptyLock(lockPath); + continue; + } + } else { + const now = yield* Clock.currentTimeMillis; + const mtime = lockInfo.value.mtime; + if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_LEGACY_STALE_MS) { + // Nonrecursive removal also refuses a populated directory that replaced this file. + yield* fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore); + continue; } - continue; } yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); }