Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

- [ai-ide] added an opt-in "Memory" prompt capability that lets agents maintain a wiki-style knowledge base per workspace, stored in the workspace metadata store and exposed to prompts via the new `{{memoryDirectory}}` variable [#17865](https://github.com/eclipse-theia/theia/pull/17865)
- [core, monaco] fixed Monaco theme CSS, `SelectComponent` dropdown placement, and the OS font class in secondary windows [#17874](https://github.com/eclipse-theia/theia/pull/17874)
- [plugin-ext] fixed a disposed plugin host RPC protocol continuing to answer requests after a reconnect, which made every main-side call from the plugin host fail and, through the plugin host logger, multiplied one failed log into thousands per second [#17925](https://github.com/eclipse-theia/theia/pull/17925)
- [scm] aligned the history graph with VS Code: ref-role lane and badge colors, a current-commit indicator, and a commit hover rendered from the content the history provider supplies [#17880](https://github.com/eclipse-theia/theia/pull/17880)
- [scm-extra] deprecated `@theia/scm-extra` package [#17882](https://github.com/eclipse-theia/theia/pull/17882)

Expand All @@ -16,6 +17,7 @@
- [ai-ide] removed `WorkspaceFunctionScope.ensureWithinWorkspace(targetUri, workspaceRootUri)`. Every path-taking AI tool now resolves and checks its argument through `WorkspaceFunctionScope.resolveAccessiblePath(pathOrUri)`, which in addition to the workspace roots accepts locations covered by the `ai-features.workspaceFunctions.allowedExternalPaths` preference or contributed via the new `AccessibleRootContribution`. Adopters that called `ensureWithinWorkspace`, or `resolveRelativePath` followed by their own boundary check, should call `resolveAccessiblePath` instead [#17865](https://github.com/eclipse-theia/theia/pull/17865)
- [core] added `onWindowLoaded` to the `SecondaryWindowService` interface; adopters implementing the interface from scratch (rather than extending `DefaultSecondaryWindowService`) must provide it [#17874](https://github.com/eclipse-theia/theia/pull/17874)
- [monaco] removed the `protected secondaryWindowHandler` field from `MonacoFrontendApplicationContribution`; the Monaco theme stylesheet is now injected into every secondary window via `SecondaryWindowService.onWindowLoaded` [#17874](https://github.com/eclipse-theia/theia/pull/17874)
- [plugin-ext] added a `toDisconnect: DisposableCollection` parameter to the `protected` `HostedPluginSupport.initRpc` and `HostedPluginSupport.createServerRpc`, so that the watcher subscription and channel they create are torn down with the connection rather than outliving it. Adopters calling either method must pass the collection; adopters overriding `createServerRpc` should accept it and register their own per-connection resources on it, since an override keeping the old signature still compiles but never tears those resources down [#17925](https://github.com/eclipse-theia/theia/pull/17925)
- [scm] widened `ScmHistoryItem.tooltip` from `string` to `string | MarkdownString | readonly MarkdownString[]`, so that hovers supplied by a history provider keep their `isTrusted` command allow-list and their multi-section form; adopters reading the field as a string must narrow it [#17880](https://github.com/eclipse-theia/theia/pull/17880)
- [scm-extra] deprecated the `@theia/scm-extra` extension and stopped publishing it on npm; it has also been removed from the example applications, which drops its `SCM History` view, the `History` context menu items in the navigator and editor, and the `alt+h` keybinding. The view has been non-functional in the default application since the removal of `@theia/git`, as nothing implements `ScmHistorySupport` anymore. Please use the SCM history graph in `@theia/scm` for branch history and the Timeline view in `@theia/timeline` for per-file history instead [#17882](https://github.com/eclipse-theia/theia/pull/17882)

Expand Down
135 changes: 135 additions & 0 deletions packages/plugin-ext/src/common/rpc-protocol.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// *****************************************************************************
// Copyright (C) 2026 JuliaHub, Inc. and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { expect } from 'chai';
import { BasicChannel } from '@theia/core/lib/common/message-rpc/channel';
import { Uint8ArrayReadBuffer, Uint8ArrayWriteBuffer } from '@theia/core/lib/common/message-rpc/uint8-array-message-buffer';
import { ConnectionClosedError, createProxyIdentifier, RPCProtocolImpl } from './rpc-protocol';
import { LoggerMain, PLUGIN_RPC_CONTEXT } from './plugin-api-rpc';
import { PluginLogger } from '../plugin/logger';

interface Greeter {
$greet(name: string): Promise<string>;
}
const GREETER = createProxyIdentifier<Greeter>('Greeter');

/**
* A pair of connected channels, each delivering every committed buffer to its peer on a later
* tick. Stands in for the transport between the main side and a plugin host.
*/
function createChannelPair(): [BasicChannel, BasicChannel] {
const channels: BasicChannel[] = [];
const create = (peer: number) => new BasicChannel(() => {
const writer = new Uint8ArrayWriteBuffer();
writer.onCommit(buffer => setTimeout(() => channels[peer].onMessageEmitter.fire(() => new Uint8ArrayReadBuffer(buffer)), 0));
return writer;
});
channels.push(create(1), create(0));
return [channels[0], channels[1]];
}

describe('RPCProtocolImpl', () => {

it('should service requests for registered locals', async () => {
const [mainChannel, hostChannel] = createChannelPair();
const main = new RPCProtocolImpl(mainChannel);
main.set(GREETER, { $greet: async (name: string) => `hello ${name}` });
const host = new RPCProtocolImpl(hostChannel);

expect(await host.getProxy(GREETER).$greet('world')).to.equal('hello world');
});

// Disposal clears `locals`, so a peer that is unaware of it keeps sending requests. Reporting
// those as a missing service handler points at an absent binding rather than a closed
// connection.
it('should report requests handled after disposal as a closed connection', () => {
const [mainChannel] = createChannelPair();
const main = new RPCProtocolImpl(mainChannel);
main.set(GREETER, { $greet: async (name: string) => `hello ${name}` });

main.dispose();

let caught: unknown;
try {
main.handleRequest('$greet', [GREETER.id, 'world']);
} catch (error) {
caught = error;
}
expect(ConnectionClosedError.is(caught), `unexpected error: ${caught}`).to.be.true;
});

it('should reject a peer request sent after disposal', async () => {
const [mainChannel, hostChannel] = createChannelPair();
const main = new RPCProtocolImpl(mainChannel);
main.set(GREETER, { $greet: async (name: string) => `hello ${name}` });
const host = new RPCProtocolImpl(hostChannel);

main.dispose();

try {
await host.getProxy(GREETER).$greet('world');
throw new Error('expected the request to be rejected');
} catch (error) {
// `code` is not carried across the wire, so the peer can only match on the message.
expect((error as Error).message).to.equal('connection is closed');
}
});

it('should reject in-flight requests when the channel announces its close', async () => {
const [mainChannel, hostChannel] = createChannelPair();
const main = new RPCProtocolImpl(mainChannel);
// never settles, so the request is still in flight when the channel goes down
main.set(GREETER, { $greet: () => new Promise<string>(() => { }) });
const host = new RPCProtocolImpl(hostChannel);

const inFlight = host.getProxy(GREETER).$greet('world');
hostChannel.onCloseEmitter.fire({ reason: 'connection went down' });

try {
await inFlight;
throw new Error('expected the in-flight request to be rejected');
} catch (error) {
expect((error as Error).message).to.equal('connection went down');
}
});
});

describe('PluginLogger', () => {

// The plugin host reports unhandled rejections through `console.error`, which it routes back
// through this logger. A rejected `$log` that stayed unhandled would therefore log itself,
// and each report would send further failing logs.
it('should not leave an unhandled rejection when the main side cannot log', async () => {
const [mainChannel, hostChannel] = createChannelPair();
const main = new RPCProtocolImpl(mainChannel);
const failing: LoggerMain = { $log: () => { throw new Error('main side is gone'); } };
main.set(PLUGIN_RPC_CONTEXT.LOGGER_MAIN, failing);
const logger = new PluginLogger(new RPCProtocolImpl(hostChannel), 'plugin-host');

const rejections: unknown[] = [];
const onRejection = (reason: unknown) => rejections.push(reason);
process.on('unhandledRejection', onRejection);
try {
logger.error('a log the main side will refuse');
// two turns of the delivery `setTimeout` plus a macrotask for the rejection to surface
await new Promise(resolve => setTimeout(resolve, 20));
} finally {
process.off('unhandledRejection', onRejection);
}

expect(rejections, `unexpected unhandled rejections: ${rejections}`).to.be.empty;
});
});
8 changes: 8 additions & 0 deletions packages/plugin-ext/src/common/rpc-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ export class RPCProtocolImpl implements RPCProtocol {
}

handleNotification(method: any, args: any[]): void {
if (this.isDisposed) {
// Disposal empties `locals`; a late message is a closed connection, not a missing
// service registration.
throw ConnectionClosedError.create();
}
const serviceId = args[0] as string;
const handler: any = this.locals.get(serviceId);
if (!handler) {
Expand All @@ -100,6 +105,9 @@ export class RPCProtocolImpl implements RPCProtocol {
}

handleRequest(method: string, args: any[]): Promise<any> {
if (this.isDisposed) {
throw ConnectionClosedError.create();
}
const serviceId = args[0] as string;
const handler: any = this.locals.get(serviceId);
if (!handler) {
Expand Down
93 changes: 93 additions & 0 deletions packages/plugin-ext/src/hosted/browser/hosted-plugin-rpc.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// *****************************************************************************
// Copyright (C) 2026 JuliaHub, Inc. and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
let disableJSDOM = enableJSDOM();
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
FrontendApplicationConfigProvider.set({});

import { expect } from 'chai';
import { DisposableCollection, Emitter } from '@theia/core';
import { BasicChannel } from '@theia/core/lib/common/message-rpc/channel';
import { Uint8ArrayReadBuffer, Uint8ArrayWriteBuffer } from '@theia/core/lib/common/message-rpc/uint8-array-message-buffer';
import { PLUGIN_HOST_BACKEND } from '../../common/plugin-protocol';
import { createProxyIdentifier, RPCProtocol, RPCProtocolImpl } from '../../common/rpc-protocol';
import { HostedPluginSupport } from './hosted-plugin';
disableJSDOM();

interface Greeter {
$greet(name: string): Promise<string>;
}
const GREETER = createProxyIdentifier<Greeter>('Greeter');

/** Exposes the protected factory, and stands in for the two collaborators it uses. */
class TestHostedPluginSupport extends HostedPluginSupport {
constructor(server: unknown, watcher: unknown) {
super();
Object.assign(this, { server, watcher });
}

createRpc(toDisconnect: DisposableCollection): RPCProtocol {
return this.createServerRpc(PLUGIN_HOST_BACKEND, toDisconnect);
}
}

describe('HostedPluginSupport.createServerRpc', () => {

before(() => { disableJSDOM = enableJSDOM(); });
after(() => { disableJSDOM(); });

/**
* Wires a plugin host to the main side the way the real backend does: the watcher is a
* singleton shared by every connection, and everything the main side writes is routed to the
* plugin host through `HostedPluginServer.onMessage`.
*/
function connectPluginHost(): { support: TestHostedPluginSupport, host: RPCProtocol } {
const watcher = new Emitter<{ pluginHostId: string, message: Uint8Array }>();
const hostChannel = new BasicChannel(() => {
const writer = new Uint8ArrayWriteBuffer();
writer.onCommit(message => setTimeout(() => watcher.fire({ pluginHostId: PLUGIN_HOST_BACKEND, message }), 0));
return writer;
});
const server = {
onMessage: (_pluginHostId: string, message: Uint8Array) =>
setTimeout(() => hostChannel.onMessageEmitter.fire(() => new Uint8ArrayReadBuffer(message)), 0)
};
return {
support: new TestHostedPluginSupport(server, { onPostMessageEvent: watcher.event }),
host: new RPCProtocolImpl(hostChannel)
};
}

it('should answer the plugin host from the protocol serving the current connection', async () => {
const { support, host } = connectPluginHost();

const firstConnection = new DisposableCollection();
support.createRpc(firstConnection).set(GREETER, { $greet: async () => 'first connection' });
expect(await host.getProxy(GREETER).$greet('world')).to.equal('first connection');

// The connection drops. Its protocol is disposed, but the watcher it subscribed to is a
// singleton that survives, and the reconnect reuses the same `pluginHostId`.
firstConnection.dispose();

const secondConnection = new DisposableCollection();
support.createRpc(secondConnection).set(GREETER, { $greet: async () => 'second connection' });

// A subscription outliving its protocol would be dispatched first and answer from an
// empty `locals` map, rejecting this before the live protocol could reply.
expect(await host.getProxy(GREETER).$greet('world')).to.equal('second connection');
});
});
20 changes: 14 additions & 6 deletions packages/plugin-ext/src/hosted/browser/hosted-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ export class HostedPluginSupport extends AbstractHostedPluginSupport<PluginManag
let manager = this.managers.get(host);
if (!manager) {
const pluginId = getPluginId(hostContributions[0].plugin.metadata.model);
const rpc = this.initRpc(host, pluginId);
const rpc = this.initRpc(host, pluginId, toDisconnect);
toDisconnect.push(rpc);

manager = rpc.getProxy(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT);
Expand Down Expand Up @@ -371,14 +371,14 @@ export class HostedPluginSupport extends AbstractHostedPluginSupport<PluginManag
return manager;
}

protected initRpc(host: PluginHost, pluginId: string): RPCProtocol {
const rpc = host === 'frontend' ? new PluginWorker().rpc : this.createServerRpc(host);
protected initRpc(host: PluginHost, pluginId: string, toDisconnect: DisposableCollection): RPCProtocol {
const rpc = host === 'frontend' ? new PluginWorker().rpc : this.createServerRpc(host, toDisconnect);
setUpPluginApi(rpc, this.container);
this.mainPluginApiProviders.getContributions().forEach(p => p.initialize(rpc, this.container));
return rpc;
}

protected createServerRpc(pluginHostId: string): RPCProtocol {
protected createServerRpc(pluginHostId: string, toDisconnect: DisposableCollection): RPCProtocol {

const channel = new BasicChannel(() => {
const writer = new Uint8ArrayWriteBuffer();
Expand All @@ -391,11 +391,19 @@ export class HostedPluginSupport extends AbstractHostedPluginSupport<PluginManag
// Create RPC protocol before adding the listener to the watcher to receive the watcher's cached messages after the rpc protocol was created.
const rpc = new RPCProtocolImpl(channel);

this.watcher.onPostMessageEvent(received => {
// The watcher outlives the connection and reuses the same `pluginHostId` for its
// successor, so this subscription must not survive the disconnect.
toDisconnect.push(this.watcher.onPostMessageEvent(received => {
if (pluginHostId === received.pluginHostId) {
channel.onMessageEmitter.fire(() => new Uint8ArrayReadBuffer(received.message));
}
});
}));
toDisconnect.push(Disposable.create(() => {
// `Channel.close()` emits no close event; fire it explicitly so that the protocol
// rejects the requests still in flight.
channel.onCloseEmitter.fire({ reason: 'The plugin host connection was closed.' });
channel.close();
}));

return rpc;
}
Expand Down
5 changes: 4 additions & 1 deletion packages/plugin-ext/src/plugin/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ export class PluginLogger {
}

private sendLog(level: LogLevel, message: string, params: any[]): void {
this.logger.$log(level, this.name, this.toLog(message), params.map(e => this.toLog(e)));
// `console.error` and the unhandled-rejection reporter both route through this logger,
// so a rejected `$log` must be swallowed or it would report itself without bound.
Promise.resolve(this.logger.$log(level, this.name, this.toLog(message), params.map(e => this.toLog(e))))
.catch(() => { });
}

private toLog(value: any): any {
Expand Down
Loading