service.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { afterEach, expect, test } from "bun:test"
  3. import { Effect } from "effect"
  4. import { mkdtemp, rm, writeFile } from "node:fs/promises"
  5. import { tmpdir } from "node:os"
  6. import { join } from "node:path"
  7. import { Service, type EnsureReason } from "../src/effect/service"
  8. const fixture = join(import.meta.dir, "fixture/service.ts")
  9. const processes: Bun.Subprocess[] = []
  10. const directories: string[] = []
  11. afterEach(async () => {
  12. processes.forEach((process) => process.kill("SIGTERM"))
  13. await Promise.all(processes.splice(0).map((process) => process.exited))
  14. await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
  15. })
  16. test("a concurrent same-version start cannot invalidate a resolved endpoint", async () => {
  17. const directory = await temp()
  18. const registration = join(directory, "service.json")
  19. spawn(registration, "modern")
  20. await waitForFile(registration)
  21. const original = await Bun.file(registration).json()
  22. const starts: EnsureReason[] = []
  23. const first = run(
  24. Service.ensure({
  25. file: registration,
  26. version: "test",
  27. command: [],
  28. onStart: (reason) => starts.push(reason),
  29. }),
  30. )
  31. await waitForFile(registration + ".first-request")
  32. const resolved = await run(Service.ensure({ file: registration, version: "test" }))
  33. expect(resolved.url).toBe(original.url)
  34. await writeFile(registration + ".release", "")
  35. await first
  36. expect(starts).toEqual([])
  37. expect(await Bun.file(registration).json()).toEqual(original)
  38. expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
  39. })
  40. test("waits for a registered service to finish starting", async () => {
  41. const directory = await temp()
  42. const registration = join(directory, "service.json")
  43. const process = spawn(registration, "starting")
  44. await waitForFile(registration)
  45. const result = run(Service.ensure({ file: registration, version: "test", command: [] }))
  46. await Bun.sleep(500)
  47. expect(process.exitCode).toBe(null)
  48. await writeFile(registration + ".release", "")
  49. expect((await result).url).toBe((await Bun.file(registration).json()).url)
  50. })
  51. test("reports a failed registered service without spawning", async () => {
  52. const directory = await temp()
  53. const registration = join(directory, "service.json")
  54. const process = spawn(registration, "failed-owner")
  55. await waitForFile(registration)
  56. await expect(run(Service.ensure({ file: registration, version: "test", command: [] }))).rejects.toThrow(
  57. "Background service failed to start",
  58. )
  59. expect(process.exitCode).toBe(null)
  60. })
  61. test("evicts an unresponsive registered service before starting its replacement", async () => {
  62. const directory = await temp()
  63. const registration = join(directory, "service.json")
  64. const existing = spawn(registration, "hanging")
  65. await waitForFile(registration)
  66. const original = await Bun.file(registration).json()
  67. const endpoint = await run(
  68. Service.ensure({
  69. file: registration,
  70. version: "test",
  71. command: [process.execPath, fixture, registration, "delayed", "10"],
  72. }),
  73. )
  74. const replacement = await Bun.file(registration).json()
  75. expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
  76. expect(await existing.exited).toBe(0)
  77. expect(replacement.pid).not.toBe(original.pid)
  78. expect(endpoint.url).toBe(replacement.url)
  79. expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
  80. process.kill(replacement.pid, "SIGTERM")
  81. }, 20_000)
  82. test("requests graceful stop of the exact service instance", async () => {
  83. const directory = await temp()
  84. const registration = join(directory, "service.json")
  85. const process = spawn(registration, "graceful")
  86. await waitForFile(registration)
  87. const info = await Bun.file(registration).json()
  88. await run(Service.stop({ file: registration }))
  89. await process.exited
  90. expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
  91. })
  92. test("does not spawn contenders while an incompatible service rejects replacement", async () => {
  93. const directory = await temp()
  94. const registration = join(directory, "service.json")
  95. const contender = join(directory, "contender.json")
  96. const existing = spawn(registration, "reject-stop")
  97. await waitForFile(registration)
  98. const controller = new AbortController()
  99. const starting = Effect.runPromise(
  100. Service.ensure({
  101. file: registration,
  102. version: "test",
  103. command: [process.execPath, fixture, contender, "record-start"],
  104. }).pipe(Effect.provide(NodeFileSystem.layer)),
  105. { signal: controller.signal },
  106. )
  107. await waitForFile(registration + ".stop-attempt")
  108. await Bun.sleep(500)
  109. controller.abort()
  110. await starting.catch(() => undefined)
  111. expect(await Bun.file(contender + ".started").exists()).toBe(false)
  112. expect(existing.exitCode).toBe(null)
  113. })
  114. test("a legacy health response is still replaced", async () => {
  115. const directory = await temp()
  116. const registration = join(directory, "service.json")
  117. const existing = spawn(registration, "legacy")
  118. await waitForFile(registration)
  119. const starts: EnsureReason[] = []
  120. const result = run(Service.ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
  121. await expect(result).rejects.toThrow("Missing service command")
  122. expect(starts).toEqual(["version-mismatch"])
  123. await existing.exited
  124. }, 10_000)
  125. test("waits for a slow winner while bounding lock probes", async () => {
  126. const directory = await temp()
  127. const registration = join(directory, "service.json")
  128. const endpoint = await run(
  129. Service.ensure({
  130. file: registration,
  131. version: "test",
  132. command: [process.execPath, fixture, registration, "coordinated"],
  133. }),
  134. )
  135. const info = await Bun.file(registration).json()
  136. try {
  137. expect(endpoint.url).toBe(info.url)
  138. expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: info.pid })
  139. expect((await Bun.file(registration + ".starts").text()).trim().split("\n")).toHaveLength(2)
  140. } finally {
  141. process.kill(info.pid, "SIGTERM")
  142. }
  143. }, 15_000)
  144. test("waits for a live contender when another contender fails", async () => {
  145. const directory = await temp()
  146. const registration = join(directory, "service.json")
  147. const endpoint = await run(
  148. Service.ensure({
  149. file: registration,
  150. version: "test",
  151. command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
  152. }),
  153. )
  154. const info = await Bun.file(registration).json()
  155. try {
  156. expect(endpoint.url).toBe(info.url)
  157. } finally {
  158. process.kill(info.pid, "SIGTERM")
  159. }
  160. }, 15_000)
  161. test("reports a contender that fails to start", async () => {
  162. const directory = await temp()
  163. const registration = join(directory, "service.json")
  164. await expect(
  165. run(
  166. Service.ensure({
  167. file: registration,
  168. version: "test",
  169. command: [process.execPath, fixture, registration, "failed"],
  170. }),
  171. ),
  172. ).rejects.toThrow("Server process exited with code 1")
  173. }, 10_000)
  174. test("reports a contender terminated by a signal", async () => {
  175. const directory = await temp()
  176. const registration = join(directory, "service.json")
  177. await expect(
  178. run(
  179. Service.ensure({
  180. file: registration,
  181. version: "test",
  182. command: [process.execPath, fixture, registration, "signal"],
  183. }),
  184. ),
  185. ).rejects.toThrow(/Server process (terminated by|exited with code)/)
  186. }, 10_000)
  187. test("reports a slow contender that eventually fails", async () => {
  188. const directory = await temp()
  189. const registration = join(directory, "service.json")
  190. await expect(
  191. run(
  192. Service.ensure({
  193. file: registration,
  194. version: "test",
  195. command: [process.execPath, fixture, registration, "delayed-failed", "8000"],
  196. }),
  197. ),
  198. ).rejects.toThrow("Server process exited with code 1")
  199. }, 15_000)
  200. test("replaces an incompatible owner that appears during startup", async () => {
  201. const directory = await temp()
  202. const registration = join(directory, "service.json")
  203. const starting = run(
  204. Service.ensure({
  205. file: registration,
  206. version: "test",
  207. command: [process.execPath, fixture, registration, "delayed", "8000"],
  208. }),
  209. )
  210. await Bun.sleep(1_000)
  211. const old = spawn(registration, "old")
  212. await waitForFile(registration)
  213. const endpoint = await starting
  214. const info = await Bun.file(registration).json()
  215. try {
  216. expect(endpoint.url).toBe(info.url)
  217. expect(info.version).toBe("test")
  218. await old.exited
  219. } finally {
  220. process.kill(info.pid, "SIGTERM")
  221. }
  222. }, 20_000)
  223. function run<A, E>(effect: Effect.Effect<A, E>) {
  224. return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
  225. }
  226. function spawn(registration: string, mode: string, ...args: string[]) {
  227. const subprocess = Bun.spawn([process.execPath, fixture, registration, mode, ...args], {
  228. stdout: "ignore",
  229. stderr: "inherit",
  230. })
  231. processes.push(subprocess)
  232. return subprocess
  233. }
  234. async function temp() {
  235. const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
  236. directories.push(directory)
  237. return directory
  238. }
  239. async function waitForFile(file: string) {
  240. for (let attempt = 0; attempt < 600; attempt++) {
  241. if (await Bun.file(file).exists()) return
  242. await Bun.sleep(5)
  243. }
  244. throw new Error(`Timed out waiting for ${file}`)
  245. }
  246. async function health(url: string) {
  247. return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
  248. }