Skip to content

Commit 7befb07

Browse files
authored
Fix Playground cache leases on macOS (#1936)
1 parent 6bbfc83 commit 7befb07

2 files changed

Lines changed: 154 additions & 73 deletions

File tree

packages/runtime-playground/src/playground-wordpress-archive-cache.ts

Lines changed: 141 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ interface LeaseState {
4242
token?: string
4343
}
4444

45+
interface LeaseDirectory {
46+
handle: FileHandle
47+
path: string
48+
access: "descriptor-relative" | "generation-checked-path"
49+
}
50+
4551
class LeaseSidecarUnsafeError extends Error {}
4652

4753
export interface PlaygroundArchiveCacheLock {
@@ -161,15 +167,15 @@ export async function acquirePlaygroundArchiveReference(archivePath: string, opt
161167
const policy = cacheLeasePolicy()
162168
const leaseMs = options.leaseMs ?? policy.leaseMs
163169
const referencesDirectory = `${archivePath}.refs`
164-
const directoryHandle = await openSafeLeaseDirectory(referencesDirectory, true)
170+
const directory = await openSafeLeaseDirectory(referencesDirectory, true)
165171
const token = randomUUID()
166172
const fileName = `${token}.json`
167173
const referencePath = join(referencesDirectory, fileName)
168174
let lease: LeaseHandle
169175
try {
170-
lease = await createFileLease(directoryHandle, fileName, referencePath, token, leaseMs, options.heartbeat !== false, options.releaseInterlock)
176+
lease = await createFileLease(directory, fileName, referencePath, token, leaseMs, options.heartbeat !== false, options.releaseInterlock)
171177
} catch (error) {
172-
await directoryHandle.close()
178+
await directory.handle.close()
173179
throw error
174180
}
175181

@@ -359,31 +365,34 @@ async function tryAcquireCacheLock(lockPath: string, leaseMs: number): Promise<L
359365
}
360366
throw error
361367
}
362-
let directoryHandle: FileHandle | undefined
368+
let directory: LeaseDirectory | undefined
363369
try {
364-
directoryHandle = await openSafeLeaseDirectory(lockPath, false)
365-
return await createDirectoryLease(directoryHandle, lockPath, token, leaseMs)
370+
directory = await openSafeLeaseDirectory(lockPath, false)
371+
return await createDirectoryLease(directory, lockPath, token, leaseMs)
366372
} catch (error) {
367-
await directoryHandle?.close().catch(() => undefined)
373+
await directory?.handle.close().catch(() => undefined)
368374
await rmdir(lockPath).catch(() => undefined)
369375
if (isConcurrentDisappearance(error)) return undefined
370376
throw error
371377
}
372378
}
373379

