From 87c33b3d85a6c874e7b5af6af8fd6784e0e1bfbd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 14 Jun 2026 13:28:48 -0400 Subject: [PATCH] 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. --- packages/opencode/src/plugin/index.ts | 5 +- packages/opencode/src/server/server.ts | 20 ++++--- .../test/server/httpapi-listen.test.ts | 53 +++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index fb36c75cc..0f71b39a9 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -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 = { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e8958c7ba..5145e26d6 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -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 { const listener = await Effect.runPromise(listenEffect(opts)) @@ -84,15 +84,14 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect) { +function makeStop(state: ListenerState, unpublishMdns: Effect.Effect, 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* () { diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 805c11d62..585c59cb4 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -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> | 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()