fix(core): keep the generation swap when a plugin cleanup throws by Veld101 · Pull Request #57096 · anomalyco/opencode · GitHub
Skip to content

fix(core): keep the generation swap when a plugin cleanup throws - #57096

Open
Veld101 wants to merge 1 commit into
anomalyco:devfrom
Veld101:plugin-swap-cleanup
Open

Veld101 wants to merge 1 commit into
anomalyco:devfrom
Veld101:plugin-swap-cleanup

Conversation

@Veld101

@Veld101 Veld101 commented Oct 19, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #57089

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Plugin.activate closes the previous generation before loading the new one, and the close loop had no per-plugin isolation. A plugin whose finalizer throws aborted the whole activation. By then the old builtins had already been closed — they are last in activation order, so the reverse close loop reaches them first — and the new generation never loaded, so their transforms were never re-registered:

T2  reload fails: `failed to reload plugins` with the plugin's ReferenceError
T3  `opencode.config.compaction` is gone, SessionCompaction rebuilds from `{ auto: true }`
    and auto-compaction runs despite `compaction: { auto: false }`

Each old slot now closes independently:

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 })),
  )

A teardown failure is logged with the plugin id and cause instead of aborting the swap, so the new generation still loads and builtin registrations survive. load() already isolates setup failures; this closes the corresponding gap on the teardown path.

This is the minimal option from the report. Two larger options remain open and are not attempted here: staging the new generation before closing the old (plugins hold process-wide resources), and separating builtins from user-plugin generations so a user change cannot unload them. Either would make the swap transactional rather than best-effort.

How did you verify your code works?

One case in packages/core/test/plugin-failure.test.ts: broken mirrors the user plugin and builtin is last in activation order, like internal.post. Generation 2 replaces both, and the case asserts the swap succeeds and the builtin transform is re-registered with the new revision.

The throwing finalizer is registered only for the revision being replaced. That is deliberate: it keeps the surviving generation healthy so teardown at the end of the test does not add an unrelated failure. (With a finalizer that always throws, the fixed behaviour loads the broken plugin successfully and the case then fails at teardown instead, which would be testing the wrong thing.)

Against the current code the case fails: the ReferenceError escapes Plugin.activate and the builtin command is gone.

bun test test/plugin-failure.test.ts              # pass
bun test test/plugin test/plugin-failure.test.ts test/plugin.test.ts   # 420 pass, 0 fail
bun typecheck                                     # packages/core, clean
prettier --check                                  # clean
bun run lint                                      # exit 0, no new findings

Screenshots / recordings

Not applicable; no UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #49982, #52618

@github-actions

Copy link
Copy Markdown
Contributor

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is sound. On the base, the reproduction from #57089 fails: when a plugin's cleanup throws, activate() fails and the builtin's command is gone afterwards. With this PR the swap succeeds and the builtin's new revision is registered. Your new test fails without the fix and passes with it. The plugin test suites (422 tests) and the packages/core typecheck pass.

The new handling copies what the file already does when cleaning up disabled plugins (catchCause + log), so it fits. Interruption isn't a concern here, because Scope.close runs finalizers in a way that can't be interrupted.

Public API: unchanged.

No blocking concerns.

Related: #49982, #52618

@Jun516 Jun516 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One edge case in the new catch: Effect.catchCause also turns an interruption from a cleanup finalizer into successful activation. Could this use the existing Effect.catchCauseIf((cause) => !Cause.hasInterrupts(cause), ...) pattern used later in this file, and add an interruption regression?

I checked with the real PluginTestLayer and Bun 1.4.2 using this test body:

Effect.gen(function* () {
  const plugins = yield* Plugin.Service
  yield* plugins.activate([
    { id: "interrupted", revision: "1", effect: () => Effect.addFinalizer(() => Effect.interrupt) },
  ])
  const activation = yield* plugins.activate([]).pipe(Effect.forkChild({ startImmediately: true }))
  const exit = yield* Fiber.await(activation)
  expect(Exit.isFailure(exit)).toBe(true)
  if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true)
})

From packages/core: bun run script/test.ts test/plugin-failure.test.ts --test-name-pattern "preserves interruption during plugin cleanup".

The check passes on the pre-fix source, fails with the exact plugin.ts blob from this PR's 10e87b9 (Expected: true; Received: false), and passes with catchCauseIf. This is a local focused check, not a full-suite result. AI assistance was used for the test and analysis.

`Plugin.activate` closes the previous generation before loading the new one, and
the close loop had no per-plugin isolation. A plugin whose finalizer throws
aborted the whole activation: the old builtins had already been closed, the new
generation never loaded, and their transforms were never re-registered. Builtin
state then fell back to defaults, so a user plugin's broken cleanup silently
re-enabled `compaction.auto` despite `compaction: { auto: false }` (anomalyco#57089).

Each old slot now closes independently, logging `failed to close plugin` with the
id and cause. One plugin's teardown failure is reported instead of aborting the
swap, so the new generation still loads and builtins keep their registrations.

Adds a case with a finalizer that throws only for the revision being replaced, so
the surviving generation tears down normally and the assertion covers the swap
alone. It fails without the change: the error escapes `Plugin.activate` and the
builtin command is gone.
@Veld101
Veld101 force-pushed the plugin-swap-cleanup branch from 10e87b9 to 536dc02 Compare October 20, 2026 12:44
@Veld101

Veld101 commented Oct 20, 2026

Copy link
Copy Markdown
Author

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants