Skip to content

Commit cf26916

Browse files
fix(plugin): reject bootstrap client reentry
Guard the plugin HTTP client so requests issued from a plugin's server() hook while the instance's plugins are still loading return a 409 instead of re-entering the still-initializing instance and deadlocking. The guard is cleared once plugin loading and config-notify hooks complete. Ported from anomalyco#31859 (adapted onto this fork's createPluginClient transport). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a8b8402 commit cf26916

3 files changed

Lines changed: 64 additions & 1 deletion

File tree

packages/opencode/src/plugin/client.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,20 @@ function rewriteRequest(request: Request, serverUrl: URL) {
3939
return new Request(new URL(`${url.pathname}${url.search}`, serverUrl), request)
4040
}
4141

42+
export function pluginClientReentryResponse(directory: string) {
43+
return new Response(`Plugin client request cannot enter instance ${directory} while its plugins are still loading`, {
44+
status: 409,
45+
headers: {
46+
"content-type": "text/plain; charset=utf-8",
47+
},
48+
})
49+
}
50+
4251
export function createPluginClient(input: {
4352
directory: string
4453
getServerUrl: () => URL
4554
fallbackFetch: ClientFetch
55+
isBootstrapping?: () => boolean
4656
}) {
4757
return createOpencodeClient({
4858
baseUrl: "http://localhost",
@@ -52,8 +62,14 @@ export function createPluginClient(input: {
5262
})
5363
}
5464

55-
export function createPluginFetch(input: { getServerUrl: () => URL; fallbackFetch: ClientFetch }): ClientFetch {
65+
export function createPluginFetch(input: {
66+
directory?: string
67+
getServerUrl: () => URL
68+
fallbackFetch: ClientFetch
69+
isBootstrapping?: () => boolean
70+
}): ClientFetch {
5671
return async (request: Request) => {
72+
if (input.isBootstrapping?.()) return pluginClientReentryResponse(input.directory ?? "")
5773
const serverUrl = input.getServerUrl()
5874
if (await useLiveServer(serverUrl)) {
5975
const key = normalizeServerUrl(serverUrl)

packages/opencode/src/plugin/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,12 @@ const layer = Layer.effect(
146146
const { Server } = yield* Effect.promise(() => import("../server/server"))
147147

148148
const getServerUrl = () => Server.url ?? new URL("http://localhost:4096")
149+
let bootstrapping = true
149150
const client = createPluginClient({
150151
directory: ctx.directory,
151152
getServerUrl,
152153
fallbackFetch: async (request) => Server.Default().app.fetch(request),
154+
isBootstrapping: () => bootstrapping,
153155
})
154156
const cfg = yield* config.get()
155157
const input: PluginInput = {
@@ -250,6 +252,8 @@ const layer = Layer.effect(
250252
)
251253
}
252254

255+
bootstrapping = false
256+
253257
const unsubscribe = yield* events.listen((event) => {
254258
if (event.location?.directory !== ctx.directory) return Effect.void
255259
return Effect.sync(() => {

packages/opencode/test/server/httpapi-listen.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,49 @@ async function openPtySocket(listener: Awaited<ReturnType<typeof startListener>>
167167
}
168168

169169
describe("HttpApi Server.listen", () => {
170+
test("rejects plugin client re-entry while plugins are loading", async () => {
171+
await using tmp = await tmpdir({
172+
config: { formatter: false, lsp: false, plugin: ["./server-plugin.js"] },
173+
init: async (dir) => {
174+
await Bun.write(
175+
path.join(dir, "server-plugin.js"),
176+
`export default {
177+
id: "plugin-client-reentry-probe",
178+
async server(input) {
179+
try {
180+
const result = await input.client.config.get();
181+
await Bun.write(${JSON.stringify(path.join(dir, "plugin-client-result.json"))}, JSON.stringify({ ok: !result.error, error: result.error }));
182+
} catch (error) {
183+
await Bun.write(${JSON.stringify(path.join(dir, "plugin-client-result.json"))}, JSON.stringify({ ok: false, error: String(error) }));
184+
}
185+
return {};
186+
},
187+
};
188+
`,
189+
)
190+
},
191+
})
192+
const listener = await startListener()
193+
try {
194+
const response = await withTimeout(
195+
fetch(new URL("/config", listener.url), {
196+
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
197+
}),
198+
5_000,
199+
"timed out waiting for plugin re-entry guarded request",
200+
)
201+
expect(response.status).toBe(200)
202+
const result = JSON.parse(await Bun.file(path.join(tmp.path, "plugin-client-result.json")).text()) as {
203+
ok?: boolean
204+
error?: unknown
205+
}
206+
expect(result.ok).toBe(false)
207+
expect(JSON.stringify(result.error)).toContain("Plugin client request cannot enter instance")
208+
} finally {
209+
await stop(listener, "timed out cleaning up plugin re-entry listener").catch(() => undefined)
210+
}
211+
})
212+
170213
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
171214
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
172215
const listener = await startListener()

0 commit comments

Comments
 (0)