Repository navigation
core: CPU-heavy plugin setup and full-scope teardown starve the shared event loop #55272
Description
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
- Keep an event-stream subscriber and lightweight HTTP probe active in one warm location.
- 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(). - Activate those locations, then dispose them together through the normal location-close path. Measure event arrival gaps and probe latency from a separate process.
- 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
opencode-agent commentedon Oct 14, 2026
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 mssetIntervalin the same process to measure gaps:Plugin.Service.closecloses the parent scope. Closing took about 2.4 s, and the timer fired only 3 times (longest gap 2401 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.28is identical todev. I measured this inside the core plugin service, not through the HTTP server or the event stream.Reproduction script (plugin-starve-repro.test.ts)
Run it:
bun plugin-starve-repro.test.tsmradwankhalil commentedon Oct 23, 2026
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.