fix: stabilize v2 runtime behavior

This commit is contained in:
Dax Raad 2026-06-26 20:56:36 -04:00
commit 655adbf46e
35 changed files with 742 additions and 425 deletions

View file

@ -11,6 +11,14 @@ export default Runtime.handler(Commands, (input) =>
const daemon = yield* Daemon.Service
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* runTui(transport)
yield* runTui(
transport,
input.standalone
? undefined
: async () => {
await Effect.runPromise(daemon.stop())
return Effect.runPromise(daemon.transport())
},
)
}),
)

View file

@ -32,12 +32,27 @@ export default Runtime.handler(
if (input.register) yield* daemon.register(address)
const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
return yield* Effect.never
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
}).pipe(Effect.annotateLogs({ role: "server" })),
)
}),
)
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
process.stdin.once("end", close)
process.stdin.once("close", close)
process.stdin.resume()
if (process.stdin.readableEnded || process.stdin.destroyed) close()
return Effect.sync(() => {
process.stdin.off("end", close)
process.stdin.off("close", close)
process.stdin.pause()
})
})
}
function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
const next = (port: number): ReturnType<typeof bind> =>

View file

@ -1,5 +1,5 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
@ -41,7 +41,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state
const file = path.join(directory, "server.json")
const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json")
const configFile = path.join(Global.Path.config, "service.json")
const legacyPasswordFile = path.join(directory, "password")
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))

View file

@ -16,9 +16,12 @@ function command(password: string) {
cwd: process.cwd(),
env: { OPENCODE_SERVER_PASSWORD: password },
extendEnv: true,
stdin: "ignore",
// The server treats EOF on this pipe as the end of its ownership lease.
// The OS closes it even when the TUI is killed before Effect finalizers run.
stdin: "pipe",
stderr: "ignore",
killSignal: "SIGKILL",
killSignal: "SIGTERM",
forceKillAfter: "3 seconds",
})
}
@ -30,7 +33,7 @@ export const transport = Effect.fn("cli.standalone.transport")(
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return { url: ready.url, headers: ServerAuth.headers({ password }) }
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
},
Effect.provide(CrossSpawnSpawner.defaultLayer),
)

View file

@ -5,7 +5,9 @@ import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
type Transport = { url: string; headers: RequestInit["headers"] }
export function runTui(transport: Transport, reload?: () => Promise<Transport>) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
return Effect.gen(function* () {
@ -23,6 +25,12 @@ export function runTui(transport: { url: string; headers: RequestInit["headers"]
)
return yield* run({
client: createOpencodeClient({ ...options, directory }),
reload: reload
? async () => {
const next = await reload()
return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory })
}
: undefined,
args: {},
config,
pluginHost: {

View file

@ -0,0 +1,15 @@
import { Effect } from "effect"
import path from "node:path"
import { Standalone } from "../../src/services/standalone"
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* Standalone.transport()
console.log(`${transport.pid} ${transport.url}`)
return yield* Effect.never
}),
),
)

View file

@ -0,0 +1,65 @@
import { expect, test } from "bun:test"
import path from "node:path"
test("standalone server exits when its owner is killed", async () => {
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
cwd: path.join(import.meta.dir, ".."),
env: process.env,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
const [rawPID, url] = line?.split(" ") ?? []
const pid = Number(rawPID)
try {
expect(pid).toBeGreaterThan(0)
expect(url).toStartWith("http://127.0.0.1:")
expect(running(pid)).toBe(true)
owner.kill("SIGKILL")
await owner.exited
expect(await waitForExit(pid)).toBe(true)
} finally {
owner.kill("SIGKILL")
if (running(pid)) process.kill(pid, "SIGKILL")
}
})
async function readLine(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
const chunks: string[] = []
while (true) {
const result = await reader.read()
if (result.done) break
chunks.push(decoder.decode(result.value, { stream: true }))
const output = chunks.join("")
const newline = output.indexOf("\n")
if (newline !== -1) {
reader.releaseLock()
return output.slice(0, newline)
}
}
reader.releaseLock()
return chunks.join("") + decoder.decode()
}
async function waitForExit(pid: number, attempts = 100): Promise<boolean> {
if (!running(pid)) return true
if (attempts === 0) return false
await Bun.sleep(50)
return waitForExit(pid, attempts - 1)
}
function running(pid: number) {
if (!Number.isSafeInteger(pid) || pid <= 0) return false
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}