effect-flock.test.ts 13 KB

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