@astrale-os/cli 1.0.0-beta.26 → 1.0.0-beta.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { lstat, mkdir, readFile, readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ export type FileLockOptions = {
6
+ pollIntervalMs?: number
7
+ staleAfterMs?: number
8
+ timeoutMs?: number
9
+ }
10
+
11
+ type Claim = { directory: string; pid: number; ticket: number | null; choosing: boolean }
12
+
13
+ /**
14
+ * Serialize a cross-process transition with a filesystem bakery lock.
15
+ *
16
+ * Every contender owns a UUID directory for its entire lifetime. Dead claims are
17
+ * removed only by that unrepeatable name, so recovery never deletes a pathname a
18
+ * new live owner could have replaced between an ownership check and an unlink.
19
+ */
20
+ export async function withFileLock<T>(
21
+ lockPath: string,
22
+ fn: () => Promise<T>,
23
+ opts: FileLockOptions = {},
24
+ ): Promise<T> {
25
+ const pollIntervalMs = opts.pollIntervalMs ?? 100
26
+ const staleAfterMs = opts.staleAfterMs ?? 30_000
27
+ const timeoutMs = opts.timeoutMs ?? 60_000
28
+ const deadline = Date.now() + timeoutMs
29
+ const claimsRoot = `${lockPath}.claims`
30
+ const token = randomUUID()
31
+ const claimDirectory = join(claimsRoot, token)
32
+
33
+ await mkdir(dirname(lockPath), { recursive: true })
34
+ await mkdir(claimsRoot, { recursive: true })
35
+ await mkdir(claimDirectory)
36
+ await writeFile(join(claimDirectory, 'owner.json'), JSON.stringify({ pid: process.pid }), {
37
+ mode: 0o600,
38
+ })
39
+ await writeFile(join(claimDirectory, 'choosing'), '', { mode: 0o600 })
40
+
41
+ try {
42
+ const claims = await liveClaims(claimsRoot, staleAfterMs)
43
+ const ticket = Math.max(0, ...claims.flatMap((claim) => claim.ticket ?? [])) + 1
44
+ await writeFile(join(claimDirectory, 'ticket'), String(ticket), { mode: 0o600 })
45
+ await unlink(join(claimDirectory, 'choosing'))
46
+
47
+ for (;;) {
48
+ const contenders = await liveClaims(claimsRoot, staleAfterMs)
49
+ const blocked = contenders.some(
50
+ (claim) =>
51
+ claim.directory !== claimDirectory &&
52
+ (claim.choosing ||
53
+ claim.ticket === null ||
54
+ claim.ticket < ticket ||
55
+ (claim.ticket === ticket && claim.directory < claimDirectory)),
56
+ )
57
+ if (!blocked) return await fn()
58
+ if (Date.now() >= deadline) throw new Error('Timed out waiting for another skill update')
59
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
60
+ }
61
+ } finally {
62
+ await rm(claimDirectory, { recursive: true, force: true })
63
+ }
64
+ }
65
+
66
+ async function liveClaims(root: string, staleAfterMs: number): Promise<Claim[]> {
67
+ const directories = (await readdir(root, { withFileTypes: true }).catch(() => []))
68
+ .filter((entry) => entry.isDirectory())
69
+ .map((entry) => join(root, entry.name))
70
+ const claims = await Promise.all(
71
+ directories.map(async (directory): Promise<Claim | null> => {
72
+ try {
73
+ const owner = JSON.parse(await readFile(join(directory, 'owner.json'), 'utf8')) as {
74
+ pid?: unknown
75
+ }
76
+ if (typeof owner.pid !== 'number') return await discardIfStale(directory, staleAfterMs)
77
+ if (!isPidAlive(owner.pid)) {
78
+ await rm(directory, { recursive: true, force: true })
79
+ return null
80
+ }
81
+ const choosing = await lstat(join(directory, 'choosing'))
82
+ .then(() => true)
83
+ .catch(() => false)
84
+ const ticket = await readFile(join(directory, 'ticket'), 'utf8')
85
+ .then((value) => {
86
+ const parsed = Number(value)
87
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
88
+ })
89
+ .catch(() => null)
90
+ return { directory, pid: owner.pid, ticket, choosing }
91
+ } catch {
92
+ return await discardIfStale(directory, staleAfterMs)
93
+ }
94
+ }),
95
+ )
96
+ return claims.filter((claim): claim is Claim => claim !== null)
97
+ }
98
+
99
+ async function discardIfStale(directory: string, staleAfterMs: number): Promise<null> {
100
+ try {
101
+ if (Date.now() - (await stat(directory)).mtimeMs > staleAfterMs) {
102
+ await rm(directory, { recursive: true, force: true })
103
+ }
104
+ } catch {
105
+ // A concurrently released unique claim is already gone.
106
+ }
107
+ return null
108
+ }
109
+
110
+ function isPidAlive(pid: number): boolean {
111
+ try {
112
+ process.kill(pid, 0)
113
+ return true
114
+ } catch (error) {
115
+ return (error as { code?: string }).code === 'EPERM'
116
+ }
117
+ }