Skip to content

A throwing plugin cleanup during reload aborts the generation swap and silently drops builtin registrations #57089

Open

Description

@yangyaofei

Summary

A plugin reload failed with failed to reload plugins. After that, sessions started auto-compacting even though the config had compaction.auto: false.

The failure is in the plugin generation swap: the registry closes the old generation before loading the new one. A cleanup finalizer that throws aborts the whole swap. Builtin plugins, including opencode.config.compaction, have already been closed and are not re-registered. SessionCompaction then rebuilds from its default { auto: true }, so the auto-compaction gate no longer skips. A later successful reload restores the builtin and the compactions stop.

Environment

  • OpenCode v2.0.32 on macOS.
  • Config contains compaction: { auto: false }.
  • A user plugin whose cleanup throws a ReferenceError after a refactor (the cleanup references a binding that was removed from the setup scope).

Timeline

  • T0: the plugin loads and works.
  • T1: a refactor changes the plugin's cleanup; the plugin still loads (setup is fine), but the cleanup now references a binding that no longer exists.
  • T2: a reload is triggered by a config/plugin change. The reload fails; the log shows failed to reload plugins with the ReferenceError.
  • T3: sessions begin to auto-compact even though compaction.auto: false; compaction records show reason: auto and reason: overflow.
  • T4: further reload attempts fail the same way.
  • T5: after the trigger is removed (the cleanup no longer throws, or the plugin is removed), the next reload succeeds; the builtin is restored; no further auto compaction.

Root cause

  1. Activation order (packages/core/src/plugin/supervisor.ts) is internal.pre → user plugins → internal.post. ConfigCompactionPlugin is in internal.post, i.e. last.
  2. Plugin.activate() (packages/core/src/plugin.ts) closes the old generation first, in reverse activation order, then loads the new generation. The close loop has no per-plugin catch; a throwing finalizer propagates and aborts the whole activation.
  3. load() isolates only the plugin effect: it wraps it in Effect.exit, logs failed to load plugin, and can restore the previous activation. The failure here is in the close/unload path, outside load, so that isolation does not apply. (The log shows failed to reload plugins, not failed to load plugin.)
  4. SessionCompaction starts from initial: () => ({ auto: true, keep: 15_000 }) (packages/core/src/session/compaction.ts). The builtin config plugin's transform is what applies auto: false from the config. With the transform gone, state.get() rebuilds to the default true, and the gate if (trigger.reason !== "manual" && !settings.auto) return { status: "skipped" } no longer skips.
  5. Because builtins are last in activation order, the reverse close loop disposes them first. By the time the user plugin's cleanup throws, the builtin has already been unloaded; the new generation never loads, so it is not restored.

Repro

The complete repro is inline below. It does not reference any local file, gist, or external
link. Add it as packages/core/test/plugin-generation-failure.test.ts in the OpenCode repo and
run:

cd packages/core && bun test test/plugin-generation-failure.test.ts
# 1 pass, 0 fail
import { expect } from "bun:test"
import { Effect } from "effect"
import { Command } from "@opencode/core/command"
import { Plugin } from "@opencode/core/plugin"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"

const it = testEffect(PluginTestLayer)

const broken = (revision: string) => ({
  id: "broken",
  revision,
  effect: () =>
    Effect.gen(function* () {
      yield* Effect.addFinalizer(() => Effect.die(new ReferenceError("cleanup binding is not defined")))
    }),
})

const builtin = (revision: string) => ({
  id: "builtin",
  revision,
  effect: (ctx: any) =>
    ctx.command.transform((editor: any) => {
      editor.add({ name: "builtin", description: revision, execute: () => Effect.void })
    }),
})

it.live("repro: a cleanup die during the generation swap drops the builtin transform", () =>
  Effect.gen(function* () {
    const plugins = yield* Plugin.Service
    const commands = yield* Command.Service

    yield* plugins.activate([broken("1"), builtin("1")] as any)
    expect((yield* commands.get("builtin"))?.description).toBe("1")

    const exit = yield* plugins.activate([broken("2"), builtin("2")] as any).pipe(Effect.exit)
    expect(exit._tag).toBe("Failure")
    expect(yield* commands.get("builtin")).toBeUndefined()
  }),
)

The test models the order: broken is the user plugin, builtin is last like internal.post. In generation 1 the builtin command exists. In generation 2 the reverse close loop disposes the builtin first; the broken plugin's finalizer dies; activate() fails; commands.get("builtin") is undefined. The test asserts the current buggy behavior, so it passes as a reproduction; after a fix, invert the assertion.

Possible fix

Minimal (recommended): catch and log each old slot's close failure so one plugin's cleanup cannot abort the swap:

