octocode-ai-shared 5.0.0

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,389 @@
1
+ import { describe, expect } from "bun:test"
2
+ import { spawn } from "child_process"
3
+ import fs from "fs/promises"
4
+ import path from "path"
5
+ import os from "os"
6
+ import { Cause, Effect, Exit, Layer } from "effect"
7
+ import { testEffect } from "../lib/effect"
8
+ import { AppFileSystem } from "octocode-ai-shared/filesystem"
9
+ import { EffectFlock } from "octocode-ai-shared/util/effect-flock"
10
+ import { Global } from "octocode-ai-shared/global"
11
+ import { Hash } from "octocode-ai-shared/util/hash"
12
+
13
+ function lock(dir: string, key: string) {
14
+ return path.join(dir, Hash.fast(key) + ".lock")
15
+ }
16
+
17
+ function sleep(ms: number) {
18
+ return new Promise<void>((resolve) => setTimeout(resolve, ms))
19
+ }
20
+
21
+ async function exists(file: string) {
22
+ return fs
23
+ .stat(file)
24
+ .then(() => true)
25
+ .catch(() => false)
26
+ }
27
+
28
+ async function readJson<T>(p: string): Promise<T> {
29
+ return JSON.parse(await fs.readFile(p, "utf8"))
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Worker subprocess helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ type Msg = {
37
+ key: string
38
+ dir: string
39
+ holdMs?: number
40
+ ready?: string
41
+ active?: string
42
+ done?: string
43
+ }
44
+
45
+ const root = path.join(import.meta.dir, "../..")
46
+ const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts")
47
+
48
+ function run(msg: Msg) {
49
+ return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
50
+ const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root })
51
+ const stdout: Buffer[] = []
52
+ const stderr: Buffer[] = []
53
+ proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
54
+ proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
55
+ proc.on("close", (code) => {
56
+ resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) })
57
+ })
58
+ })
59
+ }
60
+
61
+ function spawnWorker(msg: Msg) {
62
+ return spawn(process.execPath, [worker, JSON.stringify(msg)], {
63
+ cwd: root,
64
+ stdio: ["ignore", "pipe", "pipe"],
65
+ })
66
+ }
67
+
68
+ function stopWorker(proc: ReturnType<typeof spawnWorker>) {
69
+ if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve()
70
+ if (process.platform !== "win32" || !proc.pid) {
71
+ proc.kill()
72
+ return Promise.resolve()
73
+ }
74
+ return new Promise<void>((resolve) => {
75
+ const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
76
+ killProc.on("close", () => {
77
+ proc.kill()
78
+ resolve()
79
+ })
80
+ })
81
+ }
82
+
83
+ async function waitForFile(file: string, timeout = 3_000) {
84
+ const stop = Date.now() + timeout
85
+ while (Date.now() < stop) {
86
+ if (await exists(file)) return
87
+ await sleep(20)
88
+ }
89
+ throw new Error(`Timed out waiting for file: ${file}`)
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Test layer
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const testGlobal = Layer.succeed(
97
+ Global.Service,
98
+ Global.Service.of({
99
+ home: os.homedir(),
100
+ data: os.tmpdir(),
101
+ cache: os.tmpdir(),
102
+ config: os.tmpdir(),
103
+ state: os.tmpdir(),
104
+ bin: os.tmpdir(),
105
+ log: os.tmpdir(),
106
+ }),
107
+ )
108
+
109
+ const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Tests
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe("util.effect-flock", () => {
116
+ const it = testEffect(testLayer)
117
+
118
+ it.live(
119
+ "acquire and release via scoped Effect",
120
+ Effect.gen(function* () {
121
+ const flock = yield* EffectFlock.Service
122
+ const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
123
+ const dir = path.join(tmp, "locks")
124
+ const lockDir = lock(dir, "eflock:acquire")
125
+
126
+ yield* Effect.scoped(flock.acquire("eflock:acquire", dir))
127
+
128
+ expect(yield* Effect.promise(() => exists(lockDir))).toBe(false)
129
+ yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
130
+ }),
131
+ )
132
+
133
+ it.live(
134
+ "withLock data-first",
135
+ Effect.gen(function* () {
136
+ const flock = yield* EffectFlock.Service
137
+ const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
138
+ const dir = path.join(tmp, "locks")
139
+
140
+ let hit = false
141
+ yield* flock.withLock(
142
+ Effect.sync(() => {
143
+ hit = true
144
+ }),
145
+ "eflock:df",
146
+ dir,
147
+ )
148
+ expect(hit).toBe(true)
149
+ yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
150
+ }),
151
+ )
152
+
153
+ it.live(
154
+ "withLock pipeable",
155
+ Effect.gen(function* () {
156
+ const flock = yield* EffectFlock.Service
157
+ const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
158
+ const dir = path.join(tmp, "locks")
159
+
160
+ let hit = false
161
+ yield* Effect.sync(() => {
162
+ hit = true
163
+ }).pipe(flock.withLock("eflock:pipe", dir))
164
+ expect(hit).toBe(true)
165
+ yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
166
+ }),
167
+ )
168
+
169
+ it.live(
170
+ "writes owner metadata",
171
+ Effect.gen(function* () {
172
+ const flock = yield* EffectFlock.Service
173
+ const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
174
+ const dir = path.join(tmp, "locks")
175
+ const key = "eflock:meta"
176
+ const file = path.join(lock(dir, key), "meta.json")
177
+
178
+ yield* Effect.scoped(
179
+ Effect.gen(function* () {
180
+ yield* flock.acquire(key, dir)
181
+ const json = yield* Effect.promise(() =>
182
+ readJson<{ token?: unknown; pid?: unknown; hostname?: unknown; createdAt?: unknown }>(file),
183
+ )
184
+ expect(typeof json.token).toBe("string")
185
+ expect(typeof json.pid).toBe("number")
186
+ expect(typeof json.hostname).toBe("string")
187
+ expect(typeof json.createdAt).toBe("string")
188
+ }),
189
+ )
190
+ yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
191
+ }),
192
+ )
193
+
194
+ it.live(
195
+ "breaks stale lock dirs",
196
+ Effect.gen(function* () {
197
+ const flock = yield* EffectFlock.Service
198
+ const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
199
+ const dir = path.join(tmp, "locks")
200
+ const key = "eflock:stale"
201
+ const lockDir = lock(dir, key)
202
+
203
+ yield* Effect.promise(async () => {
204
+ await fs.mkdir(lockDir, { recursive: true })
205
+ const old = new Date(Date.now() - 120_000)
206
+ await fs.utimes(lockDir, old, old)
207
+ })
208
+
209
+ let hit = false
210
+ yield* flock.withLock(
211
+ Effect.sync(() => {
212
+ hit = true
213
+ }),
214
+ key,
215
+ dir,
216
+ )
217
+ expect(hit).toBe(true)
218
+ yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
219
+ }),
220
+ )
221
+
222
+ it.live(
223
+ "recovers from stale breaker",
224
+ Effect.gen(function* () {
225
+ const flock = yield* EffectFlock.Service
226
+ const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
227
+ const dir = path.join(tmp, "locks")
228
+ const key = "eflock:stale-breaker"
229
+ const lockDir = lock(dir, key)
230
+ const breaker = lockDir + ".breaker"
231
+
232
+ yield* Effect.promise(async () => {
233
+ await fs.mkdir(lockDir, { recursive: true })
234
+ await fs.mkdir(breaker)
235
+ const old = new Date(Date.now() - 120_000)
236
+ await fs.utimes(lockDir, old, old)
237
+ await fs.utimes(breaker, old, old)
238
+ })
239
+
240
+ let hit = false
241
+ yield* flock.withLock(
242
+ Effect.sync(() => {
243
+ hit = true
244
+ }),
245
+ key,
246
+ dir,
247
+ )
248
+ expect(hit).toBe(true)
249
+ expect(yield* Effect.promise(() => exists(breaker))).toBe(false)
250
+ yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
251
+ }),
252
+ )
253
+
254
+ it.live(
255
+ "detects compromise when lock dir removed",
256
+ Effect.gen(function* () {
257
+ const flock = yield* EffectFlock.Service
258
+ const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
259
+ const dir = path.join(tmp, "locks")
260
+ const key = "eflock:compromised"
261
+ const lockDir = lock(dir, key)
262
+
263
+ const result = yield* flock
264
+ .withLock(
265
+ Effect.promise(() => fs.rm(lockDir, { recursive: true, force: true })),
266
+ key,
267
+ dir,
268
+ )
269
+ .pipe(Effect.exit)
270
+
271
+ expect(Exit.isFailure(result)).toBe(true)
272
+ expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing")
273
+ yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
274
+ }),
275
+ )
276
+
277
+ it.live(
278
+ "detects token mismatch",
279
+ Effect.gen(function* () {
280
+ const flock = yield* EffectFlock.Service
281
+ const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
282
+ const dir = path.join(tmp, "locks")
283
+ const key = "eflock:token"
284
+ const lockDir = lock(dir, key)
285
+ const meta = path.join(lockDir, "meta.json")
286
+
287
+ const result = yield* flock
288
+ .withLock(
289
+ Effect.promise(async () => {
290
+ const json = await readJson<{ token?: string }>(meta)
291
+ json.token = "tampered"
292
+ await fs.writeFile(meta, JSON.stringify(json, null, 2))
293
+ }),
294
+ key,
295
+ dir,
296
+ )
297
+ .pipe(Effect.exit)
298
+
299
+ expect(Exit.isFailure(result)).toBe(true)
300
+ expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch")
301
+ expect(yield* Effect.promise(() => exists(lockDir))).toBe(true)
302
+ yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
303
+ }),
304
+ )
305
+
306
+ it.live(
307
+ "fails on unwritable lock roots",
308
+ Effect.gen(function* () {
309
+ if (process.platform === "win32") return
310
+ const flock = yield* EffectFlock.Service
311
+ const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
312
+ const dir = path.join(tmp, "locks")
313
+
314
+ yield* Effect.promise(async () => {
315
+ await fs.mkdir(dir, { recursive: true })
316
+ await fs.chmod(dir, 0o500)
317
+ })
318
+
319
+ const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit)
320
+ // oxlint-disable-next-line no-base-to-string -- Exit has a useful toString for test assertions
321
+ expect(String(result)).toContain("PermissionDenied")
322
+ yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true })))
323
+ }),
324
+ )
325
+
326
+ it.live(
327
+ "enforces mutual exclusion under process contention",
328
+ () =>
329
+ Effect.promise(async () => {
330
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-stress-"))
331
+ const dir = path.join(tmp, "locks")
332
+ const done = path.join(tmp, "done.log")
333
+ const active = path.join(tmp, "active")
334
+ const n = 16
335
+
336
+ try {
337
+ const out = await Promise.all(
338
+ Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })),
339
+ )
340
+
341
+ expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
342
+ expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
343
+
344
+ const lines = (await fs.readFile(done, "utf8"))
345
+ .split("\n")
346
+ .map((x) => x.trim())
347
+ .filter(Boolean)
348
+ expect(lines.length).toBe(n)
349
+ } finally {
350
+ await fs.rm(tmp, { recursive: true, force: true })
351
+ }
352
+ }),
353
+ 60_000,
354
+ )
355
+
356
+ it.live(
357
+ "recovers after a crashed lock owner",
358
+ () =>
359
+ Effect.promise(async () => {
360
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-"))
361
+ const dir = path.join(tmp, "locks")
362
+ const ready = path.join(tmp, "ready")
363
+
364
+ const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 })
365
+
366
+ try {
367
+ await waitForFile(ready, 5_000)
368
+ await stopWorker(proc)
369
+ await new Promise((resolve) => proc.on("close", resolve))
370
+
371
+ // Backdate lock files so they're past STALE_MS (60s)
372
+ const lockDir = lock(dir, "eflock:crash")
373
+ const old = new Date(Date.now() - 120_000)
374
+ await fs.utimes(lockDir, old, old).catch(() => {})
375
+ await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {})
376
+ await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {})
377
+
378
+ const done = path.join(tmp, "done.log")
379
+ const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 })
380
+ expect(result.code).toBe(0)
381
+ expect(result.stderr.toString()).toBe("")
382
+ } finally {
383
+ await stopWorker(proc).catch(() => {})
384
+ await fs.rm(tmp, { recursive: true, force: true })
385
+ }
386
+ }),
387
+ 30_000,
388
+ )
389
+ })