service.test.ts 20 KB

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