yield* Effect.forEach(
  Array.from(previous.entries()).toReversed(),
  ([id, slot]) =>
    Effect.gen(function* () {
      active.delete(id)
      if (slot.activation && !slot.activation.failure)
        yield* Scope.close(slot.activation.scope, Exit.void).pipe(
          Effect.catchCause((cause) =>
            Effect.logError("failed to close plugin", { "plugin.id": id, cause }),
          ),
        )
    }),
  { discard: true },
)

This lets the new generation load and re-register builtins; the bad plugin's cleanup failure is logged and isolated.

Stronger options:

  • Activate the new generation before closing the old, or keep the previous generation if activation fails (transactional swap). Tradeoff: plugins may hold process-wide resources, so coexistence needs staging.
  • Separate builtins from user-plugin generations (or keep them in the unchanged prefix) so user plugin changes cannot unload them. Larger architectural change.
  • On activation failure, surface the failed generation instead of silently running with defaults; at minimum log the failing plugin id.

Regression tests: invert the repro to assert the builtin survives after a throwing cleanup; add a case for a throwing finalizer in a user plugin.

Expected behavior

A user plugin failure should never silently reset builtin state or bypass configuration such as compaction.auto: false.

Activity

  1. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report and the test. I confirmed this on v2.0.32 and it still happens on the latest dev (f394d9d410bc).

    The script below is a two-case bun test that goes in packages/core/test/:

    • Control: a user plugin with a normal cleanup, followed by a builtin-like plugin (activated last, like internal.post) that registers a command. Swapping to a new generation works and the builtin's new revision is registered.
    • Bug: the same, but the user plugin's cleanup dies with a ReferenceError. The second activate() fails, and afterwards the builtin's command is gone. The old builtin was closed first (reverse order) and the new generation never loaded.

    This matches your analysis: the close loop in Plugin.activate (packages/core/src/plugin.ts) has no per-plugin error handling. I tested the mechanism with a command transform and didn't run the full compaction.auto: false path end to end, but opencode.config.compaction is applied through the same kind of transform.

    Related: #49982 (a failed reload drops custom agents and commands) and #52618 (a failed reload wipes the provider registry).

    Reproduction script (plugin-generation-failure.test.ts)
    // Copy to packages/core/test/plugin-generation-failure.test.ts and run:
    //   cd packages/core && bun test test/plugin-generation-failure.test.ts
    import { expect } from "bun:test"
    import { Effect } from "effect"
    import { Command } from "@opencode/core/command"
    import { Plugin } from "@opencode/core/plugin"
    import { testEffect } from "./lib/effect"
    import { PluginTestLayer } from "./plugin/fixture"
    
    const it = testEffect(PluginTestLayer)
    
    // User plugin: setup is fine, cleanup throws (e.g. ReferenceError after a refactor).
    const broken = (revision: string, throws: boolean) => ({
      id: "broken",
      revision,
      effect: () =>
        Effect.addFinalizer(() =>
          throws ? Effect.die(new ReferenceError("cleanup binding is not defined")) : Effect.void,
        ),
    })
    
    // Stands in for an internal.post builtin such as opencode.config.compaction (activated last).
    const builtin = (revision: string) => ({
      id: "builtin",
      revision,
      effect: (ctx: any) =>
        ctx.command.transform((editor: any) => {
          editor.add({ name: "builtin", description: revision, execute: () => Effect.void })
        }),
    })
    
    it.live("control: a clean cleanup swaps generations and keeps the builtin", () =>
      Effect.gen(function* () {
        const plugins = yield* Plugin.Service
        const commands = yield* Command.Service
        yield* plugins.activate([broken("1", false), builtin("1")] as any)
        yield* plugins.activate([broken("2", false), builtin("2")] as any)
        expect((yield* commands.get("builtin"))?.description).toBe("2")
      }),
    )
    
    it.live("bug: a throwing cleanup aborts the swap and the builtin disappears", () =>
      Effect.gen(function* () {
        const plugins = yield* Plugin.Service
        const commands = yield* Command.Service
        yield* plugins.activate([broken("1", true), builtin("1")] as any)
        expect((yield* commands.get("builtin"))?.description).toBe("1")
    
        const exit = yield* plugins.activate([broken("2", true), builtin("2")] as any).pipe(Effect.exit)
        console.log("activate exit:", exit._tag)
        console.log("builtin after failed swap:", yield* commands.get("builtin"))
        // Expected: builtin "2" registered (cleanup failure isolated). Actual: activate fails, builtin gone.
        expect(exit._tag).toBe("Success")
        expect((yield* commands.get("builtin"))?.description).toBe("2")
      }),
    )

    Run it: bun plugin-generation-failure.test.ts

  2. Jun516 commented on Oct 19, 2026

    @Jun516

    I'd like to work on this if it is still available. I'll keep the fix focused on completing plugin-generation reloads after a cleanup throws, with regressions for builtin registrations and cleanup order. I checked #55754; its scheduling change does not cover this failure. I'll use Codex to prepare and test the patch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions