@saeeol/core 7.3.2 → 7.3.5

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.
@@ -1,426 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import fs from "fs/promises"
3
- import { spawn } from "child_process"
4
- import path from "path"
5
- import os from "os"
6
- import { Flock } from "@saeeol/core/util/flock"
7
- import { Hash } from "@saeeol/core/util/hash"
8
-
9
- type Msg = {
10
- key: string
11
- dir: string
12
- staleMs?: number
13
- timeoutMs?: number
14
- baseDelayMs?: number
15
- maxDelayMs?: number
16
- holdMs?: number
17
- ready?: string
18
- active?: string
19
- done?: string
20
- }
21
-
22
- const root = path.join(import.meta.dir, "../..")
23
- const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts")
24
-
25
- async function tmpdir() {
26
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flock-test-"))
27
- return {
28
- path: dir,
29
- async [Symbol.asyncDispose]() {
30
- await fs.rm(dir, { recursive: true, force: true })
31
- },
32
- }
33
- }
34
-
35
- function lock(dir: string, key: string) {
36
- return path.join(dir, Hash.fast(key) + ".lock")
37
- }
38
-
39
- function sleep(ms: number) {
40
- return new Promise<void>((resolve) => {
41
- setTimeout(resolve, ms)
42
- })
43
- }
44
-
45
- async function exists(file: string) {
46
- return fs
47
- .stat(file)
48
- .then(() => true)
49
- .catch(() => false)
50
- }
51
-
52
- async function wait(file: string, timeout = 3_000) {
53
- const stop = Date.now() + timeout
54
- while (Date.now() < stop) {
55
- if (await exists(file)) return
56
- await sleep(20)
57
- }
58
-
59
- throw new Error(`Timed out waiting for file: ${file}`)
60
- }
61
-
62
- function run(msg: Msg) {
63
- return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
64
- const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], {
65
- cwd: root,
66
- })
67
-
68
- const stdout: Buffer[] = []
69
- const stderr: Buffer[] = []
70
-
71
- proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
72
- proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
73
-
74
- proc.on("close", (code) => {
75
- resolve({
76
- code: code ?? 1,
77
- stdout: Buffer.concat(stdout),
78
- stderr: Buffer.concat(stderr),
79
- })
80
- })
81
- })
82
- }
83
-
84
- function spawnWorker(msg: Msg) {
85
- return spawn(process.execPath, [worker, JSON.stringify(msg)], {
86
- cwd: root,
87
- stdio: ["ignore", "pipe", "pipe"],
88
- })
89
- }
90
-
91
- function stopWorker(proc: ReturnType<typeof spawnWorker>) {
92
- if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve()
93
-
94
- if (process.platform !== "win32" || !proc.pid) {
95
- proc.kill()
96
- return Promise.resolve()
97
- }
98
-
99
- return new Promise<void>((resolve) => {
100
- const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
101
- killProc.on("close", () => {
102
- proc.kill()
103
- resolve()
104
- })
105
- })
106
- }
107
-
108
- async function readJson<T>(p: string): Promise<T> {
109
- return JSON.parse(await fs.readFile(p, "utf8"))
110
- }
111
-
112
- describe("util.flock", () => {
113
- test("enforces mutual exclusion under process contention", async () => {
114
- await using tmp = await tmpdir()
115
- const dir = path.join(tmp.path, "locks")
116
- const done = path.join(tmp.path, "done.log")
117
- const active = path.join(tmp.path, "active")
118
- const key = "flock:stress"
119
- const n = 16
120
-
121
- const out = await Promise.all(
122
- Array.from({ length: n }, () =>
123
- run({
124
- key,
125
- dir,
126
- done,
127
- active,
128
- holdMs: 30,
129
- staleMs: 1_000,
130
- timeoutMs: 15_000,
131
- }),
132
- ),
133
- )
134
-
135
- expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
136
- expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
137
-
138
- const lines = (await fs.readFile(done, "utf8"))
139
- .split("\n")
140
- .map((x) => x.trim())
141
- .filter(Boolean)
142
- expect(lines.length).toBe(n)
143
- }, 20_000)
144
-
145
- test("times out while waiting when lock is still healthy", async () => {
146
- await using tmp = await tmpdir()
147
- const dir = path.join(tmp.path, "locks")
148
- const key = "flock:timeout"
149
- const ready = path.join(tmp.path, "ready")
150
- const proc = spawnWorker({
151
- key,
152
- dir,
153
- ready,
154
- holdMs: 20_000,
155
- staleMs: 10_000,
156
- timeoutMs: 30_000,
157
- })
158
-
159
- try {
160
- await wait(ready, 5_000)
161
- const seen: string[] = []
162
- const err = await Flock.withLock(key, async () => {}, {
163
- dir,
164
- staleMs: 10_000,
165
- timeoutMs: 1_000,
166
- onWait: (tick) => {
167
- seen.push(tick.key)
168
- },
169
- }).catch((err) => err)
170
-
171
- expect(err).toBeInstanceOf(Error)
172
- if (!(err instanceof Error)) throw err
173
- expect(err.message).toContain("Timed out waiting for lock")
174
- expect(seen.length).toBeGreaterThan(0)
175
- expect(seen.every((x) => x === key)).toBe(true)
176
- } finally {
177
- await stopWorker(proc).catch(() => undefined)
178
- await new Promise((resolve) => proc.on("close", resolve))
179
- }
180
- }, 15_000)
181
-
182
- test("recovers after a crashed lock owner", async () => {
183
- await using tmp = await tmpdir()
184
- const dir = path.join(tmp.path, "locks")
185
- const key = "flock:crash"
186
- const ready = path.join(tmp.path, "ready")
187
- const proc = spawnWorker({
188
- key,
189
- dir,
190
- ready,
191
- holdMs: 20_000,
192
- staleMs: 500,
193
- timeoutMs: 30_000,
194
- })
195
-
196
- await wait(ready, 5_000)
197
- await stopWorker(proc)
198
- await new Promise((resolve) => proc.on("close", resolve))
199
-
200
- let hit = false
201
- await Flock.withLock(
202
- key,
203
- async () => {
204
- hit = true
205
- },
206
- {
207
- dir,
208
- staleMs: 500,
209
- timeoutMs: 8_000,
210
- },
211
- )
212
-
213
- expect(hit).toBe(true)
214
- }, 20_000)
215
-
216
- test("breaks stale lock dirs when heartbeat is missing", async () => {
217
- await using tmp = await tmpdir()
218
- const dir = path.join(tmp.path, "locks")
219
- const key = "flock:missing-heartbeat"
220
- const lockDir = lock(dir, key)
221
-
222
- await fs.mkdir(lockDir, { recursive: true })
223
- const old = new Date(Date.now() - 2_000)
224
- await fs.utimes(lockDir, old, old)
225
-
226
- let hit = false
227
- await Flock.withLock(
228
- key,
229
- async () => {
230
- hit = true
231
- },
232
- {
233
- dir,
234
- staleMs: 200,
235
- timeoutMs: 3_000,
236
- },
237
- )
238
-
239
- expect(hit).toBe(true)
240
- })
241
-
242
- test("recovers when a stale breaker claim was left behind", async () => {
243
- await using tmp = await tmpdir()
244
- const dir = path.join(tmp.path, "locks")
245
- const key = "flock:stale-breaker"
246
- const lockDir = lock(dir, key)
247
- const breaker = lockDir + ".breaker"
248
-
249
- await fs.mkdir(lockDir, { recursive: true })
250
- await fs.mkdir(breaker)
251
-
252
- const old = new Date(Date.now() - 2_000)
253
- await fs.utimes(lockDir, old, old)
254
- await fs.utimes(breaker, old, old)
255
-
256
- let hit = false
257
- await Flock.withLock(
258
- key,
259
- async () => {
260
- hit = true
261
- },
262
- {
263
- dir,
264
- staleMs: 200,
265
- timeoutMs: 3_000,
266
- },
267
- )
268
-
269
- expect(hit).toBe(true)
270
- expect(await exists(breaker)).toBe(false)
271
- })
272
-
273
- test("fails clearly if lock dir is removed while held", async () => {
274
- await using tmp = await tmpdir()
275
- const dir = path.join(tmp.path, "locks")
276
- const key = "flock:compromised"
277
- const lockDir = lock(dir, key)
278
-
279
- const err = await Flock.withLock(
280
- key,
281
- async () => {
282
- await fs.rm(lockDir, {
283
- recursive: true,
284
- force: true,
285
- })
286
- },
287
- {
288
- dir,
289
- staleMs: 1_000,
290
- timeoutMs: 3_000,
291
- },
292
- ).catch((err) => err)
293
-
294
- expect(err).toBeInstanceOf(Error)
295
- if (!(err instanceof Error)) throw err
296
- expect(err.message).toContain("compromised")
297
-
298
- let hit = false
299
- await Flock.withLock(
300
- key,
301
- async () => {
302
- hit = true
303
- },
304
- {
305
- dir,
306
- staleMs: 200,
307
- timeoutMs: 3_000,
308
- },
309
- )
310
- expect(hit).toBe(true)
311
- })
312
-
313
- test("writes owner metadata while lock is held", async () => {
314
- await using tmp = await tmpdir()
315
- const dir = path.join(tmp.path, "locks")
316
- const key = "flock:meta"
317
- const file = path.join(lock(dir, key), "meta.json")
318
-
319
- await Flock.withLock(
320
- key,
321
- async () => {
322
- const json = await readJson<{
323
- token?: unknown
324
- pid?: unknown
325
- hostname?: unknown
326
- createdAt?: unknown
327
- }>(file)
328
-
329
- expect(typeof json.token).toBe("string")
330
- expect(typeof json.pid).toBe("number")
331
- expect(typeof json.hostname).toBe("string")
332
- expect(typeof json.createdAt).toBe("string")
333
- },
334
- {
335
- dir,
336
- staleMs: 1_000,
337
- timeoutMs: 3_000,
338
- },
339
- )
340
- })
341
-
342
- test("supports acquire with await using", async () => {
343
- await using tmp = await tmpdir()
344
- const dir = path.join(tmp.path, "locks")
345
- const key = "flock:acquire"
346
- const lockDir = lock(dir, key)
347
-
348
- {
349
- await using _ = await Flock.acquire(key, {
350
- dir,
351
- staleMs: 1_000,
352
- timeoutMs: 3_000,
353
- })
354
- expect(await exists(lockDir)).toBe(true)
355
- }
356
-
357
- expect(await exists(lockDir)).toBe(false)
358
- })
359
-
360
- test("refuses token mismatch release and recovers from stale", async () => {
361
- await using tmp = await tmpdir()
362
- const dir = path.join(tmp.path, "locks")
363
- const key = "flock:token"
364
- const lockDir = lock(dir, key)
365
- const meta = path.join(lockDir, "meta.json")
366
-
367
- const err = await Flock.withLock(
368
- key,
369
- async () => {
370
- const json = await readJson<{ token?: string }>(meta)
371
- json.token = "tampered"
372
- await fs.writeFile(meta, JSON.stringify(json, null, 2))
373
- },
374
- {
375
- dir,
376
- staleMs: 500,
377
- timeoutMs: 3_000,
378
- },
379
- ).catch((err) => err)
380
-
381
- expect(err).toBeInstanceOf(Error)
382
- if (!(err instanceof Error)) throw err
383
- expect(err.message).toContain("token mismatch")
384
- expect(await exists(lockDir)).toBe(true)
385
-
386
- let hit = false
387
- await Flock.withLock(
388
- key,
389
- async () => {
390
- hit = true
391
- },
392
- {
393
- dir,
394
- staleMs: 500,
395
- timeoutMs: 6_000,
396
- },
397
- )
398
- expect(hit).toBe(true)
399
- })
400
-
401
- test("fails clearly on unwritable lock roots", async () => {
402
- if (process.platform === "win32") return
403
-
404
- await using tmp = await tmpdir()
405
- const dir = path.join(tmp.path, "locks")
406
- const key = "flock:perm"
407
-
408
- await fs.mkdir(dir, { recursive: true })
409
- await fs.chmod(dir, 0o500)
410
-
411
- try {
412
- const err = await Flock.withLock(key, async () => {}, {
413
- dir,
414
- staleMs: 100,
415
- timeoutMs: 500,
416
- }).catch((err) => err)
417
-
418
- expect(err).toBeInstanceOf(Error)
419
- if (!(err instanceof Error)) throw err
420
- const text = err.message
421
- expect(text.includes("EACCES") || text.includes("EPERM")).toBe(true)
422
- } finally {
423
- await fs.chmod(dir, 0o700)
424
- }
425
- })
426
- })