Skip to content

core: CPU-heavy plugin setup and full-scope teardown starve the shared event loop #55272

Open

Description

@mradwankhalil

Description

CPU-heavy plugin setup and finalizers can monopolize the shared V2 server's JavaScript turn, delaying HTTP responses and unrelated locations' event streams. Full location disposal is especially important: yielding only on the plugin reload/replacement path does not cover Plugin.Service.close closing the parent scope.

Local recorded experiment: three locations, thirty plugins per location, each cleanup synchronously busy-waiting for 80 ms. Disposing the locations together produced a 7.28-second gap in the global event stream. That measurement is from the local fix's recorded experiment, not a fresh benchmark run for this filing. The unmodified v2.0.28 lifecycle paths were inspected for this report.

A local prototype adds native event-loop checkpoints between initialization operations and after each plugin's cleanup on every scope-close path. It uses a one-permit Effect.sleep(0) checkpoint, not merely an already-resolved promise or fiber-only yield. The permit is released before asynchronous setup; holding it around setup can deadlock cross-location/plugin dependencies. Registering the teardown checkpoint as a plugin scope's first finalizer makes LIFO scope closure run it after that plugin's cleanup, including failed setup and full location disposal. A separate close permit prevents setup admission from blocking teardown progress.

This cannot preempt a single plugin's synchronous work. It bounds uninterrupted work across a sequence of plugins while retaining parent-scope ordering, error collection, and exactly-once closure. Local coverage includes timer progress across overlapping initialization checkpoints and close-path coverage in plugin-burst.test.ts, plus existing plugin/failure suites; these suites were not rerun for this issue filing.

Related: #42898 reports unavailability following hot reload; #53393/#53764 reduce unnecessary compatibility reloads. This report isolates native event-loop starvation during legitimate setup and full disposal, even when no config-reload loop exists.

Plugins

Synthetic native V2 fixture plugins; no provider or MCP connection is required.

OpenCode version

2.0.28 source inspected. Local prototype also carried on earlier 2.0.25 builds; currently carried in a patched 2.0.28 r1 build.

Steps to reproduce

  1. Keep an event-stream subscriber and lightweight HTTP probe active in one warm location.
  2. In three other locations configure thirty native plugins each. Each plugin's setup returns a cleanup function that synchronously busy-waits for 80 ms using performance.now().
  3. Activate those locations, then dispose them together through the normal location-close path. Measure event arrival gaps and probe latency from a separate process.
  4. Repeat with CPU-bound setup to exercise initialization. Compare stock with checkpoints; a timer in the same server process should make progress between plugins.

Expected: unrelated locations continue to receive events and HTTP progress between plugins. Actual: aggregate lifecycle work can silence the shared event stream for seconds. Bound reload/eviction concurrency separately; it is not a substitute for scheduling between finalizers.

Screenshot and/or share link

No private session content needed; fixture shape and recorded measurement above.

Operating System

Windows 11, win32 x64.

Terminal

OpenChamber desktop client; reproduction uses the backend event stream.

Activity

  1. opencode-agent commented on Oct 14, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on the latest dev (73517183b) at the core plugin service level.

    The script below is a bun test for packages/core. It activates 30 plugins that each busy-wait 80 ms and runs a 5 ms setInterval in the same process to measure gaps:

    • Teardown: each plugin adds a finalizer that busy-waits, then Plugin.Service.close closes the parent scope. Closing took about 2.4 s, and the timer fired only 3 times (longest gap 2401 ms).
    • Setup: each plugin's setup busy-waits. Activation took about 2.4 s, with a longest timer gap of 2403 ms.

    The gap is about 30 × 80 ms, so other work in the process doesn't run between plugins, on either path. I didn't run the 2.0.28 release itself, but the plugin lifecycle code in v2.0.28 is identical to dev. I measured this inside the core plugin service, not through the HTTP server or the event stream.

    Reproduction script (plugin-starve-repro.test.ts)
    // Repro for anomalyco/opencode#55272: plugin cleanup/setup monopolize the event loop.
    import { expect } from "bun:test"
    import { Effect, Exit } from "effect"
    import { Plugin } from "@opencode/core/plugin"
    import { testEffect } from "./lib/effect"
    import { PluginTestLayer } from "./plugin/fixture"
    
    const it = testEffect(PluginTestLayer)
    const N = 30
    const BUSY = 80
    const busy = () => {
      const end = performance.now() + BUSY
      while (performance.now() < end) {}
    }
    
    function monitor() {
      let last = performance.now()
      let maxGap = 0
      let ticks = 0
      const t = setInterval(() => {
        const now = performance.now()
        maxGap = Math.max(maxGap, now - last)
        last = now
        ticks++
      }, 5)
      return {
        stop: () => {
          clearInterval(t)
          maxGap = Math.max(maxGap, performance.now() - last)
          return { maxGap: Math.round(maxGap), ticks }
        },
      }
    }
    
    it.live("teardown: timer progress while closing plugin scope", () =>
      Effect.gen(function* () {
        const plugins = yield* Plugin.Service
        yield* plugins.activate(
          Array.from({ length: N }, (_, i) => ({
            id: `p${i}`,
            revision: "1",
            effect: () => Effect.addFinalizer(() => Effect.sync(busy)),
          })),
        )
        yield* Effect.sleep("50 millis")
        const m = monitor()
        const start = performance.now()
        yield* plugins.close(Exit.void)
        yield* Effect.promise(() => new Promise((r) => setTimeout(r, 20)))
        const r = m.stop()
        console.log(`[teardown] ${N} plugins x ${BUSY}ms: close took ${Math.round(performance.now() - start)}ms, max timer gap ${r.maxGap}ms, ticks ${r.ticks}`)
        expect(r.maxGap).toBeLessThan(BUSY * 3)
      }),
    )
    
    it.live("setup: timer progress while activating CPU-heavy plugins", () =>
      Effect.gen(function* () {
        const plugins = yield* Plugin.Service
        const m = monitor()
        const start = performance.now()
        yield* plugins.activate(
          Array.from({ length: N }, (_, i) => ({
            id: `s${i}`,
            revision: "1",
            effect: () => Effect.sync(busy),
          })),
        )
        yield* Effect.promise(() => new Promise((r) => setTimeout(r, 20)))
        const r = m.stop()
        console.log(`[setup] ${N} plugins x ${BUSY}ms: activate took ${Math.round(performance.now() - start)}ms, max timer gap ${r.maxGap}ms, ticks ${r.ticks}`)
        expect(r.maxGap).toBeLessThan(BUSY * 3)
      }),
    )

    Run it: bun plugin-starve-repro.test.ts

  2. mradwankhalil commented on Oct 23, 2026

    @mradwankhalil
    ContributorAuthor

    Thanks for the independent reproduction. The roughly 2.4 s timer gaps on both setup and teardown are core-plugin-service evidence, not an HTTP-server measurement. The proposed mitigation is tracked in #55754; its scheduling and test-scope review points remain open, so a passing reproduction alone is not being treated as design approval.

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