374-
async function createDirectoryLease(directoryHandle: FileHandle, lockPath: string, token: string, leaseMs: number): Promise<LeaseHandle> {
380+
async function createDirectoryLease(directory: LeaseDirectory, lockPath: string, token: string, leaseMs: number): Promise<LeaseHandle> {
375381
const fileName = `${token}.lease.json`
376-
return createFileLease(directoryHandle, fileName, join(lockPath, fileName), token, leaseMs, true)
382+
return createFileLease(directory, fileName, join(lockPath, fileName), token, leaseMs, true)
377383
}
378384

379-
async function createFileLease(directoryHandle: FileHandle, fileName: string, displayPath: string, token: string, leaseMs: number, automaticHeartbeat: boolean, releaseInterlock?: (leasePath: string) => void | Promise<void>): Promise<LeaseHandle> {
380-
const anchoredPath = join(fileDescriptorPath(directoryHandle), fileName)
381-
const fileHandle = await open(anchoredPath, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600)
385+
async function createFileLease(directory: LeaseDirectory, fileName: string, displayPath: string, token: string, leaseMs: number, automaticHeartbeat: boolean, releaseInterlock?: (leasePath: string) => void | Promise<void>): Promise<LeaseHandle> {
386+
const fileHandle = await openLeaseDirectoryChild(directory, fileName, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600)
382387
await writeOwnerToHandle(fileHandle, await ownerRecord(token, leaseMs))
383388
return heartbeatLease(fileHandle, token, leaseMs, async () => {
384389
await releaseInterlock?.(displayPath)
385-
await fileHandle.close()
386-
await directoryHandle.close()
390+
try {
391+
if (releaseInterlock) await assertLeaseDirectoryGeneration(directory)
392+
} finally {
393+
await fileHandle.close()
394+
await directory.handle.close()
395+
}
387396
}, displayPath, automaticHeartbeat)
388397
}
389398

@@ -441,49 +450,48 @@ async function cacheLockIsActive(lockPath: string, now: number, policy: Pick<Pla
441450
throw error
442451
}
443452
if (!lockStat.isDirectory() || lockStat.isSymbolicLink()) return true
444-
let directoryHandle: FileHandle
453+
let directory: LeaseDirectory
445454
try {
446-
directoryHandle = await openSafeLeaseDirectory(lockPath, false)
455+
directory = await openSafeLeaseDirectory(lockPath, false)
447456
} catch (error) {
448457
if (isConcurrentDisappearance(error)) return false
449458
if (error instanceof LeaseSidecarUnsafeError || errorHasCode(error, "ELOOP") || errorHasCode(error, "ENOTDIR")) return true
450459
throw error
451460
}
452461
let active = false
453462
try {
454-
const anchoredDirectory = fileDescriptorPath(directoryHandle)
455463
let names: string[]
456464
try {
457-
names = await readdir(anchoredDirectory)
465+
names = await readLeaseDirectory(directory)
458466
} catch (error) {
459467
if (isConcurrentDisappearance(error)) return false
460468
throw error
461469
}
462470
for (const name of names.sort()) {
463-
const path = join(anchoredDirectory, name)
471+
const path = join(lockPath, name)
464472
if (name !== "owner.json" && !name.endsWith(".lease.json")) {
465473
let unknownStat
466474
try {
467-
unknownStat = await lstat(path)
475+
unknownStat = await lstatLeaseDirectoryChild(directory, name)
468476
} catch (error) {
469477
if (isConcurrentDisappearance(error)) continue
470478
throw error
471479
}
472480
if (unknownStat.isDirectory() || now - lockStat.mtimeMs <= policy.staleLockMs || !removeStale) {
473481
active = true
474482
} else {
475-
await unlinkIfPresent(path)
483+
await unlinkLeaseDirectoryChild(directory, name)
476484
}
477485
continue
478486
}
479-
const state = await inspectLeaseFile(path, now, policy.staleLockMs, removeStale)
487+
const state = await inspectLeaseFile(directory, name, now, policy.staleLockMs, removeStale)
480488
if (state.active) {
481489
active = true
482490
}
483491
}
484492
if (names.length === 0 && now - lockStat.mtimeMs <= policy.staleLockMs) active = true
485493
} finally {
486-
await directoryHandle.close()
494+
await directory.handle.close()
487495
if (removeStale && !active) {
488496
await rmdir(lockPath).catch((error) => {
489497
if (!isConcurrentDisappearance(error) && !errorHasCode(error, "ENOTEMPTY")) throw error
@@ -507,9 +515,9 @@ async function activeArchiveReferenceCount(archivePath: string, now: number, pol
507515
if (removeStale) await unlinkIfPresent(referencesDirectory)
508516
return removeStale ? 0 : 1
509517
}
510-
let directoryHandle: FileHandle
518+
let directory: LeaseDirectory
511519
try {
512-
directoryHandle = await openSafeLeaseDirectory(referencesDirectory, false)
520+
directory = await openSafeLeaseDirectory(referencesDirectory, false)
513521
} catch (error) {
514522
if (isConcurrentDisappearance(error)) return 0
515523
if (error instanceof LeaseSidecarUnsafeError || errorHasCode(error, "ELOOP") || errorHasCode(error, "ENOTDIR")) {
@@ -518,71 +526,78 @@ async function activeArchiveReferenceCount(archivePath: string, now: number, pol
518526
}
519527
throw error
520528
}
521-
const anchoredDirectory = fileDescriptorPath(directoryHandle)
522529
let names: string[]
523530
try {
524-
names = await readdir(anchoredDirectory)
531+
names = await readLeaseDirectory(directory)
525532
} catch (error) {
526-
await directoryHandle.close()
533+
await directory.handle.close()
527534
if (isConcurrentDisappearance(error)) return 0
528535
throw error
529536
}
530537
let active = 0
531538
try {
532539
for (const name of names.sort()) {
533-
const path = join(anchoredDirectory, name)
534-
const state = await inspectLeaseFile(path, now, policy.staleLockMs, removeStale, diagnostics)
540+
const state = await inspectLeaseFile(directory, name, now, policy.staleLockMs, removeStale, diagnostics)
535541
if (state.active) {
536542
active += 1
537543
}
538544
}
539545
} finally {
540-
await directoryHandle.close()
546+
await directory.handle.close()
541547
}
542548
return active
543549
}
544550

545-
async function inspectLeaseFile(path: string, now: number, legacyStaleMs: number, removeUnsafe: boolean, diagnostics: PlaygroundCustomArchiveCacheDiagnostic[] = []): Promise<LeaseState> {
546-
let pathStat
547-
try {
548-
pathStat = await lstat(path)
549-
} catch (error) {
550-
if (isConcurrentDisappearance(error)) return { active: false }
551-
throw error
552-
}
553-
if (!pathStat.isFile() || pathStat.isSymbolicLink() || pathStat.nlink !== 1) {
554-
diagnostics.push({ code: "custom-archive-ref-entry-unsafe", message: "Archive lease entry is not a singly-linked regular file and was not followed.", severity: "warning", path })
555-
if (removeUnsafe && !pathStat.isDirectory()) await unlinkIfPresent(path)
556-
return { active: !removeUnsafe || pathStat.isDirectory() }
557-
}
558-
559-
let handle: FileHandle | undefined
551+
async function inspectLeaseFile(directory: LeaseDirectory, name: string, now: number, legacyStaleMs: number, removeUnsafe: boolean, diagnostics: PlaygroundCustomArchiveCacheDiagnostic[] = []): Promise<LeaseState> {
552+
const path = join(directory.path, name)
560553
try {
561-
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
562-
const openedStat = await handle.stat()
563-
if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
564-
diagnostics.push({ code: "custom-archive-ref-entry-changed", message: "Archive lease entry changed while opening and was conservatively protected.", severity: "warning", path })
565-
return { active: true }
554+
await assertLeaseDirectoryGeneration(directory)
555+
let pathStat
556+
try {
557+
pathStat = await lstatLeaseDirectoryChild(directory, name)
558+
} catch (error) {
559+
if (isConcurrentDisappearance(error)) return { active: false }
560+
throw error
566561
}
567-
const owner = await readOwnerFromHandle(handle)
568-
if (owner) {
569-
const expiresAt = Date.parse(owner.expiresAt)
570-
if (Number.isFinite(expiresAt) && expiresAt > now) return { active: true, token: owner.token }
571-
if (removeUnsafe) await unlinkIfPresent(path)
572-
return { active: false, token: owner.token }
562+
if (!pathStat.isFile() || pathStat.isSymbolicLink() || pathStat.nlink !== 1) {
563+
diagnostics.push({ code: "custom-archive-ref-entry-unsafe", message: "Archive lease entry is not a singly-linked regular file and was not followed.", severity: "warning", path })
564+
if (removeUnsafe && !pathStat.isDirectory()) await unlinkLeaseDirectoryChild(directory, name)
565+
return { active: !removeUnsafe || pathStat.isDirectory() }
573566
}
574-
const active = now - pathStat.mtimeMs <= legacyStaleMs
575-
if (!active && removeUnsafe) await unlinkIfPresent(path)
576-
return { active }
577-
} catch (error) {
578-
if (isConcurrentDisappearance(error)) return { active: false }
579-
if (errorHasCode(error, "ELOOP")) {
580-
diagnostics.push({ code: "custom-archive-ref-entry-unsafe", message: "Archive lease entry became a symlink and was conservatively protected.", severity: "warning", path })
581-
return { active: true }
567+
let handle: FileHandle | undefined
568+
try {
569+
handle = await openLeaseDirectoryChild(directory, name, constants.O_RDONLY | constants.O_NOFOLLOW)
570+
const openedStat = await handle.stat()
571+
if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
572+
diagnostics.push({ code: "custom-archive-ref-entry-changed", message: "Archive lease entry changed while opening and was conservatively protected.", severity: "warning", path })
573+
return { active: true }
574+
}
575+
const owner = await readOwnerFromHandle(handle)
576+
if (owner) {
577+
const expiresAt = Date.parse(owner.expiresAt)
578+
if (Number.isFinite(expiresAt) && expiresAt > now) return { active: true, token: owner.token }
579+
if (removeUnsafe) await unlinkLeaseDirectoryChild(directory, name)
580+
return { active: false, token: owner.token }
581+
}
582+
const active = now - pathStat.mtimeMs <= legacyStaleMs
583+
if (!active && removeUnsafe) await unlinkLeaseDirectoryChild(directory, name)
584+
return { active }
585+
} catch (error) {
586+
if (isConcurrentDisappearance(error)) return { active: false }
587+
if (errorHasCode(error, "ELOOP")) {
588+
diagnostics.push({ code: "custom-archive-ref-entry-unsafe", message: "Archive lease entry became a symlink and was conservatively protected.", severity: "warning", path })
589+
return { active: true }
590+
}
591+
throw error
592+
} finally {
593+
await handle?.close()
582594
}
583-
throw error
584595
} finally {
585-
await handle?.close()
596+
try {
597+
await assertLeaseDirectoryGeneration(directory)
598+
} catch (error) {
599+
if (!isConcurrentDisappearance(error)) throw error
600+
}
586601
}
587602
}
588603

@@ -649,7 +664,7 @@ async function removeOrphanReferencesDirectory(path: string, now: number, policy
649664
}
650665
}
651666

652-
async function openSafeLeaseDirectory(path: string, create: boolean): Promise<FileHandle> {
667+
async function openSafeLeaseDirectory(path: string, create: boolean): Promise<LeaseDirectory> {
653668
for (let attempt = 0; attempt < (create ? 3 : 1); attempt += 1) {
654669
try {
655670
if (create) {
@@ -673,18 +688,71 @@ async function openSafeLeaseDirectory(path: string, create: boolean): Promise<Fi
673688
await handle.close()
674689
throw new LeaseSidecarUnsafeError(`Playground cache lease sidecar changed while opening: ${path}`)
675690
}
676-
return handle
691+
return { handle, path, access: leaseDirectoryAccess() }
677692
} catch (error) {
678693
if (!create || !isConcurrentDisappearance(error) || attempt === 2) throw error
679694
}
680695
}
681696
throw new Error(`Unable to create Playground cache lease sidecar: ${path}`)
682697
}
683698

684-
function fileDescriptorPath(handle: FileHandle): string {
685-
if (process.platform === "linux") return `/proc/self/fd/${handle.fd}`
686-
if (process.platform === "darwin" || process.platform === "freebsd") return `/dev/fd/${handle.fd}`
687-
throw new Error("Playground cache leases require a platform with file-descriptor paths")
699+
function leaseDirectoryAccess(): LeaseDirectory["access"] {
700+
if (process.platform === "linux") return "descriptor-relative"
701+
if (process.platform === "darwin" || process.platform === "freebsd") return "generation-checked-path"
702+
throw new Error("Playground cache leases require Linux descriptor-relative access or Darwin/FreeBSD generation-checked path access")
703+
}
704+
705+
function leaseDirectoryChildPath(directory: LeaseDirectory, name?: string): string {
706+
const path = directory.access === "descriptor-relative" ? `/proc/self/fd/${directory.handle.fd}` : directory.path
707+
return name === undefined ? path : join(path, name)
708+
}
709+
710+
async function assertLeaseDirectoryGeneration(directory: LeaseDirectory): Promise<void> {
711+
if (directory.access !== "generation-checked-path") return
712+
const [pathStat, handleStat] = await Promise.all([lstat(directory.path), directory.handle.stat()])
713+
if (!pathStat.isDirectory() || pathStat.isSymbolicLink() || pathStat.dev !== handleStat.dev || pathStat.ino !== handleStat.ino) {
714+
throw new LeaseSidecarUnsafeError(`Playground cache lease sidecar changed while accessing: ${directory.path}`)
715+
}
716+
}
717+
718+
async function readLeaseDirectory(directory: LeaseDirectory): Promise<string[]> {
719+
await assertLeaseDirectoryGeneration(directory)
720+
try {
721+
return await readdir(leaseDirectoryChildPath(directory))
722+
} finally {
723+
await assertLeaseDirectoryGeneration(directory)
724+
}
725+
}
726+
727+
async function lstatLeaseDirectoryChild(directory: LeaseDirectory, name: string) {
728+
await assertLeaseDirectoryGeneration(directory)
729+
try {
730+
return await lstat(leaseDirectoryChildPath(directory, name))
731+
} finally {
732+
await assertLeaseDirectoryGeneration(directory)
733+
}
734+
}
735+
736+
async function openLeaseDirectoryChild(directory: LeaseDirectory, name: string, flags: number, mode?: number): Promise<FileHandle> {
737+
await assertLeaseDirectoryGeneration(directory)
738+
let handle: FileHandle | undefined
739+
try {
740+
handle = await open(leaseDirectoryChildPath(directory, name), flags, mode)
741+
await assertLeaseDirectoryGeneration(directory)
742+
return handle
743+
} catch (error) {
744+
await handle?.close()
745+
throw error
746+
}
747+
}
748+
749+
async function unlinkLeaseDirectoryChild(directory: LeaseDirectory, name: string): Promise<boolean> {
750+
await assertLeaseDirectoryGeneration(directory)
751+
try {
752+
return await unlinkIfPresent(leaseDirectoryChildPath(directory, name))
753+
} finally {
754+
await assertLeaseDirectoryGeneration(directory)
755+
}
688756
}
689757

690758
async function readOwnerFromHandle(handle: FileHandle): Promise<CacheOwnerRecord | undefined> {

tests/playground-custom-archive-cache.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,19 @@ try {
145145
await rm(staleSamePath.path)
146146
await rm(`${samePathArchive}.refs`, { recursive: true })
147147

148+
if (process.platform === "darwin" || process.platform === "freebsd") {
149+
const replacedDirectoryArchive = await archive("custom-replaced-refs-directory.zip", 46, Date.now())
150+
const replacedDirectoryReference = await acquirePlaygroundArchiveReference(replacedDirectoryArchive, {
151+
heartbeat: false,
152+
async releaseInterlock() {
153+
await rm(`${replacedDirectoryArchive}.refs`, { recursive: true })
154+
await mkdir(`${replacedDirectoryArchive}.refs`)
155+
},
156+
})
157+
await assert.rejects(() => replacedDirectoryReference.release(), /sidecar changed while accessing/, "Darwin/FreeBSD pathname fallback must reject a replaced parent directory generation")
158+
await rm(`${replacedDirectoryArchive}.refs`, { recursive: true })
159+
}
160+
148161
const replacedCandidate = await archive("custom-replaced-candidate.zip", 47, now - 100_000)
149162
let replaced = false
150163
const replacementResult = await maintainPlaygroundCustomArchiveCache(root, {

0 commit comments

Comments
 (0)