fix(plugin): reuse active server for client requests

Temporarily route plugin SDK calls through the active listener so they reuse its instance store instead of initializing plugins again through the default app. Keep the in-process fallback for commands without a listener. This is intentionally a narrow bridge until v2 owns plugin clients in the correct shared server runtime.
This commit is contained in:
Dax Raad 2026-06-14 13:28:48 -04:00
parent 3f81402663
commit 87c33b3d85
3 changed files with 70 additions and 8 deletions

View File

@ -138,11 +138,12 @@ export const layer = Layer.effect(
const { Server } = yield* Effect.promise(() => import("../server/server"))
const serverUrl = Server.url
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
baseUrl: serverUrl?.toString() ?? "http://localhost:4096",
directory: ctx.directory,
headers: ServerAuth.headers(),
fetch: async (...args) => Server.Default().app.fetch(...args),
...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }),
})
const cfg = yield* config.get()
const input: PluginInput = {

View File

@ -67,7 +67,7 @@ export async function openapi() {
return OpenApi.fromApi(PublicApi)
}
export let url: URL
export let url: URL | undefined
export async function listen(opts: ListenOptions): Promise<Listener> {
const listener = await Effect.runPromise(listenEffect(opts))
@ -84,15 +84,14 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unkno
const state = yield* startWithPortFallback(opts)
const address = yield* tcpAddress(state)
const listenerUrl = makeURL(opts.hostname, address.port)
url = listenerUrl
const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)
url = listenerUrl
return {
hostname: opts.hostname,
port: address.port,
url: listenerUrl,
stop: yield* makeStop(state, unpublishMdns),
stop: yield* makeStop(state, unpublishMdns, listenerUrl),
}
},
)
@ -169,10 +168,19 @@ function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) {
})
}
function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>) {
function makeStop(state: ListenerState, unpublishMdns: Effect.Effect<void>, listenerUrl: URL) {
return Effect.gen(function* () {
const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore))
const closeScopeOnce = yield* Effect.cached(Scope.close(state.scope, Exit.void).pipe(Effect.ignore))
const closeScopeOnce = yield* Effect.cached(
Scope.close(state.scope, Exit.void).pipe(
Effect.ignore,
Effect.ensuring(
Effect.sync(() => {
if (url === listenerUrl) url = undefined
}),
),
),
)
return (close?: boolean) =>
Effect.gen(function* () {

View File

@ -1,5 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import net from "node:net"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
@ -300,6 +302,57 @@ describe("HttpApi Server.listen", () => {
expect(output).not.toContain("Sent HTTP response")
})
test("plugin client requests reuse the listening server instance", async () => {
await using tmp = await tmpdir({
init: async (directory) => {
const plugin = path.join(directory, "plugin.ts")
const initialized = path.join(directory, "initialized.txt")
const completed = path.join(directory, "completed.txt")
await Bun.write(
plugin,
[
"export default async function plugin(input) {",
` await Bun.write(${JSON.stringify(initialized)}, (await Bun.file(${JSON.stringify(initialized)}).text().catch(() => "")) + "initialized\\n")`,
" setTimeout(async () => {",
" await input.client.config.get()",
` await Bun.write(${JSON.stringify(completed)}, "completed")`,
" }, 50)",
" return {}",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(directory, "opencode.json"),
JSON.stringify({ formatter: false, lsp: false, plugin: [pathToFileURL(plugin).href] }),
)
return { initialized, completed }
},
})
const previous = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
let listener: Awaited<ReturnType<typeof startListener>> | undefined
try {
listener = await startListener()
const response = await fetch(new URL("/config", listener.url), {
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
await withTimeout(
(async () => {
while (!(await Bun.file(tmp.extra.completed).exists())) await Bun.sleep(10)
})(),
5_000,
"timed out waiting for plugin client request",
)
expect(await Bun.file(tmp.extra.initialized).text()).toBe("initialized\n")
} finally {
if (listener) await stop(listener, "timed out cleaning up plugin client listener").catch(() => undefined)
if (previous === undefined) delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
else process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = previous
}
})
test("port 0 prefers 4096 when free", async () => {
if (!(await isPortFree(4096))) return
const listener = await startListener()