promise-service.test.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. import { afterEach, expect, test } from "bun:test"
  2. import { mkdtemp, rm } from "node:fs/promises"
  3. import { tmpdir } from "node:os"
  4. import { join } from "node:path"
  5. import { Service, type EnsureReason } from "../src/promise/service"
  6. const fixture = join(import.meta.dir, "fixture/service.ts")
  7. const processes: Bun.Subprocess[] = []
  8. const directories: string[] = []
  9. afterEach(async () => {
  10. processes.forEach((process) => process.kill("SIGTERM"))
  11. await Promise.all(processes.splice(0).map((process) => process.exited))
  12. await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
  13. })
  14. test("discovers a registered service", async () => {
  15. const registration = await setup("graceful")
  16. expect(await Service.discover({ file: registration, version: "test" })).toEqual(
  17. expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
  18. )
  19. expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
  20. })
  21. test("ensures a missing service with native promises", async () => {
  22. const directory = await temp()
  23. const registration = join(directory, "service.json")
  24. const starts: EnsureReason[] = []
  25. const endpoint = await Service.ensure({
  26. file: registration,
  27. version: "test",
  28. command: [process.execPath, fixture, registration, "coordinated"],
  29. onStart: (reason) => starts.push(reason),
  30. })
  31. const info = await Bun.file(registration).json()
  32. try {
  33. expect(endpoint.url).toBe(info.url)
  34. expect(starts).toEqual(["missing"])
  35. } finally {
  36. process.kill(info.pid, "SIGTERM")
  37. await waitForExit(info.pid)
  38. }
  39. }, 15_000)
  40. test("waits for a live contender when another native contender fails", async () => {
  41. const directory = await temp()
  42. const registration = join(directory, "service.json")
  43. const endpoint = await Service.ensure({
  44. file: registration,
  45. version: "test",
  46. command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
  47. })
  48. const info = await Bun.file(registration).json()
  49. try {
  50. expect(endpoint.url).toBe(info.url)
  51. } finally {
  52. process.kill(info.pid, "SIGTERM")
  53. await waitForExit(info.pid)
  54. }
  55. }, 15_000)
  56. test("reports a failed registered service", async () => {
  57. const registration = await setup("failed-owner")
  58. await expect(Service.ensure({ file: registration, version: "test", command: [] })).rejects.toThrow(
  59. "Background service failed to start",
  60. )
  61. })
  62. test("evicts an unresponsive registered service before starting its replacement", async () => {
  63. const directory = await temp()
  64. const registration = join(directory, "service.json")
  65. const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
  66. stdout: "ignore",
  67. stderr: "inherit",
  68. })
  69. processes.push(existing)
  70. await waitForFile(registration)
  71. const original = await Bun.file(registration).json()
  72. const endpoint = await Service.ensure({
  73. file: registration,
  74. version: "test",
  75. command: [process.execPath, fixture, registration, "delayed", "10"],
  76. })
  77. const replacement = await Bun.file(registration).json()
  78. expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
  79. expect(await existing.exited).toBe(0)
  80. expect(replacement.pid).not.toBe(original.pid)
  81. expect(endpoint.url).toBe(replacement.url)
  82. process.kill(replacement.pid, "SIGTERM")
  83. await waitForExit(replacement.pid)
  84. }, 20_000)
  85. test("requests graceful stop of the exact service instance", async () => {
  86. const registration = await setup("graceful")
  87. const info = await Bun.file(registration).json()
  88. await Service.stop({ file: registration })
  89. expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
  90. })
  91. async function setup(mode: string) {
  92. const directory = await temp()
  93. const registration = join(directory, "service.json")
  94. processes.push(Bun.spawn([process.execPath, fixture, registration, mode], { stdout: "ignore", stderr: "inherit" }))
  95. await waitForFile(registration)
  96. return registration
  97. }
  98. async function temp() {
  99. const directory = await mkdtemp(join(tmpdir(), "opencode-promise-service-"))
  100. directories.push(directory)
  101. return directory
  102. }
  103. async function waitForFile(file: string) {
  104. for (let attempt = 0; attempt < 600; attempt++) {
  105. if (await Bun.file(file).exists()) return
  106. await Bun.sleep(5)
  107. }
  108. throw new Error(`Timed out waiting for ${file}`)
  109. }
  110. async function waitForExit(pid: number) {
  111. for (let attempt = 0; attempt < 600; attempt++) {
  112. try {
  113. process.kill(pid, 0)
  114. } catch {
  115. return
  116. }
  117. await Bun.sleep(5)
  118. }
  119. throw new Error(`Timed out waiting for process ${pid}`)
  120. }