Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton e98a2f5809 test: isolate flaky service and tab checks 2026-08-14 13:17:17 +00:00
4 changed files with 104 additions and 87 deletions
+10 -50
View File
@@ -1,17 +1,17 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { Service, type DiscoverOptions } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
import { Effect, Option, Redacted, Schedule } from "effect"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
@@ -121,7 +121,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
return yield* ServiceRegistration.register(
address,
password,
instanceID,
serviceOptions.file,
shutdown,
)
}),
},
transform,
@@ -158,52 +164,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
})
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (
address: HttpServer.Address,
password: string,
id: string,
file: string,
shutdown: Effect.Effect<void>,
) {
const fs = yield* FileSystem.FileSystem
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const info = {
id,
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(file).pipe(
Effect.flatMap(decodeInfo),
Effect.orElseSucceed(() => undefined),
)
const owns = (found: Info | undefined) =>
found?.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* current.pipe(
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(shutdown),
Effect.forkScoped,
)
return current.pipe(
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
Effect.ignore,
)
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
Effect.filterOrFail((value) => value !== undefined),
@@ -0,0 +1,53 @@
export * as ServiceRegistration from "./service-registration"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import path from "node:path"
import { Effect, FileSystem, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { OPENCODE_VERSION } from "../version"
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
export const register = Effect.fnUntraced(function* (
address: HttpServer.Address,
password: string,
id: string,
file: string,
shutdown: Effect.Effect<void>,
) {
const fs = yield* FileSystem.FileSystem
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const info = {
id,
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(file).pipe(
Effect.flatMap(decodeInfo),
Effect.orElseSucceed(() => undefined),
)
const owns = (found: Info | undefined) =>
found?.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* current.pipe(
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(shutdown),
Effect.forkScoped,
)
return current.pipe(
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
Effect.ignore,
)
})
+22 -18
View File
@@ -8,6 +8,7 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { ServiceConfig } from "../src/services/service-config"
import { ServiceRegistration } from "../src/services/service-registration"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
@@ -413,34 +414,37 @@ test("port contender recognizes an incumbent registered during the bind race", a
}
}, 45_000)
test("stale dead registration is replaced after binding the selected port", async () => {
test("service registration replaces a stale owner with the bound address", 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 }),
JSON.stringify({ id: "dead", version: "dead", url: "http://127.0.0.1:4321", 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
const cleanup = await Effect.runPromise(
ServiceRegistration.register(
{ _tag: "TcpAddress", hostname: "127.0.0.1", port: 4321 },
"secret",
"owner",
registration,
Effect.never,
).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
)
expect(await Bun.file(registration).json()).toEqual({
id: "owner",
version: OPENCODE_VERSION,
url: "http://127.0.0.1:4321",
pid: process.pid,
password: "secret",
})
await Effect.runPromise(cleanup.pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(registration).exists()).toBe(false)
} 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-"))
+19 -19
View File
@@ -141,7 +141,15 @@ async function renderSessionTabs(
locations,
vcsLocations,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
emit: (event: OpenCodeEvent) =>
new Promise<void>((resolve) => {
const off = client.event.listen((delivered) => {
if (delivered.details.id !== event.id) return
off()
resolve()
})
events.emit({ ...event, location: { directory } })
}),
focus: () => app.renderer.emit("focus"),
blur: () => app.renderer.emit("blur"),
flush: () => storage.flush(),
@@ -214,39 +222,31 @@ test("stores session tabs for the current working directory by default", async (
})
test("only the foreground TUI mutates unread state", async () => {
await using temporary = await tmpdir()
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
try {
foreground = await renderSessionTabs("first", { state: temporary.path })
background = await renderSessionTabs("second", { state: temporary.path })
foreground = await renderSessionTabs("first", { persisted: ["first", "second"] })
background = await renderSessionTabs("first", { persisted: ["first", "second"] })
foreground.focus()
background.blur()
await wait(() => foreground?.tabs.tabs().length === 2 && background?.tabs.tabs().length === 2)
const firstDone = executionSucceeded("first")
foreground.emit(firstDone)
background.emit(firstDone)
await Promise.all([foreground.emit(firstDone), background.emit(firstDone)])
await Promise.all([foreground.flush(), background.flush()])
expect(foreground.tabs.status("first").unread).toBeUndefined()
expect(background.tabs.status("first").unread).toBeUndefined()
const secondDone = executionSucceeded("second")
foreground.emit(secondDone)
background.emit(secondDone)
await wait(
() =>
foreground?.tabs.status("second").unread === "activity" &&
background?.tabs.status("second").unread === "activity",
)
await Promise.all([foreground.emit(secondDone), background.emit(secondDone)])
await Promise.all([foreground.flush(), background.flush()])
expect(foreground.tabs.status("second").unread).toBe("activity")
expect(background.tabs.status("second").unread).toBeUndefined()
foreground.tabs.select("second")
await wait(
() =>
foreground?.tabs.status("second").unread === undefined &&
background?.tabs.status("second").unread === undefined,
)
await foreground.flush()
expect(foreground.tabs.status("second").unread).toBeUndefined()
expect(background.tabs.status("second").unread).toBeUndefined()
} finally {
if (foreground) await foreground.destroy()
if (background) await background.destroy()