Skip to content

Commit 4233bea

Browse files
committed
fix: wait for plugins to complete before exit
- Track pending plugin event handlers in plugin service - Add OPENCODE_EXPERIMENTAL_PLUGIN_EXIT_DEFAULT_TIMEOUT_MS env var (default: 60000ms) - Wait for pending plugin events when session becomes idle - Await event loop completion in run command before exiting Fixes issue where plugins were interrupted mid-execution when opencode-dev run exits, causing incomplete plugin processing.
1 parent 38deb0f commit 4233bea

3 files changed

Lines changed: 42 additions & 4 deletions

File tree

packages/opencode/src/cli/cmd/run.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { BashTool } from "../../tool/bash"
2727
import { TodoWriteTool } from "../../tool/todo"
2828
import { Locale } from "../../util"
2929
import { AppRuntime } from "@/effect/app-runtime"
30+
import { Plugin } from "../../plugin"
3031

3132
type ToolProps<T> = {
3233
input: Tool.InferParameters<T>
@@ -534,6 +535,12 @@ export const RunCommand = cmd({
534535
event.properties.sessionID === sessionID &&
535536
event.properties.status.type === "idle"
536537
) {
538+
// Wait for plugins to finish processing the session.idle event
539+
if (!args.attach) {
540+
await AppRuntime.runPromise(Plugin.Service.use((svc) => svc.waitForPendingEvents())).catch((e) => {
541+
console.error("Failed to wait for pending plugin events:", e)
542+
})
543+
}
537544
break
538545
}
539546

@@ -631,7 +638,7 @@ export const RunCommand = cmd({
631638
}
632639
await share(sdk, sessionID)
633640

634-
loop().catch((e) => {
641+
const loopPromise = loop().catch((e) => {
635642
console.error(e)
636643
process.exit(1)
637644
})
@@ -655,6 +662,8 @@ export const RunCommand = cmd({
655662
parts: [...files, { type: "text", text: message }],
656663
})
657664
}
665+
666+
await loopPromise
658667
}
659668

660669
if (args.attach) {

packages/opencode/src/flag/flag.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const Flag = {
6666
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
6767
OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"),
6868
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
69+
OPENCODE_EXPERIMENTAL_PLUGIN_EXIT_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_PLUGIN_EXIT_DEFAULT_TIMEOUT_MS"),
6970
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
7071
OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
7172
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),

packages/opencode/src/plugin/index.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const log = Log.create({ service: "plugin" })
3030

3131
type State = {
3232
hooks: Hooks[]
33+
pendingEvents: Set<Promise<void>>
3334
}
3435

3536
// Hook names that follow the (input, output) => Promise<void> trigger pattern
@@ -49,6 +50,7 @@ export interface Interface {
4950
) => Effect.Effect<Output>
5051
readonly list: () => Effect.Effect<Hooks[]>
5152
readonly init: () => Effect.Effect<void>
53+
readonly waitForPendingEvents: (timeoutMs?: number) => Effect.Effect<void>
5254
}
5355

5456
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -111,6 +113,7 @@ export const layer = Layer.effect(
111113
const state = yield* InstanceState.make<State>(
112114
Effect.fn("Plugin.state")(function* (ctx) {
113115
const hooks: Hooks[] = []
116+
const pendingEvents = new Set<Promise<void>>()
114117
const bridge = yield* EffectBridge.make()
115118

116119
function publishPluginError(message: string) {
@@ -245,14 +248,21 @@ export const layer = Layer.effect(
245248
Stream.runForEach((input) =>
246249
Effect.sync(() => {
247250
for (const hook of hooks) {
248-
void hook["event"]?.({ event: input as any })
251+
const eventHandler = hook["event"]
252+
if (!eventHandler) continue
253+
const promise = Promise.resolve()
254+
.then(() => eventHandler({ event: input as any }))
255+
.finally(() => {
256+
pendingEvents.delete(promise)
257+
})
258+
pendingEvents.add(promise)
249259
}
250260
}),
251261
),
252262
Effect.forkScoped,
253263
)
254264

255-
return { hooks }
265+
return { hooks, pendingEvents }
256266
}),
257267
)
258268

@@ -280,7 +290,25 @@ export const layer = Layer.effect(
280290
yield* InstanceState.get(state)
281291
})
282292

283-
return Service.of({ trigger, list, init })
293+
const waitForPendingEvents = Effect.fn("Plugin.waitForPendingEvents")(function* (timeoutMs?: number) {
294+
const s = yield* InstanceState.get(state)
295+
const timeout = timeoutMs ?? Flag.OPENCODE_EXPERIMENTAL_PLUGIN_EXIT_DEFAULT_TIMEOUT_MS ?? 60000
296+
297+
yield* Effect.tryPromise({
298+
try: async () => {
299+
// Wait a tick to let event handlers be added to pendingEvents
300+
await Promise.resolve()
301+
const pending = Array.from(s.pendingEvents)
302+
if (pending.length === 0) return
303+
await Promise.race([Promise.all(pending), new Promise<void>((resolve) => setTimeout(resolve, timeout))])
304+
},
305+
catch: (err) => {
306+
log.error("failed to wait for pending plugin events", { error: err })
307+
},
308+
}).pipe(Effect.ignore)
309+
})
310+
311+
return Service.of({ trigger, list, init, waitForPendingEvents })
284312
}),
285313
)
286314

0 commit comments

Comments
 (0)