Skip to content

Disposing one plugin instance shuts down other directories' active bridges #307

Description

@null-axiom

Environment

  • @cortexkit/aft-opencode 0.55.1
  • macOS, OpenCode Desktop
  • Multiple directory-scoped plugin instances in the same host process

Summary

Disposing one directory's AFT plugin instance runs a process-wide cleanup registry. This shuts down bridge pools belonging to other, still-active plugin instances and causes outstanding shell calls to fail with:

[aft-plugin] Bridge shutting down

The host uses directory-keyed plugin state and invokes each instance's dispose() when that directory is unloaded. AFT creates a separate transport pool for each factory invocation, but its returned dispose() invokes all registered cleanups rather than only that invocation's cleanup.

Observed sequence

On September 10, 2026, the host disposed one project while shell calls were running in another. Project names below are replaced with A and B; timestamps are UTC.

Timestamp Event
15:36:02.097 OpenCode logs disposing instance for project A.
15:36:02.099 AFT logs Shutdown triggered by dispose — running 8 cleanup(s).
15:36:02.103 Two outstanding Bash calls in project B return, coinciding with the shutdown.
15:36:02.129 AFT logs three Process exited during shutdown messages.
15:36:05.237 New tool demand triggers transport was shut down but new demand arrived — reviving (host quit hook fired without process exit?).
15:36:05.240 AFT spawns a replacement bridge.

The process-exit messages describe AFT bridge children, not proof that the OpenCode host exited. OpenCode continued logging under the same run ID. The parenthetical in the revival warning is speculative; the explicit shutdown reason was dispose.

Installed-source evidence

References below are to the installed package's dist/index.js in version 0.55.1.

Each factory invocation owns a separate pool

At lines 40413–40414, the plugin factory delegates to initializePluginForDirectory(input). Each invocation creates its transport pool at lines 40651–40678. The standalone transport factory constructs a new BridgePool at lines 16430–16448.

Cleanup registration is process-wide

Lines 36868–36874:

var GLOBAL_KEY = "__aftShutdownHooks__";
function getState() {
  const g = globalThis;
  if (!g[GLOBAL_KEY]) {
    g[GLOBAL_KEY] = { cleanups: new Set, installed: false };
  }
  return g[GLOBAL_KEY];
}

At lines 40747–40761, each factory invocation registers a cleanup closure capturing its own pool. That cleanup also stops its RPC server, clears subscriptions, and ultimately executes:

await pool.shutdown();

Individual disposal invokes all cleanups

Lines 41056–41058:

dispose: async () => {
  await runCleanups("dispose");
}

At lines 36877–36894, runCleanups obtains the global registry, copies every callback, clears the set, and awaits all callbacks with Promise.allSettled.

The resulting ownership mismatch is:

factory A -> pool A -> cleanup A registered globally
factory B -> pool B -> cleanup B registered globally

dispose(A)
  -> runCleanups("dispose")
  -> cleanup A -> pool A.shutdown()
  -> cleanup B -> pool B.shutdown()

Host lifecycle

The inspected OpenCode host uses directory-keyed InstanceState scopes. Directory disposal invalidates that directory's ScopedCache entry, closes its scope, and invokes the corresponding plugin hooks' dispose() methods.

Relevant host source:

  • packages/opencode/src/project/instance-store.ts: instance disposal and registered disposers.
  • packages/opencode/src/effect/instance-state.ts:26–40: directory-keyed cache and invalidation registration.
  • packages/opencode/src/plugin/index.ts:306–319: plugin-state finalizer invoking hook.hooks.dispose?.().

This lifecycle does not require unrelated directories or the entire host process to be disposed. The report comes from a custom OpenCode build; stock-host reproduction has not been performed.

Expected behavior

Disposing one plugin instance should clean up only that instance's owned resources. Other directories' bridges and outstanding calls should remain unaffected.

Global cleanup is appropriate for actual process shutdown, but should not be used as an individual factory instance's disposal operation. Instance disposal should also remove its own registration from the global shutdown registry and remain safe when called repeatedly.

Suggested regression test

This isolated test has not yet been executed. The evidence above consists of the observed runtime failure and direct installed-source inspection.

  1. Invoke the plugin factory twice in the same JavaScript realm with distinct directories, producing hooks A and B.
  2. Start a bridge for each instance and hold a tool call open in B.
  3. Call only await hooksA.dispose().
  4. Assert that A's resources are cleaned up, while B's bridge and held call remain active.
  5. Complete B's call and verify normal result delivery.
  6. Dispose B and verify its resources are cleaned up without duplicate cleanup of A.

Scope

This report concerns cross-instance shutdown and interrupted tool calls. Separately observed memory-pressure spikes have not been attributed to this defect and are not part of this report.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions