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
180 changes: 177 additions & 3 deletions lib/cache-lock.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,164 @@
import { randomUUID } from "node:crypto"
import fs from "node:fs/promises"
import path from "node:path"
import lockfile from "proper-lockfile"

type LockFunction = (file: string, options?: Record<string, unknown>) => Promise<() => Promise<void>>

type RetryOptions = {
retries: number
minTimeout: number
maxTimeout: number
}

let cachedLockFunction: LockFunction | undefined
const DIRECTORY_LOCK_OWNER_FILE = ".owner"

function resolveLockFunction(value: unknown, visited = new Set<unknown>()): LockFunction | undefined {
if (typeof value === "function") {
return value as LockFunction
}
if (!value || typeof value !== "object") {
return undefined
}
if (visited.has(value)) {
return undefined
}
visited.add(value)

const record = value as Record<string, unknown>
const direct = resolveLockFunction(record.lock, visited)
if (direct) return direct

const viaDefault = resolveLockFunction(record.default, visited)
if (viaDefault) return viaDefault

const viaModule = resolveLockFunction(record.module, visited)
if (viaModule) return viaModule

const viaExports = resolveLockFunction(record.exports, visited)
if (viaExports) return viaExports

return undefined
}

function isFsErrorCode(error: unknown, code: string): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === code
}

function toRetryOptions(value: unknown): RetryOptions {
const defaultOptions: RetryOptions = {
retries: 0,
minTimeout: 50,
maxTimeout: 200
}
if (!value || typeof value !== "object") {
return defaultOptions
}

const record = value as Record<string, unknown>
const retries =
typeof record.retries === "number" && Number.isFinite(record.retries)
? Math.max(0, Math.floor(record.retries))
: defaultOptions.retries
const minTimeout =
typeof record.minTimeout === "number" && Number.isFinite(record.minTimeout)
? Math.max(1, Math.floor(record.minTimeout))
: defaultOptions.minTimeout
const maxTimeout =
typeof record.maxTimeout === "number" && Number.isFinite(record.maxTimeout)
? Math.max(minTimeout, Math.floor(record.maxTimeout))
: defaultOptions.maxTimeout

return {
retries,
minTimeout,
maxTimeout
}
}

async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms))
}

function ownerFilePathForLockDirectory(lockDir: string): string {
return path.join(lockDir, DIRECTORY_LOCK_OWNER_FILE)
}

async function writeDirectoryLockOwner(lockDir: string, ownerToken: string): Promise<void> {
await fs.writeFile(ownerFilePathForLockDirectory(lockDir), ownerToken, { mode: 0o600 })
}

async function hasDirectoryLockOwner(lockDir: string, ownerToken: string): Promise<boolean> {
try {
const value = await fs.readFile(ownerFilePathForLockDirectory(lockDir), "utf8")
return value === ownerToken
} catch (error) {
if (isFsErrorCode(error, "ENOENT")) {
return false
}
throw error
}
}

async function lockWithDirectoryFallback(
targetPath: string,
options?: Record<string, unknown>
): Promise<() => Promise<void>> {
const lockDir = `${targetPath}.lock`
const retry = toRetryOptions(options?.retries)

for (let attempt = 0; ; attempt += 1) {
try {
await fs.mkdir(lockDir)
const ownerToken = randomUUID()
try {
await writeDirectoryLockOwner(lockDir, ownerToken)
} catch (error) {
await fs.rm(lockDir, { recursive: true, force: true })
throw error
}
return async () => {
if (!(await hasDirectoryLockOwner(lockDir, ownerToken))) {
return
}
try {
await fs.rm(lockDir, { recursive: true, force: true })
} catch (error) {
if (!isFsErrorCode(error, "ENOENT")) {
throw error
}
}
}
} catch (error) {
if (!isFsErrorCode(error, "EEXIST")) {
throw error
}

if (attempt >= retry.retries) {
throw error
}

const timeout = Math.min(retry.maxTimeout, retry.minTimeout * 2 ** attempt)
await sleep(timeout)
}
}
}

async function resolveImportedLockFunction(specifier: string): Promise<LockFunction | undefined> {
try {
return resolveLockFunction(await import(specifier))
} catch {
return undefined
}
}

async function getLockFunction(): Promise<LockFunction> {
if (cachedLockFunction) return cachedLockFunction

cachedLockFunction = (await resolveImportedLockFunction("proper-lockfile")) ?? lockWithDirectoryFallback

return cachedLockFunction
}

const LOCK_RETRIES = {
retries: 20,
Expand Down Expand Up @@ -41,7 +199,8 @@ export async function withLockedDirectory<T>(
options: LockTargetOptions = {}
): Promise<T> {
await fs.mkdir(directoryPath, { recursive: true })
const release = await lockfile.lock(directoryPath, resolveLockOptions(options))
const lock = await getLockFunction()
const release = await lock(directoryPath, resolveLockOptions(options))
try {
return await fn()
} finally {
Expand All @@ -57,10 +216,25 @@ export async function withLockedFile<T>(
await fs.mkdir(path.dirname(filePath), { recursive: true })
const lockTargetPath = lockTargetPathForFile(filePath)
await ensureLockTargetFile(lockTargetPath)
const release = await lockfile.lock(lockTargetPath, resolveLockOptions(options))
const lock = await getLockFunction()
const release = await lock(lockTargetPath, resolveLockOptions(options))
try {
return await fn()
} finally {
await release()
}
}

// Test-only hooks for focused fallback coverage without widening the runtime behavior surface.
export const __cacheLockTest = {
DIRECTORY_LOCK_OWNER_FILE,
getLockFunction,
hasDirectoryLockOwner,
lockWithDirectoryFallback,
ownerFilePathForLockDirectory,
resolveImportedLockFunction,
resolveLockFunction,
resolveLockOptions,
toRetryOptions,
writeDirectoryLockOwner
}
5 changes: 5 additions & 0 deletions scripts/test-mocking-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
"mock": 0,
"stubGlobal": 0
},
"test/cache-lock.test.ts": {
"doMock": 4,
"mock": 0,
"stubGlobal": 0
},
"test/codex-native-auth-menu-wiring.test.ts": {
"doMock": 7,
"mock": 0,
Expand Down
Loading
Loading