service.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import { NodeFileSystem } from "@effect/platform-node"
  2. import { Service, type Info } from "@opencode-ai/client/effect/service"
  3. import { Global } from "@opencode-ai/util/global"
  4. import { OPENCODE_VERSION } from "../src/version"
  5. import { expect, test } from "bun:test"
  6. import { Effect, Schema } from "effect"
  7. import fs from "node:fs/promises"
  8. import os from "node:os"
  9. import path from "node:path"
  10. import { ServiceConfig } from "../src/services/service-config"
  11. test("managed service ports are stable per installation channel", () => {
  12. expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
  13. expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
  14. expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
  15. expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
  16. expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
  17. })
  18. test("local channel stores service config with the local service filename", async () => {
  19. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
  20. try {
  21. await Effect.runPromise(
  22. ServiceConfig.set("hostname", "127.0.0.2").pipe(
  23. Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
  24. Effect.provide(NodeFileSystem.layer),
  25. ),
  26. )
  27. expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({
  28. hostname: "127.0.0.2",
  29. })
  30. expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false)
  31. } finally {
  32. await fs.rm(root, { recursive: true, force: true })
  33. }
  34. })
  35. test("service filenames share release channels and identify preview channels", () => {
  36. expect(ServiceConfig.filename("latest")).toBe("service.json")
  37. expect(ServiceConfig.filename("next")).toBe("service.json")
  38. expect(ServiceConfig.filename("local")).toBe("service-local.json")
  39. expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json")
  40. expect(ServiceConfig.filename("preview/a")).toBe("service-preview-a.json")
  41. expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234", "preview-a")).toBe(true)
  42. expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234.2", "preview-a")).toBe(true)
  43. expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false)
  44. expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
  45. })
  46. test("only newer clients replace managed service versions", () => {
  47. expect(ServiceConfig.canReplaceVersion("1.2.3", "1.2.4")).toBe(true)
  48. expect(ServiceConfig.canReplaceVersion("1.2.4", "1.2.3")).toBe(false)
  49. expect(ServiceConfig.canReplaceVersion("1.2.3", "1.2.3")).toBe(false)
  50. expect(ServiceConfig.canReplaceVersion("0.0.0-next-9999", "0.0.0-next-15000")).toBe(true)
  51. expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000", "0.0.0-next-9999")).toBe(false)
  52. expect(ServiceConfig.canReplaceVersion(undefined, "1.2.3")).toBe(true)
  53. })
  54. test("service config migrates from the hashed channel filename", async () => {
  55. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-"))
  56. const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!)
  57. const target = path.join(root, ServiceConfig.filename("preview-a"))
  58. try {
  59. await fs.writeFile(legacy, JSON.stringify({ hostname: "127.0.0.2", port: 4098 }))
  60. await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer)))
  61. expect(await Bun.file(target).json()).toEqual({ hostname: "127.0.0.2", port: 4098 })
  62. await fs.writeFile(target, JSON.stringify({ port: 4099 }))
  63. await Effect.runPromise(ServiceConfig.migrateConfig(legacy, target).pipe(Effect.provide(NodeFileSystem.layer)))
  64. expect(await Bun.file(target).json()).toEqual({ port: 4099 })
  65. } finally {
  66. await fs.rm(root, { recursive: true, force: true })
  67. }
  68. })
  69. test("preview registration migration never moves stable discovery", async () => {
  70. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-"))
  71. const legacy = path.join(root, "service.json")
  72. const target = path.join(root, ServiceConfig.filename("preview-a"))
  73. try {
  74. await fs.writeFile(
  75. legacy,
  76. JSON.stringify({ id: "old-preview", version: "0.0.0-preview-a-1234", url: "http://localhost:4096", pid: 1 }),
  77. )
  78. await Effect.runPromise(
  79. ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe(
  80. Effect.provide(NodeFileSystem.layer),
  81. ),
  82. )
  83. expect(await Bun.file(legacy).exists()).toBe(true)
  84. expect(await Bun.file(target).json()).toMatchObject({ id: "old-preview" })
  85. await fs.rm(target)
  86. await fs.writeFile(legacy, JSON.stringify({ id: "stable", version: "1.2.3", url: "http://localhost:4096", pid: 1 }))
  87. await Effect.runPromise(
  88. ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe(
  89. Effect.provide(NodeFileSystem.layer),
  90. ),
  91. )
  92. expect(await Bun.file(legacy).exists()).toBe(true)
  93. expect(await Bun.file(target).exists()).toBe(false)
  94. await fs.writeFile(
  95. legacy,
  96. JSON.stringify({ id: "old-preview", version: "0.0.0-preview-a-1234", url: "http://localhost:4096", pid: 1 }),
  97. )
  98. await fs.writeFile(target, JSON.stringify({ id: "current-preview" }))
  99. await Effect.runPromise(
  100. ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe(
  101. Effect.provide(NodeFileSystem.layer),
  102. ),
  103. )
  104. expect(await Bun.file(legacy).exists()).toBe(true)
  105. expect(await Bun.file(target).json()).toMatchObject({ id: "current-preview" })
  106. } finally {
  107. await fs.rm(root, { recursive: true, force: true })
  108. }
  109. })
  110. test("managed service writes its registration once", async () => {
  111. const service = await startManagedService("opencode-service-once-")
  112. try {
  113. const before = await fs.stat(service.registration)
  114. await Bun.sleep(6_000)
  115. const after = await fs.stat(service.registration)
  116. expect(after.ino).toBe(before.ino)
  117. expect(after.mtimeMs).toBe(before.mtimeMs)
  118. expect(await Bun.file(service.registration).json()).toEqual(service.info)
  119. } finally {
  120. await stopManagedService(service)
  121. }
  122. }, 30_000)
  123. test("deleting a managed service registration stops its owner", async () => {
  124. const service = await startManagedService("opencode-service-delete-")
  125. try {
  126. await fs.rm(service.registration)
  127. expect(await waitForExit(service.owner)).toBe(true)
  128. expect(await Bun.file(service.registration).exists()).toBe(false)
  129. await expectPortAvailable(service.port)
  130. } finally {
  131. await stopManagedService(service)
  132. }
  133. }, 30_000)
  134. test("deleting a failed service registration stops its owner", async () => {
  135. const service = await startManagedService("opencode-service-failed-delete-", true)
  136. try {
  137. await waitForFailed(service.info)
  138. await fs.rm(service.registration)
  139. expect(await waitForExit(service.owner)).toBe(true)
  140. await expectPortAvailable(service.port)
  141. } finally {
  142. await stopManagedService(service)
  143. }
  144. }, 30_000)
  145. test("corrupting a managed service registration stops its owner", async () => {
  146. const service = await startManagedService("opencode-service-corrupt-")
  147. try {
  148. await fs.writeFile(service.registration, "not-json")
  149. expect(await waitForExit(service.owner)).toBe(true)
  150. expect(await Bun.file(service.registration).text()).toBe("not-json")
  151. await expectPortAvailable(service.port)
  152. } finally {
  153. await stopManagedService(service)
  154. }
  155. }, 30_000)
  156. test("replacing a managed service registration stops its owner and preserves the foreign owner", async () => {
  157. const service = await startManagedService("opencode-service-foreign-")
  158. const foreign = { ...service.info, id: "foreign-owner", pid: process.pid }
  159. try {
  160. await fs.writeFile(service.registration, JSON.stringify(foreign))
  161. expect(await waitForExit(service.owner)).toBe(true)
  162. expect(await Bun.file(service.registration).json()).toEqual(foreign)
  163. await expectPortAvailable(service.port)
  164. } finally {
  165. await stopManagedService(service)
  166. }
  167. }, 30_000)
  168. test("clean managed service shutdown removes its registration", async () => {
  169. const service = await startManagedService("opencode-service-clean-")
  170. try {
  171. await Effect.runPromise(Service.stop({ file: service.registration }).pipe(Effect.provide(NodeFileSystem.layer)))
  172. expect(await waitForExit(service.owner)).toBe(true)
  173. expect(await Bun.file(service.registration).exists()).toBe(false)
  174. } finally {
  175. await stopManagedService(service)
  176. }
  177. }, 30_000)
  178. test("concurrent service processes elect one server", async () => {
  179. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
  180. const database = path.join(root, "opencode.db")
  181. const env = {
  182. ...process.env,
  183. HOME: root,
  184. OPENCODE_DB: database,
  185. OPENCODE_TEST_HOME: root,
  186. XDG_CACHE_HOME: path.join(root, "cache"),
  187. XDG_CONFIG_HOME: path.join(root, "config"),
  188. XDG_DATA_HOME: path.join(root, "data"),
  189. XDG_STATE_HOME: path.join(root, "state"),
  190. }
  191. const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
  192. const registration = path.join(root, "state", "opencode", "service-local.json")
  193. const port = await availablePort()
  194. const config = path.join(root, "config", "opencode", "service-local.json")
  195. await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
  196. await fs.writeFile(config, JSON.stringify({ port }))
  197. const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "pipe" }))
  198. try {
  199. const info = await waitForInfo(registration)
  200. const winner = processes.find((process) => process.pid === info.pid)
  201. const losers = processes.filter((process) => process.pid !== info.pid)
  202. const exited = await Promise.all(
  203. losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(60_000).then(() => false)])),
  204. )
  205. expect(exited).toEqual(losers.map(() => true))
  206. const errors = await Promise.all(
  207. losers.map(
  208. async (process) => (await new Response(process.stdout).text()) + (await new Response(process.stderr).text()),
  209. ),
  210. )
  211. expect(
  212. losers.map((process) => process.exitCode),
  213. errors.filter(Boolean).join("\n"),
  214. ).toEqual(losers.map(() => 0))
  215. expect(winner?.exitCode).toBe(null)
  216. expect(new URL(info.url).port).toBe(String(port))
  217. expect((await Bun.file(config).json()).password).toBe(info.password)
  218. expect(await Bun.file(registration + ".lock").exists()).toBe(false)
  219. expect(
  220. await fetch(new URL("/api/health", info.url), {
  221. headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
  222. }).then((response) => response.json()),
  223. ).toEqual({
  224. healthy: true,
  225. version: info.version,
  226. pid: info.pid,
  227. })
  228. const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
  229. try {
  230. const contenderExited = await Promise.race([
  231. contender.exited.then(() => true),
  232. Bun.sleep(10_000).then(() => false),
  233. ])
  234. expect(contenderExited).toBe(true)
  235. expect(contender.exitCode).toBe(0)
  236. expect((await waitForInfo(registration)).id).toBe(info.id)
  237. } finally {
  238. contender.kill("SIGTERM")
  239. await contender.exited
  240. }
  241. await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
  242. await winner?.exited
  243. expect(await Bun.file(registration).exists()).toBe(false)
  244. } finally {
  245. processes.forEach((process) => process.kill("SIGTERM"))
  246. await Promise.all(processes.map((process) => process.exited))
  247. await fs.rm(root, { recursive: true, force: true })
  248. }
  249. }, 120_000)
  250. test("configured managed service port overrides the channel default", async () => {
  251. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-port-"))
  252. const port = await availablePort()
  253. const env = serviceEnv(root)
  254. const registration = path.join(root, "state", "opencode", "service-local.json")
  255. const config = path.join(root, "config", "opencode", "service-local.json")
  256. await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
  257. await fs.writeFile(config, JSON.stringify({ port, password: "" }))
  258. const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
  259. env,
  260. stderr: "pipe",
  261. stdout: "ignore",
  262. })
  263. try {
  264. const info = await waitForInfo(registration)
  265. expect(new URL(info.url).port).toBe(String(port))
  266. expect(info.password).not.toBe("")
  267. expect((await Bun.file(config).json()).password).toBe(info.password)
  268. await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
  269. await owner.exited
  270. } finally {
  271. owner.kill("SIGTERM")
  272. await owner.exited
  273. await fs.rm(root, { recursive: true, force: true })
  274. }
  275. }, 30_000)
  276. test("unrelated managed port occupancy reports an actionable conflict", async () => {
  277. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
  278. const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })
  279. const port = listener.port
  280. const registration = path.join(root, "state", "opencode", "service-local.json")
  281. await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
  282. await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
  283. const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
  284. env: serviceEnv(root),
  285. stderr: "pipe",
  286. stdout: "pipe",
  287. })
  288. try {
  289. expect(await contender.exited).not.toBe(0)
  290. const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
  291. expect(output).toContain(`Managed service port ${port} on 127.0.0.1 is already in use by another process`)
  292. expect(output).toContain("opencode service set port <port>")
  293. expect(await Bun.file(registration).exists()).toBe(false)
  294. } finally {
  295. listener.stop(true)
  296. contender.kill("SIGTERM")
  297. await contender.exited
  298. await fs.rm(root, { recursive: true, force: true })
  299. }
  300. }, 30_000)
  301. test("unresponsive managed port occupancy reports a bounded conflict", async () => {
  302. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
  303. const recognizing = Promise.withResolvers<void>()
  304. const requests = { count: 0 }
  305. using listener = Bun.serve({
  306. hostname: "127.0.0.1",
  307. port: 0,
  308. fetch() {
  309. requests.count += 1
  310. if (requests.count === 2) recognizing.resolve()
  311. return new Promise<Response>(() => {})
  312. },
  313. })
  314. const registration = path.join(root, "state", "opencode", "service-local.json")
  315. await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
  316. await fs.mkdir(path.dirname(registration), { recursive: true })
  317. await fs.writeFile(
  318. path.join(root, "config", "opencode", "service-local.json"),
  319. JSON.stringify({ port: listener.port }),
  320. )
  321. const stale = {
  322. id: "stale",
  323. version: OPENCODE_VERSION,
  324. url: "http://127.0.0.1:1",
  325. pid: process.pid,
  326. password: "stale",
  327. }
  328. await fs.writeFile(registration, JSON.stringify(stale))
  329. const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
  330. env: serviceEnv(root),
  331. stderr: "pipe",
  332. stdout: "pipe",
  333. })
  334. try {
  335. expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
  336. const exitCode = await Promise.race([contender.exited, Bun.sleep(20_000).then(() => undefined)])
  337. expect(exitCode).toBe(1)
  338. const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
  339. expect(output).toContain(`Managed service port ${listener.port} on 127.0.0.1 is already in use by another process`)
  340. expect(await Bun.file(registration).json()).toEqual(stale)
  341. } finally {
  342. contender.kill("SIGTERM")
  343. await contender.exited
  344. await fs.rm(root, { recursive: true, force: true })
  345. }
  346. }, 45_000)
  347. test("port contender recognizes an incumbent registered during the bind race", async () => {
  348. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-bind-race-"))
  349. const recognizing = Promise.withResolvers<void>()
  350. const requests = { count: 0 }
  351. using listener = Bun.serve({
  352. hostname: "127.0.0.1",
  353. port: 0,
  354. fetch() {
  355. requests.count += 1
  356. if (requests.count === 2) recognizing.resolve()
  357. return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }, { status: 503 })
  358. },
  359. })
  360. const registration = path.join(root, "state", "opencode", "service-local.json")
  361. const config = path.join(root, "config", "opencode", "service-local.json")
  362. await fs.mkdir(path.dirname(config), { recursive: true })
  363. await fs.writeFile(config, JSON.stringify({ port: listener.port }))
  364. await fs.mkdir(path.dirname(registration), { recursive: true })
  365. await fs.writeFile(
  366. registration,
  367. JSON.stringify({
  368. id: "stale",
  369. version: OPENCODE_VERSION,
  370. url: "http://127.0.0.1:1",
  371. pid: 2_147_483_647,
  372. password: "stale",
  373. }),
  374. )
  375. const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
  376. env: serviceEnv(root),
  377. stderr: "pipe",
  378. stdout: "ignore",
  379. })
  380. try {
  381. expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
  382. await Bun.sleep(8_000)
  383. const info = {
  384. id: "incumbent",
  385. version: OPENCODE_VERSION,
  386. url: `http://127.0.0.1:${listener.port}`,
  387. pid: process.pid,
  388. password: "incumbent",
  389. }
  390. await fs.writeFile(registration, JSON.stringify(info))
  391. expect(await Promise.race([contender.exited, Bun.sleep(20_000).then(() => undefined)])).toBe(0)
  392. expect(await Bun.file(registration).json()).toEqual(info)
  393. } finally {
  394. contender.kill("SIGTERM")
  395. await contender.exited
  396. await fs.rm(root, { recursive: true, force: true })
  397. }
  398. }, 45_000)
  399. test("stale dead registration is replaced after binding the selected port", async () => {
  400. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
  401. const port = await availablePort()
  402. const registration = path.join(root, "state", "opencode", "service-local.json")
  403. await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
  404. await fs.mkdir(path.dirname(registration), { recursive: true })
  405. await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
  406. await fs.writeFile(
  407. registration,
  408. JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, pid: 2_147_483_647 }),
  409. )
  410. const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
  411. env: serviceEnv(root),
  412. stderr: "pipe",
  413. stdout: "ignore",
  414. })
  415. try {
  416. const info = await waitForInfo(registration, (value) => value.id !== "dead")
  417. expect(new URL(info.url).port).toBe(String(port))
  418. expect(info.pid).toBe(owner.pid)
  419. await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
  420. await owner.exited
  421. } finally {
  422. owner.kill("SIGTERM")
  423. await owner.exited
  424. await fs.rm(root, { recursive: true, force: true })
  425. }
  426. }, 30_000)
  427. test("a failed service stays registered and owns the selected port until stopped", async () => {
  428. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
  429. const database = path.join(root, "database")
  430. await fs.mkdir(database)
  431. const env = {
  432. ...process.env,
  433. HOME: root,
  434. OPENCODE_DB: database,
  435. OPENCODE_TEST_HOME: root,
  436. XDG_CACHE_HOME: path.join(root, "cache"),
  437. XDG_CONFIG_HOME: path.join(root, "config"),
  438. XDG_DATA_HOME: path.join(root, "data"),
  439. XDG_STATE_HOME: path.join(root, "state"),
  440. }
  441. const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
  442. const registration = path.join(root, "state", "opencode", "service-local.json")
  443. const owner = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
  444. try {
  445. const info = await waitForInfo(registration)
  446. await waitForFailed(info)
  447. expect(owner.exitCode).toBe(null)
  448. const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
  449. expect(await Promise.race([contender.exited.then(() => true), Bun.sleep(10_000).then(() => false)])).toBe(true)
  450. expect(contender.exitCode).toBe(0)
  451. expect((await waitForInfo(registration)).id).toBe(info.id)
  452. expect(owner.exitCode).toBe(null)
  453. await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
  454. await owner.exited
  455. expect(await Bun.file(registration).exists()).toBe(false)
  456. } finally {
  457. owner.kill("SIGTERM")
  458. await owner.exited
  459. await fs.rm(root, { recursive: true, force: true })
  460. }
  461. }, 30_000)
  462. async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
  463. for (let attempt = 0; attempt < 400; attempt++) {
  464. const value = await Bun.file(file)
  465. .json()
  466. .catch(() => undefined)
  467. if (value !== undefined) {
  468. const info = await Schema.decodeUnknownPromise(Service.Info)(value)
  469. if (accept(info)) return info
  470. }
  471. await Bun.sleep(50)
  472. }
  473. throw new Error("Timed out waiting for service registration")
  474. }
  475. async function waitForFailed(info: Info) {
  476. for (let attempt = 0; attempt < 400; attempt++) {
  477. const status = await fetch(new URL("/api/health", info.url), {
  478. headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
  479. })
  480. .then((response) => response.status)
  481. .catch(() => undefined)
  482. if (status === 500) return
  483. await Bun.sleep(50)
  484. }
  485. throw new Error("Timed out waiting for service boot failure")
  486. }
  487. async function availablePort() {
  488. const server = Bun.serve({ port: 0, fetch: () => new Response() })
  489. const port = server.port
  490. await server.stop(true)
  491. if (port === undefined) throw new Error("Server did not bind a port")
  492. return port
  493. }
  494. function serviceEnv(root: string) {
  495. return {
  496. ...process.env,
  497. HOME: root,
  498. OPENCODE_DB: path.join(root, "opencode.db"),
  499. OPENCODE_TEST_HOME: root,
  500. XDG_CACHE_HOME: path.join(root, "cache"),
  501. XDG_CONFIG_HOME: path.join(root, "config"),
  502. XDG_DATA_HOME: path.join(root, "data"),
  503. XDG_STATE_HOME: path.join(root, "state"),
  504. }
  505. }
  506. async function startManagedService(prefix: string, failBoot = false) {
  507. const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
  508. const port = await availablePort()
  509. const registration = path.join(root, "state", "opencode", "service-local.json")
  510. await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
  511. if (failBoot) await fs.mkdir(path.join(root, "database"))
  512. await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
  513. const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
  514. env: failBoot ? { ...serviceEnv(root), OPENCODE_DB: path.join(root, "database") } : serviceEnv(root),
  515. stderr: "pipe",
  516. stdout: "ignore",
  517. })
  518. const info = await waitForInfo(registration).catch(async (cause) => {
  519. owner.kill("SIGTERM")
  520. await owner.exited
  521. await fs.rm(root, { recursive: true, force: true })
  522. throw cause
  523. })
  524. return { root, port, registration, owner, info }
  525. }
  526. async function stopManagedService(service: Awaited<ReturnType<typeof startManagedService>>) {
  527. service.owner.kill("SIGTERM")
  528. await service.owner.exited
  529. await fs.rm(service.root, { recursive: true, force: true })
  530. }
  531. function waitForExit(process: Bun.Subprocess, timeout = 10_000) {
  532. return Promise.race([process.exited.then(() => true), Bun.sleep(timeout).then(() => false)])
  533. }
  534. async function expectPortAvailable(port: number) {
  535. const server = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() })
  536. await server.stop(true)
  537. }