@@ -32,6 +32,12 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
3232
3333const log = Log . create ( { service : "plugin" } )
3434
35+ // Synchronous shutdown hooks invoked from src/index.ts's top-level finally
36+ // before forceFlush. Plugins register here when loaded; runs once per
37+ // process before process.exit(). Module-level intentionally — needs to be
38+ // reachable outside the Effect runtime.
39+ export const pluginShutdownHooks = new Set < ( ) => void > ( )
40+
3541type State = {
3642 hooks : Hooks [ ]
3743}
@@ -245,29 +251,21 @@ export const layer = Layer.effect(
245251 }
246252
247253 // Subscribe to bus events, fiber interrupted when scope closes.
248- // session.idle and server.instance.disposed are plugins' only chance to
249- // drain async work (e.g. OTel span exporters) before src/index.ts's
250- // top-level finally runs forceFlush and calls process.exit() — await
251- // those handlers; keep the rest fire-and-forget for throughput.
254+ // Isolate per-hook failures (sync throw or async rejection) so one bad
255+ // plugin can't kill the subscription fiber and silently disable every
256+ // other plugin's event handler for the rest of the process.
252257 yield * bus . subscribeAll ( ) . pipe (
253258 Stream . runForEach ( ( input ) =>
254- Effect . promise ( async ( ) => {
255- const awaitHook = input . type === "server.instance.disposed" || input . type === "session.idle"
259+ Effect . sync ( ( ) => {
256260 for ( const hook of hooks ) {
257261 try {
258262 const ret = hook [ "event" ] ?.( { event : input as any } )
259- if ( awaitHook && ret ) {
260- await ret
261- } else if ( ret ) {
262- // Fire-and-forget path: surface async failures to logs instead of letting them
263- // become unhandledRejections that hide which plugin/event broke.
263+ if ( ret ) {
264264 void Promise . resolve ( ret ) . catch ( ( err ) =>
265265 log . error ( "plugin event hook failed" , { error : err } ) ,
266266 )
267267 }
268268 } catch ( err ) {
269- // Catches sync throws + awaited async rejections so one bad plugin can't kill
270- // the subscription fiber and silently disable every other plugin.
271269 log . error ( "plugin event hook failed" , { error : err } )
272270 }
273271 }
@@ -276,6 +274,25 @@ export const layer = Layer.effect(
276274 Effect . forkScoped ,
277275 )
278276
277+ // Register synchronous shutdown hooks for the top-level finally in
278+ // src/index.ts. Runs before forceFlush so plugins can end any open
279+ // OTel spans (e.g. bcode-laminar's turn span) — the bus-based
280+ // session.idle / server.instance.disposed paths race with scope
281+ // teardown and don't reliably deliver, so plugins need a direct sync
282+ // entry point. Deregister on instance disposal so multi-instance TUI
283+ // mode doesn't accumulate stale closures across reopens.
284+ const registered : Array < ( ) => void > = [ ]
285+ for ( const hook of hooks ) {
286+ if ( ! hook . shutdown ) continue
287+ pluginShutdownHooks . add ( hook . shutdown )
288+ registered . push ( hook . shutdown )
289+ }
290+ yield * Effect . addFinalizer ( ( ) =>
291+ Effect . sync ( ( ) => {
292+ for ( const fn of registered ) pluginShutdownHooks . delete ( fn )
293+ } ) ,
294+ )
295+
279296 return { hooks }
280297 } ) ,
281298 )
0 commit comments