fix(plugin-ext): stop stale plugin host RPC protocols from hijacking replies - #17925
Draft
dr14-make wants to merge 1 commit into
Draft
fix(plugin-ext): stop stale plugin host RPC protocols from hijacking replies#17925dr14-make wants to merge 1 commit into
dr14-make wants to merge 1 commit into
Conversation
dr14-make
force-pushed
the
fix/stale-plugin-rpc-log-storm
branch
from
August 17, 2026 11:08
13ce107 to
e52c4ac
Compare
dr14-make
marked this pull request as draft
August 17, 2026 11:22
dr14-make
force-pushed
the
fix/stale-plugin-rpc-log-storm
branch
from
August 17, 2026 13:19
e52c4ac to
b797a4e
Compare
…replies When the frontend connection closes, `toDisconnect.dispose()` disposes the `RPCProtocolImpl` serving the plugin host, but `createServerRpc` discarded the `HostedPluginWatcher.onPostMessageEvent` disposable. The watcher is a singleton that outlives every connection and `pluginHostId` is the same on each of them, so the subscription survived and kept feeding plugin host messages into the disposed protocol's channel. On reconnect, `onDidOpenConnection` re-runs the load and adds a second protocol and subscription. Emitter dispatch is FIFO, so the disposed protocol saw every message first and answered it from an empty `locals` map, beating the current protocol to the reply; the correct reply then arrived second and was dropped as `No reply handler for reply with id: N`. Every main-side call from the plugin host therefore failed after the first reconnect. That failure also compounded. The plugin host routes `console.*` through `PluginLogger`, whose `$log` is dispatched as an RPC request because its name starts with neither `notify` nor `on`, and the returned promise was dropped. A failed log therefore became an unhandled rejection, which the plugin host reports with two `console.error` calls, each sending another failing `$log`. A single failed log grew into thousands per second until the application stopped responding. Dispose the subscription and close the channel with the protocol, announcing the close first so requests still in flight are rejected rather than left pending. Swallow the rejection in `PluginLogger` so a log failure can never report itself. Report messages handled after disposal as `ConnectionClosedError`, since an emptied `locals` map otherwise surfaces as a missing service registration and sends adopters looking for an absent binding. Signed-off-by: Dmitrij Rozdestvensky <dmitrij.rozdestvensky@juliahub.com>
dr14-make
force-pushed
the
fix/stale-plugin-rpc-log-storm
branch
from
August 17, 2026 14:06
b797a4e to
3db2d01
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What it does
Fixes #17924, which has the full mechanism and the prior reports.
After the frontend reconnects, a disposed
RPCProtocolImplkeeps answering the plugin host.createServerRpcdiscarded itsHostedPluginWatcher.onPostMessageEventdisposable, and that watcher is a singleton outliving every connection with a constantpluginHostId, so the dead protocol keeps receiving. Emitter dispatch is FIFO, so it answers from an emptylocalsmap before the live protocol does and wins the reply race — every main-side call from the plugin host fails. The plugin host then reports each failure throughPluginLogger, whose$logis an RPC request whose rejection was dropped, so the unhandled-rejection reporter logs through the same broken channel and one failed log becomes thousands per second.hosted-plugin.ts— dispose the subscription and close the channel with the protocol, announcing the close first so in-flight requests are rejected rather than left pending.logger.ts— swallow the$logrejection, so a log failure cannot report itself.rpc-protocol.ts— report messages handled after disposal asConnectionClosedError; an emptiedlocalsmap otherwise reads as a missing service registration.How to test
npm test --workspace @theia/plugin-ext. Two new specs, and I confirmed each fails with its half of the change reverted rather than assuming it:hosted/browser/hosted-plugin-rpc.spec.tspins the reply race. It wires a plugin host tocreateServerRpcthrough a shared watcher, serves one request, disposes that connection, opens a second, and asserts the answer comes from the live protocol. Against the pre-fixcreateServerRpcit fails withexpected 'first connection' to equal 'second connection'— the disposed protocol answering first.common/rpc-protocol.spec.tscovers the post-disposal error, the in-flight rejection on channel close, and the logger no longer leaving an unhandled rejection.Worth noting from writing that first test: with the subscription disposal alone reverted the test still passes, because closing the channel already stops delivery. The two teardown steps overlap — the subscription disposal is what stops the listener accumulating per reconnect, the channel close is what stops a disposed protocol receiving.
Manually, with the default
frontendConnectionTimeout: suspend and wake the machine on an Electron app with plugins active. Before, the log fills with pairedno local service handler with id LoggerMainmessages at an accelerating rate and the application stops responding; after, they are gone and it stays responsive.Follow-ups
Recorded in #17924 rather than here: the teardown also leaves the plugin session unusable (the plugin host is re-forked, so extensions lose their state, while main-side registries keep the previous connection's registrations), and it only happens at all because the default
frontendConnectionTimeout: 0makes a successful reconnect impossible. Also unexplained there: a duplicate-reply stream that survives this fix.Two smaller ones:
PluginWorkerleaks itsworker.onmessage/onerrorwiring the same way and never terminates theWorker; andConnectionClosedError.is()can never match a received error, because msgpackr carriesname/message/stackbut not the customcode.Breaking changes
HostedPluginSupport.initRpcandcreateServerRpctake an additionaltoDisconnect: DisposableCollection, matching how the rest of the file passes it around. Both areprotected: a subclass that calls them no longer compiles, and one that overridescreateServerRpcwith the old signature still compiles but silently misses the teardown. The alternative that avoids the break is a composite disposable covering subscription, channel and protocol so the existingtoDisconnect.push(rpc)carries all three — happy to switch.Attribution
Parts of this change and its investigation were produced with AI assistance. I have read and verified every line of the diff, reproduced the bug and the fix myself, and am accountable for the contribution.
Review checklist
nlsservice (for details, please see the Internationalization/Localization section in the Coding Guidelines)Reminder for reviewers