|
|
@@ -1,5 +1,5 @@
|
|
|
import { NodeFileSystem } from "@effect/platform-node"
|
|
|
-import { Service } from "@opencode-ai/client/effect/service"
|
|
|
+import { Service, type Info } from "@opencode-ai/client/effect/service"
|
|
|
import { Database } from "@opencode-ai/core/database/database"
|
|
|
import { EventV2 } from "@opencode-ai/core/event"
|
|
|
import { EventTable } from "@opencode-ai/core/event/sql"
|
|
|
@@ -17,6 +17,13 @@ import os from "node:os"
|
|
|
import path from "node:path"
|
|
|
import { ServiceConfig } from "../src/services/service-config"
|
|
|
|
|
|
+test("managed service ports are stable per installation channel", () => {
|
|
|
+ expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
|
|
+ expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
|
|
|
+ expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
|
|
|
+ expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
|
|
|
+})
|
|
|
+
|
|
|
test("local channel stores service config with the local service filename", async () => {
|
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
|
|
try {
|
|
|
@@ -130,6 +137,9 @@ test("concurrent service processes elect one server", async () => {
|
|
|
)
|
|
|
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
|
|
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
|
|
+ const port = await availablePort()
|
|
|
+ await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
|
|
+ await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
|
|
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }))
|
|
|
|
|
|
try {
|
|
|
@@ -137,11 +147,13 @@ test("concurrent service processes elect one server", async () => {
|
|
|
const winner = processes.find((process) => process.pid === info.pid)
|
|
|
const losers = processes.filter((process) => process.pid !== info.pid)
|
|
|
const exited = await Promise.all(
|
|
|
- losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(10_000).then(() => false)])),
|
|
|
+ losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(60_000).then(() => false)])),
|
|
|
)
|
|
|
|
|
|
expect(exited).toEqual(losers.map(() => true))
|
|
|
expect(winner?.exitCode).toBe(null)
|
|
|
+ expect(new URL(info.url).port).toBe(String(port))
|
|
|
+ expect(await Bun.file(registration + ".lock").exists()).toBe(false)
|
|
|
expect(
|
|
|
await fetch(new URL("/api/health", info.url), {
|
|
|
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
|
|
@@ -154,9 +166,14 @@ test("concurrent service processes elect one server", async () => {
|
|
|
const blockedTemp = registration + "." + info.id + ".tmp"
|
|
|
await fs.mkdir(blockedTemp)
|
|
|
await fs.rm(registration)
|
|
|
- await Bun.sleep(6_000)
|
|
|
+ const repairContender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
|
|
+ await Bun.sleep(3_000)
|
|
|
expect(await Bun.file(registration).exists()).toBe(false)
|
|
|
await fs.rm(blockedTemp, { recursive: true })
|
|
|
+ expect(await Promise.race([repairContender.exited.then(() => true), Bun.sleep(15_000).then(() => false)])).toBe(
|
|
|
+ true,
|
|
|
+ )
|
|
|
+ expect(repairContender.exitCode).toBe(0)
|
|
|
const restored = await waitForInfo(registration)
|
|
|
expect(restored.id).toBe(info.id)
|
|
|
expect(restored.pid).toBe(info.pid)
|
|
|
@@ -164,6 +181,16 @@ test("concurrent service processes elect one server", async () => {
|
|
|
const repaired = await waitForInfo(registration)
|
|
|
expect(repaired.id).toBe(info.id)
|
|
|
expect(repaired.pid).toBe(info.pid)
|
|
|
+ await fs.writeFile(
|
|
|
+ registration,
|
|
|
+ JSON.stringify({ ...info, id: "older-orphan", pid: process.pid, startedAt: info.startedAt! - 1 }),
|
|
|
+ )
|
|
|
+ const reclaimed = await waitForInfo(registration, (value) => value.id === info.id)
|
|
|
+ expect(reclaimed.pid).toBe(info.pid)
|
|
|
+ await fs.writeFile(registration, JSON.stringify({ ...info, id: "newer-owner", startedAt: info.startedAt! + 1 }))
|
|
|
+ await Bun.sleep(6_000)
|
|
|
+ expect((await waitForInfo(registration)).id).toBe("newer-owner")
|
|
|
+ await fs.writeFile(registration, JSON.stringify(info))
|
|
|
|
|
|
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
|
|
try {
|
|
|
@@ -202,9 +229,88 @@ test("concurrent service processes elect one server", async () => {
|
|
|
await fs.rm(root, { recursive: true, force: true })
|
|
|
}
|
|
|
}
|
|
|
-}, 60_000)
|
|
|
+}, 120_000)
|
|
|
+
|
|
|
+test("configured managed service port overrides the channel default", async () => {
|
|
|
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-port-"))
|
|
|
+ const port = await availablePort()
|
|
|
+ const env = serviceEnv(root)
|
|
|
+ const registration = path.join(root, "state", "opencode", "service-local.json")
|
|
|
+ await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
|
|
+ await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
|
|
+ const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
|
|
+ env,
|
|
|
+ stderr: "pipe",
|
|
|
+ stdout: "ignore",
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ const info = await waitForInfo(registration)
|
|
|
+ expect(new URL(info.url).port).toBe(String(port))
|
|
|
+ await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
|
|
+ await owner.exited
|
|
|
+ } finally {
|
|
|
+ owner.kill("SIGTERM")
|
|
|
+ await owner.exited
|
|
|
+ await fs.rm(root, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+}, 30_000)
|
|
|
+
|
|
|
+test("unrelated managed port occupancy reports an actionable conflict", async () => {
|
|
|
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
|
|
|
+ const listener = Bun.serve({ port: 0, fetch: () => new Response("unrelated") })
|
|
|
+ const port = listener.port
|
|
|
+ const registration = path.join(root, "state", "opencode", "service-local.json")
|
|
|
+ await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
|
|
+ await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
|
|
+ const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
|
|
+ env: serviceEnv(root),
|
|
|
+ stderr: "pipe",
|
|
|
+ stdout: "pipe",
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ expect(await contender.exited).not.toBe(0)
|
|
|
+ const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
|
|
|
+ expect(output).toContain(`Managed service port ${port} on 127.0.0.1 is already in use by another process`)
|
|
|
+ expect(output).toContain("opencode service set port <port>")
|
|
|
+ expect(await Bun.file(registration).exists()).toBe(false)
|
|
|
+ } finally {
|
|
|
+ listener.stop(true)
|
|
|
+ contender.kill("SIGTERM")
|
|
|
+ await contender.exited
|
|
|
+ await fs.rm(root, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+}, 30_000)
|
|
|
+
|
|
|
+test("stale dead registration is replaced after binding the selected port", async () => {
|
|
|
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
|
|
|
+ const port = await availablePort()
|
|
|
+ const registration = path.join(root, "state", "opencode", "service-local.json")
|
|
|
+ await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
|
|
+ await fs.mkdir(path.dirname(registration), { recursive: true })
|
|
|
+ await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
|
|
+ await fs.writeFile(
|
|
|
+ registration,
|
|
|
+ JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, pid: 2_147_483_647 }),
|
|
|
+ )
|
|
|
+ const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
|
|
+ env: serviceEnv(root),
|
|
|
+ stderr: "pipe",
|
|
|
+ stdout: "ignore",
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ const info = await waitForInfo(registration, (value) => value.id !== "dead")
|
|
|
+ expect(new URL(info.url).port).toBe(String(port))
|
|
|
+ expect(info.pid).toBe(owner.pid)
|
|
|
+ await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
|
|
+ await owner.exited
|
|
|
+ } finally {
|
|
|
+ owner.kill("SIGTERM")
|
|
|
+ await owner.exited
|
|
|
+ await fs.rm(root, { recursive: true, force: true })
|
|
|
+ }
|
|
|
+}, 30_000)
|
|
|
|
|
|
-test("a failed service stays registered and owns the lock until stopped", async () => {
|
|
|
+test("a failed service stays registered and owns the selected port until stopped", async () => {
|
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
|
|
|
const database = path.join(root, "database")
|
|
|
await fs.mkdir(database)
|
|
|
@@ -275,13 +381,36 @@ function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-async function waitForInfo(file: string) {
|
|
|
+async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
|
|
|
for (let attempt = 0; attempt < 400; attempt++) {
|
|
|
const value = await Bun.file(file)
|
|
|
.json()
|
|
|
.catch(() => undefined)
|
|
|
- if (value !== undefined) return Schema.decodeUnknownPromise(Service.Info)(value)
|
|
|
+ if (value !== undefined) {
|
|
|
+ const info = await Schema.decodeUnknownPromise(Service.Info)(value)
|
|
|
+ if (accept(info)) return info
|
|
|
+ }
|
|
|
await Bun.sleep(50)
|
|
|
}
|
|
|
throw new Error("Timed out waiting for service registration")
|
|
|
}
|
|
|
+
|
|
|
+async function availablePort() {
|
|
|
+ const server = Bun.serve({ port: 0, fetch: () => new Response() })
|
|
|
+ const port = server.port
|
|
|
+ await server.stop(true)
|
|
|
+ return port
|
|
|
+}
|
|
|
+
|
|
|
+function serviceEnv(root: string) {
|
|
|
+ return {
|
|
|
+ ...process.env,
|
|
|
+ HOME: root,
|
|
|
+ OPENCODE_DB: path.join(root, "opencode.db"),
|
|
|
+ OPENCODE_TEST_HOME: root,
|
|
|
+ XDG_CACHE_HOME: path.join(root, "cache"),
|
|
|
+ XDG_CONFIG_HOME: path.join(root, "config"),
|
|
|
+ XDG_DATA_HOME: path.join(root, "data"),
|
|
|
+ XDG_STATE_HOME: path.join(root, "state"),
|
|
|
+ }
|
|
|
+}
|