fix(cli): elect managed service by port bind (#37572)
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
This commit is contained in:
parent
987242b3e8
commit
6ea8247e0f
6 changed files with 232 additions and 39 deletions
|
|
@ -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("a failed service stays registered and owns the lock until stopped", async () => {
|
||||
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 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"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue