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.
- package/package.json +42 -0
- package/src/filesystem.ts +236 -0
- package/src/global.ts +84 -0
- package/src/types.d.ts +46 -0
- package/src/util/array.ts +10 -0
- package/src/util/binary.ts +41 -0
- package/src/util/effect-flock.ts +283 -0
- package/src/util/encode.ts +51 -0
- package/src/util/error.ts +60 -0
- package/src/util/flock.ts +358 -0
- package/src/util/fn.ts +11 -0
- package/src/util/glob.ts +34 -0
- package/src/util/hash.ts +7 -0
- package/src/util/identifier.ts +53 -0
- package/src/util/iife.ts +3 -0
- package/src/util/lazy.ts +11 -0
- package/src/util/module.ts +10 -0
- package/src/util/path.ts +37 -0
- package/src/util/retry.ts +42 -0
- package/src/util/slug.ts +74 -0
- package/test/filesystem/filesystem.test.ts +338 -0
- package/test/fixture/effect-flock-worker.ts +63 -0
- package/test/fixture/flock-worker.ts +72 -0
- package/test/global.test.ts +54 -0
- package/test/lib/effect.ts +53 -0
- package/test/util/effect-flock.test.ts +389 -0
- package/test/util/flock.test.ts +426 -0
- package/test/util/identifier.test.ts +30 -0
- package/tsconfig.json +14 -0
|
@@ -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
|
+
})
|