Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks:
}
}

function pluginClientReentryResponse(directory: string) {
return new Response(`Plugin client request cannot enter instance ${directory} while its plugins are still loading`, {
status: 409,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
}

const layer = Layer.effect(
Service,
Effect.gen(function* () {
Expand All @@ -139,11 +148,17 @@ const layer = Layer.effect(
const { Server } = yield* Effect.promise(() => import("../server/server"))

const serverUrl = Server.url
let bootstrapping = true
const client = createOpencodeClient({
baseUrl: serverUrl?.toString() ?? "http://localhost:4096",
directory: ctx.directory,
headers: ServerAuth.headers(),
...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }),
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
if (bootstrapping) return pluginClientReentryResponse(ctx.directory)
if (serverUrl) return globalThis.fetch(request)
return Server.Default().app.fetch(request)
},
})
const cfg = yield* config.get()
const input: PluginInput = {
Expand Down Expand Up @@ -248,6 +263,8 @@ const layer = Layer.effect(
)
}

bootstrapping = false

const unsubscribe = yield* events.listen((event) => {
if (event.location?.directory !== ctx.directory) return Effect.void
return Effect.sync(() => {
Expand Down
43 changes: 43 additions & 0 deletions packages/opencode/test/server/httpapi-listen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,49 @@ async function openPtySocket(listener: Awaited<ReturnType<typeof startListener>>
}

describe("HttpApi Server.listen", () => {
test("rejects plugin client re-entry while plugins are loading", async () => {
await using tmp = await tmpdir({
config: { formatter: false, lsp: false, plugin: ["./server-plugin.js"] },
init: async (dir) => {
await Bun.write(
path.join(dir, "server-plugin.js"),
`export default {
id: "plugin-client-reentry-probe",
async server(input) {
try {
const result = await input.client.config.get();
await Bun.write(${JSON.stringify(path.join(dir, "plugin-client-result.json"))}, JSON.stringify({ ok: !result.error, error: result.error }));
} catch (error) {
await Bun.write(${JSON.stringify(path.join(dir, "plugin-client-result.json"))}, JSON.stringify({ ok: false, error: String(error) }));
}
return {};
},
};
`,
)
},
})
const listener = await startListener()
try {
const response = await withTimeout(
fetch(new URL("/config", listener.url), {
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
}),
5_000,
"timed out waiting for plugin re-entry guarded request",
)
expect(response.status).toBe(200)
const result = JSON.parse(await Bun.file(path.join(tmp.path, "plugin-client-result.json")).text()) as {
ok?: boolean
error?: unknown
}
expect(result.ok).toBe(false)
expect(JSON.stringify(result.error)).toContain("Plugin client request cannot enter instance")
} finally {
await stop(listener, "timed out cleaning up plugin re-entry listener").catch(() => undefined)
}
})

testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const listener = await startListener()
Expand Down
Loading