promise-service.test.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  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("waits for a live contender when another native contender fails", 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, "coordinated-failed-loser", "300"],
  60. })
  61. const info = await Bun.file(registration).json()
  62. try {
  63. expect(endpoint.url).toBe(info.url)
  64. } finally {
  65. process.kill(info.pid, "SIGTERM")
  66. await waitForExit(info.pid)
  67. }
  68. })
  69. test("reports a failed registered service", async () => {
  70. const registration = await setup("failed-owner")
  71. await expect(ensure({ file: registration, version: "test", command: [] })).rejects.toThrow(
  72. "Background service failed to start",
  73. )
  74. })
  75. test("reports a bounded contender stderr tail with native promises", async () => {
  76. const directory = await temp()
  77. const registration = join(directory, "service.json")
  78. const error = await Service.ensure({
  79. file: registration,
  80. version: "test",
  81. command: [process.execPath, fixture, registration, "stderr-failed"],
  82. }).catch((error: unknown) => error)
  83. expect(error).toBeInstanceOf(Error)
  84. if (!(error instanceof Error)) throw error
  85. expect(error.message).toContain("actionable startup failure")
  86. expect(error.message.length).toBeLessThan(9_000)
  87. }, 10_000)
  88. test("evicts an unresponsive registered service before starting its replacement", async () => {
  89. const directory = await temp()
  90. const registration = join(directory, "service.json")
  91. const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
  92. stdout: "ignore",
  93. stderr: "inherit",
  94. })
  95. processes.push(existing)
  96. await waitForFile(registration)
  97. const original = await Bun.file(registration).json()
  98. const endpoint = await ensure({
  99. file: registration,
  100. version: "test",
  101. command: [process.execPath, fixture, registration, "delayed", "10"],
  102. })
  103. const replacement = await Bun.file(registration).json()
  104. expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
  105. expect(await existing.exited).toBe(0)
  106. expect(replacement.pid).not.toBe(original.pid)
  107. expect(endpoint.url).toBe(replacement.url)
  108. process.kill(replacement.pid, "SIGTERM")
  109. await waitForExit(replacement.pid)
  110. })
  111. test("requests graceful stop of the exact service instance", async () => {
  112. const registration = await setup("graceful")
  113. const info = await Bun.file(registration).json()
  114. await Service.stop({ file: registration })
  115. expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
  116. })
  117. async function setup(mode: string) {
  118. const directory = await temp()
  119. const registration = join(directory, "service.json")
  120. processes.push(Bun.spawn([process.execPath, fixture, registration, mode], { stdout: "ignore", stderr: "inherit" }))
  121. await waitForFile(registration)
  122. return registration
  123. }
  124. async function temp() {
  125. const directory = await mkdtemp(join(tmpdir(), "opencode-promise-service-"))
  126. directories.push(directory)
  127. return directory
  128. }
  129. async function waitForFile(file: string) {
  130. for (let attempt = 0; attempt < 600; attempt++) {
  131. if (await Bun.file(file).exists()) return
  132. await Bun.sleep(5)
  133. }
  134. throw new Error(`Timed out waiting for ${file}`)
  135. }