effect-flock.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  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/core/effect/layer-node"
  10. import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
  11. import { Global } from "@opencode-ai/core/global"
  12. import { Hash } from "@opencode-ai/core/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. "withLock data-first",
  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. let hit = false
  121. yield* flock.withLock(
  122. Effect.sync(() => {
  123. hit = true
  124. }),
  125. "eflock:df",
  126. dir,
  127. )
  128. expect(hit).toBe(true)
  129. yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
  130. }),
  131. )
  132. it.live(
  133. "withLock pipeable",
  134. Effect.gen(function* () {
  135. const flock = yield* EffectFlock.Service
  136. const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
  137. const dir = path.join(tmp, "locks")
  138. let hit = false
  139. yield* Effect.sync(() => {
  140. hit = true
  141. }).pipe(flock.withLock("eflock:pipe", dir))
  142. expect(hit).toBe(true)
  143. yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
  144. }),
  145. )
  146. it.live(
  147. "writes owner metadata",
  148. Effect.gen(function* () {
  149. const flock = yield* EffectFlock.Service
  150. const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
  151. const dir = path.join(tmp, "locks")
  152. const key = "eflock:meta"
  153. const file = path.join(lock(dir, key), "meta.json")
  154. yield* Effect.scoped(
  155. Effect.gen(function* () {
  156. yield* flock.acquire(key, dir)
  157. const json = yield* Effect.promise(() =>
  158. readJson<{ token?: unknown; pid?: unknown; hostname?: unknown; createdAt?: unknown }>(file),
  159. )
  160. expect(typeof json.token).toBe("string")
  161. expect(typeof json.pid).toBe("number")
  162. expect(typeof json.hostname).toBe("string")
  163. expect(typeof json.createdAt).toBe("string")
  164. }),
  165. )
  166. yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
  167. }),
  168. )
  169. it.live(
  170. "breaks stale lock dirs",
  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:stale"
  176. const lockDir = lock(dir, key)
  177. yield* Effect.promise(async () => {
  178. await fs.mkdir(lockDir, { recursive: true })
  179. const old = new Date(Date.now() - 120_000)
  180. await fs.utimes(lockDir, old, old)
  181. })
  182. let hit = false
  183. yield* flock.withLock(
  184. Effect.sync(() => {
  185. hit = true
  186. }),
  187. key,
  188. dir,
  189. )
  190. expect(hit).toBe(true)
  191. yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
  192. }),
  193. )
  194. it.live(
  195. "recovers from stale breaker",
  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-breaker"
  201. const lockDir = lock(dir, key)
  202. const breaker = lockDir + ".breaker"
  203. yield* Effect.promise(async () => {
  204. await fs.mkdir(lockDir, { recursive: true })
  205. await fs.mkdir(breaker)
  206. const old = new Date(Date.now() - 120_000)
  207. await fs.utimes(lockDir, old, old)
  208. await fs.utimes(breaker, old, old)
  209. })
  210. let hit = false
  211. yield* flock.withLock(
  212. Effect.sync(() => {
  213. hit = true
  214. }),
  215. key,
  216. dir,
  217. )
  218. expect(hit).toBe(true)
  219. expect(yield* Effect.promise(() => exists(breaker))).toBe(false)
  220. yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
  221. }),
  222. )
  223. it.live(
  224. "detects compromise when lock dir removed",
  225. Effect.gen(function* () {
  226. const flock = yield* EffectFlock.Service
  227. const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
  228. const dir = path.join(tmp, "locks")
  229. const key = "eflock:compromised"
  230. const lockDir = lock(dir, key)
  231. const result = yield* flock
  232. .withLock(
  233. Effect.promise(() => fs.rm(lockDir, { recursive: true, force: true })),
  234. key,
  235. dir,
  236. )
  237. .pipe(Effect.exit)
  238. expect(Exit.isFailure(result)).toBe(true)
  239. expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing")
  240. yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
  241. }),
  242. )
  243. it.live(
  244. "detects token mismatch",
  245. Effect.gen(function* () {
  246. const flock = yield* EffectFlock.Service
  247. const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
  248. const dir = path.join(tmp, "locks")
  249. const key = "eflock:token"
  250. const lockDir = lock(dir, key)
  251. const meta = path.join(lockDir, "meta.json")
  252. const result = yield* flock
  253. .withLock(
  254. Effect.promise(async () => {
  255. const json = await readJson<{ token?: string }>(meta)
  256. json.token = "tampered"
  257. await fs.writeFile(meta, JSON.stringify(json, null, 2))
  258. }),
  259. key,
  260. dir,
  261. )
  262. .pipe(Effect.exit)
  263. expect(Exit.isFailure(result)).toBe(true)
  264. expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch")
  265. expect(yield* Effect.promise(() => exists(lockDir))).toBe(true)
  266. yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
  267. }),
  268. )
  269. it.live(
  270. "fails on unwritable lock roots",
  271. Effect.gen(function* () {
  272. if (process.platform === "win32") return
  273. const flock = yield* EffectFlock.Service
  274. const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
  275. const dir = path.join(tmp, "locks")
  276. yield* Effect.promise(async () => {
  277. await fs.mkdir(dir, { recursive: true })
  278. await fs.chmod(dir, 0o500)
  279. })
  280. const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit)
  281. // oxlint-disable-next-line no-base-to-string -- Exit has a useful toString for test assertions
  282. expect(String(result)).toContain("PermissionDenied")
  283. yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true })))
  284. }),
  285. )
  286. it.live(
  287. "enforces mutual exclusion under process contention",
  288. () =>
  289. Effect.promise(async () => {
  290. const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-stress-"))
  291. const dir = path.join(tmp, "locks")
  292. const done = path.join(tmp, "done.log")
  293. const active = path.join(tmp, "active")
  294. const n = 16
  295. try {
  296. const out = await Promise.all(
  297. Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })),
  298. )
  299. expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
  300. expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
  301. const lines = (await fs.readFile(done, "utf8"))
  302. .split("\n")
  303. .map((x) => x.trim())
  304. .filter(Boolean)
  305. expect(lines.length).toBe(n)
  306. } finally {
  307. await fs.rm(tmp, { recursive: true, force: true })
  308. }
  309. }),
  310. 60_000,
  311. )
  312. it.live(
  313. "recovers after a crashed lock owner",
  314. () =>
  315. Effect.promise(async () => {
  316. const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-"))
  317. const dir = path.join(tmp, "locks")
  318. const ready = path.join(tmp, "ready")
  319. const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 })
  320. try {
  321. await waitForFile(ready, 5_000)
  322. await stopWorker(proc)
  323. // Backdate lock files so they're past STALE_MS (60s)
  324. const lockDir = lock(dir, "eflock:crash")
  325. const old = new Date(Date.now() - 120_000)
  326. await fs.utimes(lockDir, old, old).catch(() => {})
  327. await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {})
  328. await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {})
  329. const done = path.join(tmp, "done.log")
  330. const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 })
  331. expect(result.code).toBe(0)
  332. expect(result.stderr.toString()).toBe("")
  333. } finally {
  334. await stopWorker(proc).catch(() => {})
  335. await fs.rm(tmp, { recursive: true, force: true })
  336. }
  337. }),
  338. 30_000,
  339. )
  340. })