service.test.ts 23 KB

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