1
0

promise-service.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  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. import { accelerate, waitForExit } from "./fixture/service-timing"
  7. const fixture = join(import.meta.dir, "fixture/service.ts")
  8. const ensure = accelerate(Service.ensure)
  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("discovers a registered service", async () => {
  17. const registration = await setup("graceful")
  18. expect(await Service.discover({ file: registration, version: "test" })).toEqual(
  19. expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
  20. )
  21. expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
  22. })
  23. test("discovers a compatible registered service", async () => {
  24. const registration = await setup("compatible")
  25. expect(await Service.discover({ file: registration, version: "2.1.0" })).toBeUndefined()
  26. expect(await Service.discover({ file: registration, version: "2.1.0-next.1" })).toEqual(
  27. expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
  28. )
  29. expect(await Service.discover({ file: registration, version: (version) => version.startsWith("2.") })).toEqual(
  30. expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
  31. )
  32. expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
  33. })
  34. test("ensures a missing service with native promises", async () => {
  35. const directory = await temp()
  36. const registration = join(directory, "service.json")
  37. const starts: EnsureReason[] = []
  38. const endpoint = await ensure({
  39. file: registration,
  40. version: "test",
  41. command: [process.execPath, fixture, registration, "coordinated"],
  42. onStart: (reason) => starts.push(reason),
  43. })
  44. const info = await Bun.file(registration).json()
  45. try {
  46. expect(endpoint.url).toBe(info.url)
  47. expect(starts).toEqual(["missing"])
  48. } finally {
  49. process.kill(info.pid, "SIGTERM")
  50. await waitForExit(info.pid)
  51. }
  52. })
  53. test("adds configured environment variables with native promises", async () => {
  54. const directory = await temp()
  55. const registration = join(directory, "service.json")
  56. const endpoint = await ensure({
  57. file: registration,
  58. version: "test",
  59. command: [process.execPath, fixture, registration, "environment"],
  60. env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
  61. })
  62. const info = await Bun.file(registration).json()
  63. try {
  64. expect(endpoint.url).toBe(info.url)
  65. expect(await Bun.file(registration + ".environment").text()).toBe("configured")
  66. } finally {
  67. process.kill(info.pid, "SIGTERM")
  68. await waitForExit(info.pid)
  69. }
  70. })
  71. test("waits for a live contender when another native contender fails", async () => {
  72. const directory = await temp()
  73. const registration = join(directory, "service.json")
  74. const endpoint = await ensure({
  75. file: registration,
  76. version: "test",
  77. command: [process.execPath, fixture, registration, "coordinated-failed-loser", "300"],
  78. })
  79. const info = await Bun.file(registration).json()
  80. try {
  81. expect(endpoint.url).toBe(info.url)
  82. } finally {
  83. process.kill(info.pid, "SIGTERM")
  84. await waitForExit(info.pid)
  85. }
  86. })
  87. test("reports a failed registered service", async () => {
  88. const registration = await setup("failed-owner")
  89. await expect(ensure({ file: registration, version: "test", command: [] })).rejects.toThrow(
  90. "Background service failed to start",
  91. )
  92. })
  93. test("reports a bounded contender stderr tail with native promises", async () => {
  94. const directory = await temp()
  95. const registration = join(directory, "service.json")
  96. const error = await Service.ensure({
  97. file: registration,
  98. version: "test",
  99. command: [process.execPath, fixture, registration, "stderr-failed"],
  100. }).catch((error: unknown) => error)
  101. expect(error).toBeInstanceOf(Error)
  102. if (!(error instanceof Error)) throw error
  103. expect(error.message).toContain("actionable startup failure")
  104. expect(error.message.length).toBeLessThan(9_000)
  105. }, 10_000)
  106. test("evicts an unresponsive registered service before starting its replacement", async () => {
  107. const directory = await temp()
  108. const registration = join(directory, "service.json")
  109. const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
  110. stdout: "ignore",
  111. stderr: "inherit",
  112. })
  113. processes.push(existing)
  114. await waitForFile(registration)
  115. const original = await Bun.file(registration).json()
  116. const endpoint = await ensure({
  117. file: registration,
  118. version: "test",
  119. command: [process.execPath, fixture, registration, "delayed", "10"],
  120. })
  121. const replacement = await Bun.file(registration).json()
  122. expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
  123. expect(await existing.exited).toBe(0)
  124. expect(replacement.pid).not.toBe(original.pid)
  125. expect(endpoint.url).toBe(replacement.url)
  126. process.kill(replacement.pid, "SIGTERM")
  127. await waitForExit(replacement.pid)
  128. })
  129. test("signals the registered service process", async () => {
  130. const registration = await setup("graceful")
  131. await Service.stop({ file: registration })
  132. expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
  133. expect(await Bun.file(registration).exists()).toBe(false)
  134. })
  135. async function setup(mode: string) {
  136. const directory = await temp()
  137. const registration = join(directory, "service.json")
  138. processes.push(Bun.spawn([process.execPath, fixture, registration, mode], { stdout: "ignore", stderr: "inherit" }))
  139. await waitForFile(registration)
  140. return registration
  141. }
  142. async function temp() {
  143. const directory = await mkdtemp(join(tmpdir(), "opencode-promise-service-"))
  144. directories.push(directory)
  145. return directory
  146. }
  147. async function waitForFile(file: string) {
  148. for (let attempt = 0; attempt < 600; attempt++) {
  149. if (await Bun.file(file).exists()) return
  150. await Bun.sleep(5)
  151. }
  152. throw new Error(`Timed out waiting for ${file}`)
  153. }