From 7e5f2582126fe9539b3bafa6d564cd28f72e9cd1 Mon Sep 17 00:00:00 2001 From: Tobias Ortmayr Date: Mon, 11 Apr 2022 10:20:14 +0200 Subject: [PATCH] Integrate new message-rpc prototype into core messaging API (extensions) Refactors and improves the prototype of a faster JSON-RPC protocol initially contributed by @tsmaeder (See also https://github.com/eclipse-theia/theia/pull/10781). The encoding approach used in the initial POC has some performance drawbacks when encoding plain JSON objects. We refactored the protocol to improve the performance for JSON objects whilst maintaining the excellent performance for encoding objects that contain binary data. Integrates the new message-rpc prototype into the core messaging API (replacing vscode-ws-jsonrpc). This has major impacts on the Messaging API as we no longer expose a `Connection` object (which was provided by vscode-ws-jsonrpc) and directly rely on a generic transport `Channel` implementation instead. - Introduce `Channel` as the main transport concept for messages (instead of the dedicated `Connection` from vscode-jsonrpc) - Replace usage of `vscode-ws-jsonrpc` with a custom binary RPC protocol. - Refactor all connection providers to use the new binary protocol. - Ensure that the `RemoteFileSystemProvider` API uses `Uint8Arrays` over plain number arrays. This enables direct serialization as buffers and reduces the overhead of unnecessarily converting from and to `Uint8Arrays`. - Refactor terminal widget and terminal backend contribution so that the widgets communicates with the underlying terminal process using the new rpc protocol. - Rework the IPC bootstrap protocol so that it uses a binary pipe for message transmission instead of the `ipc` pipe which only supports string encoding. - Extend the `JsonRpcProxyFactory` with an optional `RpcConnectionFactory` that enables adopter to creates proxies with a that use a custom `RpcProtocol`/`RpcConnection`. The plugin API still uses its own RPC protocol implementation. Currently we have to encode/decode between binary data to handle RPC calls from a plugin context. Aligning the two protocols and zero-copy tunneling of RPC messages is planned for a follow-up PR. Contributed on behalf of STMicroelectronics. Closes #10684 --- CHANGELOG.md | 4 + package.json | 4 + .../messaging/ws-connection-provider.ts | 60 ++- packages/core/src/common/index.ts | 1 + .../core/src/common/message-rpc/README.md | 10 - .../array-buffer-message-buffer.spec.ts | 42 +- .../array-buffer-message-buffer.ts | 120 ++++- .../src/common/message-rpc/channel.spec.ts | 65 ++- .../core/src/common/message-rpc/channel.ts | 225 +++++---- .../common/message-rpc/connection-handler.ts | 38 -- .../src/common/message-rpc/experiments.ts | 56 -- packages/core/src/common/message-rpc/index.ts | 18 + .../src/common/message-rpc/message-buffer.ts | 102 +++- .../message-rpc/message-encoder.spec.ts | 39 -- .../src/common/message-rpc/message-encoder.ts | 400 --------------- .../message-rpc/rpc-message-encoder.spec.ts | 39 ++ .../common/message-rpc/rpc-message-encoder.ts | 477 ++++++++++++++++++ .../src/common/message-rpc/rpc-protocol.ts | 243 ++++++--- .../core/src/common/message-rpc/rpc-proxy.ts | 93 ---- .../message-rpc/websocket-client-channel.ts | 229 --------- .../messaging/abstract-connection-provider.ts | 61 +-- packages/core/src/common/messaging/handler.ts | 4 +- .../common/messaging/proxy-factory.spec.ts | 21 +- .../src/common/messaging/proxy-factory.ts | 37 +- .../common/messaging/web-socket-channel.ts | 209 +++----- .../electron-ipc-connection-provider.ts | 32 +- .../electron-ws-connection-provider.ts | 16 +- .../electron-messaging-contribution.ts | 150 +++--- .../messaging/electron-messaging-service.ts | 10 +- .../core/src/node/messaging/ipc-bootstrap.ts | 49 +- .../node/messaging/ipc-connection-provider.ts | 64 ++- .../core/src/node/messaging/ipc-protocol.ts | 4 +- .../node/messaging/messaging-contribution.ts | 101 +--- .../src/node/messaging/messaging-service.ts | 35 +- .../messaging/test/test-web-socket-channel.ts | 45 +- .../src/browser/debug-session-connection.ts | 12 +- .../src/browser/debug-session-contribution.ts | 3 +- packages/debug/src/browser/debug-session.tsx | 38 +- .../debug/src/node/debug-adapter-session.ts | 6 +- packages/debug/src/node/debug-model.ts | 2 +- packages/filesystem/src/common/files.ts | 2 +- .../src/common/remote-file-system-provider.ts | 37 +- packages/plugin-ext/src/common/connection.ts | 44 +- .../debug/plugin-debug-session-factory.ts | 2 +- .../debug/plugin-debug-adapter-session.ts | 2 +- .../task/src/node/task-server.slow-spec.ts | 70 +-- .../src/browser/terminal-widget-impl.ts | 57 ++- ...terminal-backend-contribution.slow-spec.ts | 22 +- .../src/node/terminal-backend-contribution.ts | 26 +- yarn.lock | 28 +- 50 files changed, 1695 insertions(+), 1759 deletions(-) delete mode 100644 packages/core/src/common/message-rpc/README.md delete mode 100644 packages/core/src/common/message-rpc/connection-handler.ts delete mode 100644 packages/core/src/common/message-rpc/experiments.ts create mode 100644 packages/core/src/common/message-rpc/index.ts delete mode 100644 packages/core/src/common/message-rpc/message-encoder.spec.ts delete mode 100644 packages/core/src/common/message-rpc/message-encoder.ts create mode 100644 packages/core/src/common/message-rpc/rpc-message-encoder.spec.ts create mode 100644 packages/core/src/common/message-rpc/rpc-message-encoder.ts delete mode 100644 packages/core/src/common/message-rpc/rpc-proxy.ts delete mode 100644 packages/core/src/common/message-rpc/websocket-client-channel.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd2a17186410..9e7e113b21eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - [core] Move code for untitled resources into `core` from `plugin-ext` and allow users to open untitled editors with `New File` command. [#10868](https://github.com/eclipse-theia/theia/pull/10868) - [plugin] added support for `SnippetString.appendChoice` [#10969](https://github.com/eclipse-theia/theia/pull/10969) - Contributed on behalf of STMicroelectronics +- [core] Heavily refactored the core messaging API. Replaced `vscode-ws-jsonrpc` with a custom RPC protocol that is better suited for handling binary data and enables message tunneling. + This impacts all main concepts of the messaging API. The API no longer exposes a `Connection` object and uses a generic `Channel` implementation instead. + * `MessagingService`: No longer offers the `listen` and `forward` method. Use `wsChannel`instead. [#11011](https://github.com/eclipse-theia/theia/pull/11011) - Contributed on behalf of STMicroelectronics. + [Breaking Changes:](#breaking_changes_1.25.0) diff --git a/package.json b/package.json index 52350494e2f90..39e5bc7fc9f81 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "**/@types/node": "12" }, "devDependencies": { + "@types/chai": "4.3.0", + "@types/chai-spies": "1.0.3", "@types/chai-string": "^1.4.0", "@types/jsdom": "^11.0.4", "@types/node": "12", @@ -20,6 +22,8 @@ "@typescript-eslint/eslint-plugin": "^4.8.1", "@typescript-eslint/eslint-plugin-tslint": "^4.8.1", "@typescript-eslint/parser": "^4.8.1", + "chai": "4.3.4", + "chai-spies": "1.0.0", "chai-string": "^1.4.0", "chalk": "4.0.0", "concurrently": "^3.5.0", diff --git a/packages/core/src/browser/messaging/ws-connection-provider.ts b/packages/core/src/browser/messaging/ws-connection-provider.ts index f83aabda22826..b41461d35e1ec 100644 --- a/packages/core/src/browser/messaging/ws-connection-provider.ts +++ b/packages/core/src/browser/messaging/ws-connection-provider.ts @@ -14,12 +14,13 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { injectable, interfaces, decorate, unmanaged } from 'inversify'; -import { JsonRpcProxyFactory, JsonRpcProxy, Emitter, Event } from '../../common'; -import { WebSocketChannel } from '../../common/messaging/web-socket-channel'; -import { Endpoint } from '../endpoint'; -import { AbstractConnectionProvider } from '../../common/messaging/abstract-connection-provider'; +import { decorate, injectable, interfaces, unmanaged } from 'inversify'; import { io, Socket } from 'socket.io-client'; +import { Emitter, Event, JsonRpcProxy, JsonRpcProxyFactory } from '../../common'; +import { Channel } from '../../common/message-rpc/channel'; +import { AbstractConnectionProvider } from '../../common/messaging/abstract-connection-provider'; +import { IWebSocket, WebSocketChannel } from '../../common/messaging/web-socket-channel'; +import { Endpoint } from '../endpoint'; decorate(injectable(), JsonRpcProxyFactory); decorate(unmanaged(), JsonRpcProxyFactory, 0); @@ -35,6 +36,8 @@ export interface WebSocketOptions { export class WebSocketConnectionProvider extends AbstractConnectionProvider { protected readonly onSocketDidOpenEmitter: Emitter = new Emitter(); + // Socket that is used by the main channel + protected socket: Socket; get onSocketDidOpen(): Event { return this.onSocketDidOpenEmitter.event; } @@ -48,31 +51,23 @@ export class WebSocketConnectionProvider extends AbstractConnectionProvider(path, arg); } - protected readonly socket: Socket; - - constructor() { - super(); + protected createMainChannel(): Channel { const url = this.createWebSocketUrl(WebSocketChannel.wsPath); const socket = this.createWebSocket(url); + const channel = new WebSocketChannel(toIWebSocket(socket)); socket.on('connect', () => { this.fireSocketDidOpen(); }); - socket.on('disconnect', reason => { - for (const channel of [...this.channels.values()]) { - channel.close(undefined, reason); - } - this.fireSocketDidClose(); - }); - socket.on('message', data => { - this.handleIncomingRawMessage(data); - }); + channel.onClose(() => this.fireSocketDidClose()); socket.connect(); this.socket = socket; + + return channel; } - override openChannel(path: string, handler: (channel: WebSocketChannel) => void, options?: WebSocketOptions): void { + override async openChannel(path: string, handler: (channel: Channel) => void, options?: WebSocketOptions): Promise { if (this.socket.connected) { - super.openChannel(path, handler, options); + return super.openChannel(path, handler, options); } else { const openChannel = () => { this.socket.off('connect', openChannel); @@ -82,14 +77,6 @@ export class WebSocketConnectionProvider extends AbstractConnectionProvider { - if (this.socket.connected) { - this.socket.send(content); - } - }); - } - /** * @param path The handler to reach in the backend. */ @@ -143,3 +130,20 @@ export class WebSocketConnectionProvider extends AbstractConnectionProvider { + socket.removeAllListeners('disconnect'); + socket.removeAllListeners('error'); + socket.removeAllListeners('message'); + socket.close(); + }, + isConnected: () => socket.connected, + onClose: cb => socket.on('disconnect', reason => cb(reason)), + onError: cb => socket.on('error', reason => cb(reason)), + onMessage: cb => socket.on('message', data => cb(data)), + send: message => socket.emit('message', message) + }; +} + diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index 5c944c157087a..e82ecddfa1268 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -29,6 +29,7 @@ export * from './contribution-provider'; export * from './path'; export * from './logger'; export * from './messaging'; +export * from './message-rpc'; export * from './message-service'; export * from './message-service-protocol'; export * from './progress-service'; diff --git a/packages/core/src/common/message-rpc/README.md b/packages/core/src/common/message-rpc/README.md deleted file mode 100644 index d94e3170c0906..0000000000000 --- a/packages/core/src/common/message-rpc/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# message-rpc - -An attempt to rewrite the theia RPC infrastructure with a couple of changes: - -1. "Zero-copy" message writing and reading -2. Support for binary buffers without ever encoding them -3. Separate RPC server from RPC client -4. Use a unified "Channel" interface - -A lot of this code is more or less copied from the current Theia code. diff --git a/packages/core/src/common/message-rpc/array-buffer-message-buffer.spec.ts b/packages/core/src/common/message-rpc/array-buffer-message-buffer.spec.ts index 9c84a7ba7558a..8b2d43a50755c 100644 --- a/packages/core/src/common/message-rpc/array-buffer-message-buffer.spec.ts +++ b/packages/core/src/common/message-rpc/array-buffer-message-buffer.spec.ts @@ -1,28 +1,28 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ +// ***************************************************************************** +// Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 +// ***************************************************************************** import { expect } from 'chai'; -import { ArrayBufferReadBuffer, ArrrayBufferWriteBuffer } from './array-buffer-message-buffer'; +import { ArrayBufferReadBuffer, ArrayBufferWriteBuffer } from './array-buffer-message-buffer'; describe('array message buffer tests', () => { it('basic read write test', () => { const buffer = new ArrayBuffer(1024); - const writer = new ArrrayBufferWriteBuffer(buffer); + const writer = new ArrayBufferWriteBuffer(buffer); - writer.writeByte(8); - writer.writeInt(10000); + writer.writeUint8(8); + writer.writeUint32(10000); writer.writeBytes(new Uint8Array([1, 2, 3, 4])); writer.writeString('this is a string'); writer.writeString('another string'); @@ -32,8 +32,8 @@ describe('array message buffer tests', () => { const reader = new ArrayBufferReadBuffer(written); - expect(reader.readByte()).equal(8); - expect(reader.readInt()).equal(10000); + expect(reader.readUint8()).equal(8); + expect(reader.readUint32()).equal(10000); expect(reader.readBytes()).deep.equal(new Uint8Array([1, 2, 3, 4]).buffer); expect(reader.readString()).equal('this is a string'); expect(reader.readString()).equal('another string'); diff --git a/packages/core/src/common/message-rpc/array-buffer-message-buffer.ts b/packages/core/src/common/message-rpc/array-buffer-message-buffer.ts index cf5d8832705f5..f402244a97d03 100644 --- a/packages/core/src/common/message-rpc/array-buffer-message-buffer.ts +++ b/packages/core/src/common/message-rpc/array-buffer-message-buffer.ts @@ -1,22 +1,22 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ +// ***************************************************************************** +// Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 +// ***************************************************************************** import { Emitter, Event } from '../event'; -import { ReadBuffer, WriteBuffer } from './message-buffer'; +import { getUintType, UintType, ReadBuffer, WriteBuffer } from './message-buffer'; -export class ArrrayBufferWriteBuffer implements WriteBuffer { +export class ArrayBufferWriteBuffer implements WriteBuffer { constructor(private buffer: ArrayBuffer = new ArrayBuffer(1024), private offset: number = 0) { } @@ -37,19 +37,42 @@ export class ArrrayBufferWriteBuffer implements WriteBuffer { return this; } - writeByte(value: number): WriteBuffer { + writeUint8(value: number): WriteBuffer { this.ensureCapacity(1); this.msg.setUint8(this.offset++, value); return this; } - writeInt(value: number): WriteBuffer { + writeUint16(value: number): WriteBuffer { + this.ensureCapacity(2); + this.msg.setUint16(this.offset, value); + this.offset += 2; + return this; + } + + writeUint32(value: number): WriteBuffer { this.ensureCapacity(4); this.msg.setUint32(this.offset, value); this.offset += 4; return this; } + writeInteger(value: number): WriteBuffer { + const type = getUintType(value); + this.writeUint8(type); + switch (type) { + case UintType.Uint8: + this.writeUint8(value); + return this; + case UintType.Uint16: + this.writeUint16(value); + return this; + default: + this.writeUint32(value); + return this; + } + } + writeString(value: string): WriteBuffer { const encoded = this.encodeString(value); this.writeBytes(encoded); @@ -61,8 +84,8 @@ export class ArrrayBufferWriteBuffer implements WriteBuffer { } writeBytes(value: ArrayBuffer): WriteBuffer { - this.ensureCapacity(value.byteLength + 4); - this.writeInt(value.byteLength); + this.writeInteger(value.byteLength); + this.ensureCapacity(value.byteLength); new Uint8Array(this.buffer).set(new Uint8Array(value), this.offset); this.offset += value.byteLength; return this; @@ -79,32 +102,51 @@ export class ArrrayBufferWriteBuffer implements WriteBuffer { getCurrentContents(): ArrayBuffer { return this.buffer.slice(0, this.offset); + } } export class ArrayBufferReadBuffer implements ReadBuffer { private offset: number = 0; - constructor(private readonly buffer: ArrayBuffer) { + constructor(private readonly buffer: ArrayBuffer, readPosition = 0) { + this.offset = readPosition; } private get msg(): DataView { return new DataView(this.buffer); } - readByte(): number { + readUint8(): number { return this.msg.getUint8(this.offset++); } - readInt(): number { + readUint16(): number { + const result = this.msg.getUint16(this.offset); + this.offset += 2; + return result; + } + + readUint32(): number { const result = this.msg.getInt32(this.offset); this.offset += 4; return result; } + readInteger(): number { + const type = this.readUint8(); + switch (type) { + case UintType.Uint8: + return this.readUint8(); + case UintType.Uint16: + return this.readUint16(); + default: + return this.readUint32(); + } + } + readString(): string { - const len = this.msg.getUint32(this.offset); - this.offset += 4; + const len = this.readInteger(); const result = this.decodeString(this.buffer.slice(this.offset, this.offset + len)); this.offset += len; return result; @@ -115,10 +157,32 @@ export class ArrayBufferReadBuffer implements ReadBuffer { } readBytes(): ArrayBuffer { - const length = this.msg.getUint32(this.offset); - this.offset += 4; + const length = this.readInteger(); const result = this.buffer.slice(this.offset, this.offset + length); this.offset += length; return result; } + + sliceAtCurrentPosition(): ReadBuffer { + return new ArrayBufferReadBuffer(this.buffer, this.offset); + } } + +/** + * Retrieve an {@link ArrayBuffer} view for the given buffer. Some {@link Uint8Array} buffer implementations e.g node's {@link Buffer} + * are using shared memory array buffers under the hood. Therefore we need to check the buffers `byteOffset` and `length` and slice + * the underlying array buffer if needed. + * @param buffer The Uint8Array or ArrayBuffer that should be converted. + * @returns a trimmed `ArrayBuffer` representation for the given buffer. + */ +export function toArrayBuffer(buffer: Uint8Array | ArrayBuffer): ArrayBuffer { + if (buffer instanceof ArrayBuffer) { + return buffer; + } + if (buffer.byteOffset === 0 && buffer.byteLength === buffer.buffer.byteLength) { + return buffer.buffer; + } + + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); +} + diff --git a/packages/core/src/common/message-rpc/channel.spec.ts b/packages/core/src/common/message-rpc/channel.spec.ts index 6c372ffb64a06..47d7d579b9c29 100644 --- a/packages/core/src/common/message-rpc/channel.spec.ts +++ b/packages/core/src/common/message-rpc/channel.spec.ts @@ -1,26 +1,45 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ +// ***************************************************************************** +// Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 +// ***************************************************************************** import { assert, expect, spy, use } from 'chai'; import * as spies from 'chai-spies'; - -import { ChannelMultiplexer, ChannelPipe } from './channel'; -import { ReadBuffer } from './message-buffer'; +import { ArrayBufferReadBuffer, ArrayBufferWriteBuffer } from './array-buffer-message-buffer'; +import { ChannelMultiplexer, ForwardingChannel, MessageProvider } from './channel'; use(spies); +/** + * A pipe with two channels at each end for testing. + */ +export class ChannelPipe { + readonly left: ForwardingChannel = new ForwardingChannel('left', () => this.right.onCloseEmitter.fire({ reason: 'Left channel has been closed' }), () => { + const leftWrite = new ArrayBufferWriteBuffer(); + leftWrite.onCommit(buffer => { + this.right.onMessageEmitter.fire(() => new ArrayBufferReadBuffer(buffer)); + }); + return leftWrite; + }); + readonly right: ForwardingChannel = new ForwardingChannel('right', () => this.left.onCloseEmitter.fire({ reason: 'Right channel has been closed' }), () => { + const rightWrite = new ArrayBufferWriteBuffer(); + rightWrite.onCommit(buffer => { + this.left.onMessageEmitter.fire(() => new ArrayBufferReadBuffer(buffer)); + }); + return rightWrite; + }); +} + describe('multiplexer test', () => { it('multiplex message', async () => { const pipe = new ChannelPipe(); @@ -42,15 +61,15 @@ describe('multiplexer test', () => { assert.isNotNull(rightFirst); assert.isNotNull(rightSecond); - const leftSecondSpy = spy((buf: ReadBuffer) => { - const message = buf.readString(); + const leftSecondSpy = spy((buf: MessageProvider) => { + const message = buf().readString(); expect(message).equal('message for second'); }); leftSecond.onMessage(leftSecondSpy); - const rightFirstSpy = spy((buf: ReadBuffer) => { - const message = buf.readString(); + const rightFirstSpy = spy((buf: MessageProvider) => { + const message = buf().readString(); expect(message).equal('message for first'); }); @@ -63,5 +82,5 @@ describe('multiplexer test', () => { expect(rightFirstSpy).to.be.called(); expect(openChannelSpy).to.be.called.exactly(4); - }) + }); }); diff --git a/packages/core/src/common/message-rpc/channel.ts b/packages/core/src/common/message-rpc/channel.ts index da7342251c291..97f70b6d6bcd6 100644 --- a/packages/core/src/common/message-rpc/channel.ts +++ b/packages/core/src/common/message-rpc/channel.ts @@ -1,51 +1,69 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ -import { ArrayBufferReadBuffer, ArrrayBufferWriteBuffer } from './array-buffer-message-buffer'; +// ***************************************************************************** +// Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 +// ***************************************************************************** import { Emitter, Event } from '../event'; import { ReadBuffer, WriteBuffer } from './message-buffer'; /** - * A channel is a bidirectinal communications channel with lifecycle and + * A channel is a bidirectional communications channel with lifecycle and * error signalling. Note that creation of channels is specific to particular * implementations and thus not part of the protocol. */ export interface Channel { + /** * The remote side has closed the channel */ - onClose: Event; + onClose: Event; + /** * An error has occurred while writing to or reading from the channel */ onError: Event; + /** - * A message has arrived and can be read using the given {@link ReadBuffer} + * A message has arrived and can be read by listeners using a {@link MessageProvider}. */ - onMessage: Event; + onMessage: Event; + /** * Obtain a {@link WriteBuffer} to write a message to the channel. */ getWriteBuffer(): WriteBuffer; + /** * Close this channel. No {@link onClose} event should be sent */ close(): void; } -enum MessageTypes { +/** + * The event that is emitted when a channel is closed from the remote side. + */ +export interface ChannelCloseEvent { + reason: string, + code?: number +}; + +/** + * The `MessageProvider` is emitted when a channel receives a new message. + * Listeners can read the provider to obtain a new {@link ReadBuffer} for the received message + */ +export type MessageProvider = () => ReadBuffer; + +export enum MessageTypes { Open = 1, Close = 2, AckOpen = 3, @@ -53,22 +71,26 @@ enum MessageTypes { } /** - * Helper class to implement the single channels on a {@link ChannelMultiplexer} + * Helper class to implement the single channels on a {@link ChannelMultiplexer}. Simply forwards write requests to + * the given write buffer source i.e. the main channel of the {@link ChannelMultiplexer}. */ -class ForwardingChannel implements Channel { - constructor(private readonly closeHander: () => void, private readonly writeBufferSource: () => WriteBuffer) { +export class ForwardingChannel implements Channel { + + constructor(readonly id: string, protected readonly closeHandler: () => void, protected readonly writeBufferSource: () => WriteBuffer) { } - onCloseEmitter: Emitter = new Emitter(); - get onClose(): Event { + onCloseEmitter: Emitter = new Emitter(); + get onClose(): Event { return this.onCloseEmitter.event; }; + onErrorEmitter: Emitter = new Emitter(); get onError(): Event { return this.onErrorEmitter.event; }; - onMessageEmitter: Emitter = new Emitter(); - get onMessage(): Event { + + onMessageEmitter: Emitter = new Emitter(); + get onMessage(): Event { return this.onMessageEmitter.event; }; @@ -76,13 +98,18 @@ class ForwardingChannel implements Channel { return this.writeBufferSource(); } + send(message: ArrayBuffer): void { + const writeBuffer = this.getWriteBuffer(); + writeBuffer.writeBytes(message); + writeBuffer.commit(); + } + close(): void { - this.closeHander(); + this.closeHandler(); } } /** - * A class to encode/decode multiple channels over a single underlying {@link Channel} * The write buffers in this implementation immediately write to the underlying * channel, so we rely on writers to the multiplexed channels to always commit their * messages and always in one go. @@ -91,14 +118,14 @@ export class ChannelMultiplexer { protected pendingOpen: Map void> = new Map(); protected openChannels: Map = new Map(); - protected readonly onOpenChannelEmitter: Emitter = new Emitter(); - get onDidOpenChannel(): Event { + protected readonly onOpenChannelEmitter = new Emitter<{ id: string, channel: Channel }>(); + get onDidOpenChannel(): Event<{ id: string, channel: Channel }> { return this.onOpenChannelEmitter.event; } constructor(protected readonly underlyingChannel: Channel) { - this.underlyingChannel.onMessage(buffer => this.handleMessage(buffer)); - this.underlyingChannel.onClose(() => this.handleClose()); + this.underlyingChannel.onMessage(buffer => this.handleMessage(buffer())); + this.underlyingChannel.onClose(event => this.closeUnderlyingChannel(event)); this.underlyingChannel.onError(error => this.handleError(error)); } @@ -108,76 +135,95 @@ export class ChannelMultiplexer { }); } - protected handleClose(): void { + closeUnderlyingChannel(event?: ChannelCloseEvent): void { + this.pendingOpen.clear(); this.openChannels.forEach(channel => { - channel.close(); + channel.onCloseEmitter.fire(event ?? { reason: 'Multiplexer main channel has been closed from the remote side!' }); }); + this.openChannels.clear(); } protected handleMessage(buffer: ReadBuffer): void { - const type = buffer.readByte(); + const type = buffer.readUint8(); const id = buffer.readString(); switch (type) { case MessageTypes.AckOpen: { - // edge case: both side try to open a channel at the same time. - const resolve = this.pendingOpen.get(id); - if (resolve) { - const channel = this.createChannel(id); - this.pendingOpen.delete(id); - this.openChannels.set(id, channel); - resolve!(channel); - this.onOpenChannelEmitter.fire(channel); - } - break; + return this.handleAckOpen(id); } case MessageTypes.Open: { - if (!this.openChannels.has(id)) { - const channel = this.createChannel(id); - this.openChannels.set(id, channel); - const resolve = this.pendingOpen.get(id); - if (resolve) { - // edge case: both side try to open a channel at the same time. - resolve(channel); - } - this.underlyingChannel.getWriteBuffer().writeByte(MessageTypes.AckOpen).writeString(id).commit(); - this.onOpenChannelEmitter.fire(channel); - } - - break; + return this.handleOpen(id); } case MessageTypes.Close: { - const channel = this.openChannels.get(id); - if (channel) { - channel.onCloseEmitter.fire(); - this.openChannels.delete(id); - } - break; + return this.handleClose(id); } case MessageTypes.Data: { - const channel = this.openChannels.get(id); - if (channel) { - channel.onMessageEmitter.fire(buffer); - } - break; + return this.handleData(id, buffer.sliceAtCurrentPosition()); } + } + } + protected handleAckOpen(id: string): void { + // edge case: both side try to open a channel at the same time. + const resolve = this.pendingOpen.get(id); + if (resolve) { + const channel = this.createChannel(id); + this.pendingOpen.delete(id); + this.openChannels.set(id, channel); + resolve!(channel); + this.onOpenChannelEmitter.fire({ id, channel }); + } + } + + protected handleOpen(id: string): void { + if (!this.openChannels.has(id)) { + const channel = this.createChannel(id); + this.openChannels.set(id, channel); + const resolve = this.pendingOpen.get(id); + if (resolve) { + // edge case: both side try to open a channel at the same time. + resolve(channel); + } + this.underlyingChannel.getWriteBuffer().writeUint8(MessageTypes.AckOpen).writeString(id).commit(); + this.onOpenChannelEmitter.fire({ id, channel }); + } + } + + protected handleClose(id: string): void { + const channel = this.openChannels.get(id); + if (channel) { + channel.onCloseEmitter.fire({ reason: 'Channel has been closed from the remote side' }); + this.openChannels.delete(id); + } + } + + protected handleData(id: string, data: ReadBuffer): void { + const channel = this.openChannels.get(id); + if (channel) { + channel.onMessageEmitter.fire(() => data); } } protected createChannel(id: string): ForwardingChannel { - return new ForwardingChannel(() => this.closeChannel(id), () => { - const underlying = this.underlyingChannel.getWriteBuffer(); - underlying.writeByte(MessageTypes.Data); - underlying.writeString(id); - return underlying; - }); + return new ForwardingChannel(id, () => this.closeChannel(id), () => this.prepareWriteBuffer(id)); + } + + // Prepare the write buffer for the channel with the give, id. The channel id has to be encoded + // and written to the buffer before the actual message. + protected prepareWriteBuffer(id: string): WriteBuffer { + const underlying = this.underlyingChannel.getWriteBuffer(); + underlying.writeUint8(MessageTypes.Data); + underlying.writeString(id); + return underlying; } protected closeChannel(id: string): void { - this.underlyingChannel.getWriteBuffer().writeByte(MessageTypes.Close).writeString(id).commit(); - this.openChannels.get(id)!.onCloseEmitter.fire(); + this.underlyingChannel.getWriteBuffer() + .writeUint8(MessageTypes.Close) + .writeString(id) + .commit(); + this.openChannels.delete(id); } @@ -185,7 +231,7 @@ export class ChannelMultiplexer { const result = new Promise((resolve, reject) => { this.pendingOpen.set(id, resolve); }); - this.underlyingChannel.getWriteBuffer().writeByte(MessageTypes.Open).writeString(id).commit(); + this.underlyingChannel.getWriteBuffer().writeUint8(MessageTypes.Open).writeString(id).commit(); return result; } @@ -194,22 +240,3 @@ export class ChannelMultiplexer { } } -/** - * A pipe with two channels at each end for testing. - */ -export class ChannelPipe { - readonly left: ForwardingChannel = new ForwardingChannel(() => this.right.onCloseEmitter.fire(), () => { - const leftWrite = new ArrrayBufferWriteBuffer(); - leftWrite.onCommit(buffer => { - this.right.onMessageEmitter.fire(new ArrayBufferReadBuffer(buffer)); - }); - return leftWrite; - }); - readonly right: ForwardingChannel = new ForwardingChannel(() => this.left.onCloseEmitter.fire(), () => { - const rightWrite = new ArrrayBufferWriteBuffer(); - rightWrite.onCommit(buffer => { - this.left.onMessageEmitter.fire(new ArrayBufferReadBuffer(buffer)); - }); - return rightWrite; - }); -} diff --git a/packages/core/src/common/message-rpc/connection-handler.ts b/packages/core/src/common/message-rpc/connection-handler.ts deleted file mode 100644 index d5fbfa277224a..0000000000000 --- a/packages/core/src/common/message-rpc/connection-handler.ts +++ /dev/null @@ -1,38 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ -import { Channel } from './channel'; -import { RpcHandler, RpcProxyHandler } from './rpc-proxy'; - -interface ConnectionHandler { - onConnection(connection: Channel): void; -} - -export class JsonRpcConnectionHandler implements ConnectionHandler { - constructor( - readonly path: string, - readonly targetFactory: (proxy: T) => unknown, - ) { } - - onConnection(connection: Channel): void { - const proxyHandler = new RpcProxyHandler(); - // eslint-disable-next-line no-null/no-null - const proxy = new Proxy(Object.create(null), proxyHandler); - const target = this.targetFactory(proxy); - - new RpcHandler(target).onChannelOpen(connection); - proxyHandler.onChannelOpen(connection); - } -} diff --git a/packages/core/src/common/message-rpc/experiments.ts b/packages/core/src/common/message-rpc/experiments.ts deleted file mode 100644 index 60285ea3d7907..0000000000000 --- a/packages/core/src/common/message-rpc/experiments.ts +++ /dev/null @@ -1,56 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ -import { ChannelPipe } from './channel'; -import { RpcHandler, RpcProxyHandler } from './rpc-proxy'; -import * as fs from 'fs'; - -/** - * This file is for fiddling around and testing. Not production code. - */ - -const pipe = new ChannelPipe(); - -interface ReadFile { - read(path: string): Promise; -} - -class Server implements ReadFile { - read(path: string): Promise { - const bytes = fs.readFileSync(path); - const result = new ArrayBuffer(bytes.byteLength); - bytes.copy(new Uint8Array(result)); - return Promise.resolve(result); - } -} - -const handler = new RpcHandler(new Server()); -handler.onChannelOpen(pipe.right); - -const proxyHandler = new RpcProxyHandler(); -// eslint-disable-next-line no-null/no-null -const proxy: ReadFile = new Proxy(Object.create(null), proxyHandler); -proxyHandler.onChannelOpen(pipe.left); - -const t0 = new Date().getTime(); - -proxy.read(process.argv[2]).then(value => { - const t1 = new Date().getTime(); - console.log(`read file of length: ${value.byteLength} in ${t1 - t0}ms`); - console.log(value.slice(0, 20)); -}).catch(e => { - console.log(e); -}); - diff --git a/packages/core/src/common/message-rpc/index.ts b/packages/core/src/common/message-rpc/index.ts new file mode 100644 index 0000000000000..8cada9981de3e --- /dev/null +++ b/packages/core/src/common/message-rpc/index.ts @@ -0,0 +1,18 @@ +// ***************************************************************************** +// Copyright (C) 2022 STMicroelectronics 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 WITH Classpath-exception-2.0 +// ***************************************************************************** +export * from './rpc-protocol'; +export * from './channel'; +export * from './message-buffer'; diff --git a/packages/core/src/common/message-rpc/message-buffer.ts b/packages/core/src/common/message-rpc/message-buffer.ts index 79466424512b5..56276e94dfdf0 100644 --- a/packages/core/src/common/message-rpc/message-buffer.ts +++ b/packages/core/src/common/message-rpc/message-buffer.ts @@ -1,28 +1,34 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ +// ***************************************************************************** +// Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 +// ***************************************************************************** /** * A buffer maintaining a write position capable of writing primitive values */ export interface WriteBuffer { - writeByte(byte: number): WriteBuffer - writeInt(value: number): WriteBuffer; + writeUint8(byte: number): WriteBuffer + writeUint16(value: number): WriteBuffer + writeUint32(value: number): WriteBuffer; writeString(value: string): WriteBuffer; writeBytes(value: ArrayBuffer): WriteBuffer; - + /** + * Writes a number as integer value.The best suited encoding format(Uint8 Uint16 or Uint32) is + * computed automatically and encoded as the first byte. Mainly used to persist length values of + * strings and arrays. + */ + writeInteger(value: number): WriteBuffer /** * Makes any writes to the buffer permanent, for example by sending the writes over a channel. * You must obtain a new write buffer after committing @@ -33,13 +39,24 @@ export interface WriteBuffer { export class ForwardingWriteBuffer implements WriteBuffer { constructor(protected readonly underlying: WriteBuffer) { } - writeByte(byte: number): WriteBuffer { - this.underlying.writeByte(byte); + + writeUint8(byte: number): WriteBuffer { + this.underlying.writeUint8(byte); + return this; + } + + writeUint16(value: number): WriteBuffer { + this.underlying.writeUint16(value); + return this; + } + + writeUint32(value: number): WriteBuffer { + this.underlying.writeUint32(value); return this; } - writeInt(value: number): WriteBuffer { - this.underlying.writeInt(value); + writeInteger(value: number): WriteBuffer { + this.underlying.writeInteger(value); return this; } @@ -58,13 +75,50 @@ export class ForwardingWriteBuffer implements WriteBuffer { } } +export enum UintType { + Uint8 = 1, + Uint16 = 2, + Uint32 = 3 +} + +/** + * Checks wether the given number is an unsigned integer and returns the {@link UintType} + * that is needed to store it in binary format. + * @param value The number for which the UintType should be retrieved. + * @returns the corresponding UInt type. + * @throws An error if the given number is not an unsigned integer. + */ +export function getUintType(value: number): UintType { + if (value < 0 || (value % 1) !== 0) { + throw new Error(`Could not determine IntType. ${value} is not an unsigned integer`); + } + if (value <= 255) { + return UintType.Uint8; + } else if (value <= 65535) { + return UintType.Uint16; + } + return UintType.Uint32; +} + /** * A buffer maintaining a read position in a buffer containing a received message capable of * reading primitive values. */ export interface ReadBuffer { - readByte(): number; - readInt(): number; + readUint8(): number; + readUint16(): number; + readUint32(): number; readString(): string; readBytes(): ArrayBuffer; + + /** + * Reads a number as int value. The encoding format(Uint8, Uint16, or Uint32) is expected to be + * encoded in the first byte. + */ + readInteger(): number + /** + * Returns a new read buffer whose starting read position is the current read position of this buffer. + * Can be used to read (sub) messages multiple times. + */ + sliceAtCurrentPosition(): ReadBuffer } diff --git a/packages/core/src/common/message-rpc/message-encoder.spec.ts b/packages/core/src/common/message-rpc/message-encoder.spec.ts deleted file mode 100644 index 0f6108052c0a5..0000000000000 --- a/packages/core/src/common/message-rpc/message-encoder.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ -import { expect } from 'chai'; -import { ArrayBufferReadBuffer, ArrrayBufferWriteBuffer } from './array-buffer-message-buffer'; -import { MessageDecoder, MessageEncoder } from './message-encoder'; - -describe('message buffer test', () => { - it('encode object', () => { - const buffer = new ArrayBuffer(1024); - const writer = new ArrrayBufferWriteBuffer(buffer); - - const encoder = new MessageEncoder(); - const jsonMangled = JSON.parse(JSON.stringify(encoder)); - - encoder.writeTypedValue(writer, encoder); - - const written = writer.getCurrentContents(); - - const reader = new ArrayBufferReadBuffer(written); - - const decoder = new MessageDecoder(); - const decoded = decoder.readTypedValue(reader); - - expect(decoded).deep.equal(jsonMangled); - }); -}); diff --git a/packages/core/src/common/message-rpc/message-encoder.ts b/packages/core/src/common/message-rpc/message-encoder.ts deleted file mode 100644 index 16e3a55593a30..0000000000000 --- a/packages/core/src/common/message-rpc/message-encoder.ts +++ /dev/null @@ -1,400 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ -import { ReadBuffer, WriteBuffer } from './message-buffer'; - -/** - * This code lets you encode rpc protocol messages (request/reply/notification/error/cancel) - * into a channel write buffer and decode the same messages from a read buffer. - * Custom encoders/decoders can be registered to specially handling certain types of values - * to be encoded. Clients are responsible for ensuring that the set of tags for encoders - * is distinct and the same at both ends of a channel. - */ - -export interface SerializedError { - readonly $isError: true; - readonly name: string; - readonly message: string; - readonly stack: string; -} - -export const enum MessageType { - Request = 1, - Notification = 2, - Reply = 3, - ReplyErr = 4, - Cancel = 5, -} - -export interface CancelMessage { - type: MessageType.Cancel; - id: number; -} - -export interface RequestMessage { - type: MessageType.Request; - id: number; - method: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args: any[]; -} - -export interface NotificationMessage { - type: MessageType.Notification; - id: number; - method: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args: any[]; -} - -export interface ReplyMessage { - type: MessageType.Reply; - id: number; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - res: any; -} - -export interface ReplyErrMessage { - type: MessageType.ReplyErr; - id: number; - err: SerializedError; -} - -export type RPCMessage = RequestMessage | ReplyMessage | ReplyErrMessage | CancelMessage | NotificationMessage; - -enum ObjectType { - JSON = 0, - ByteArray = 1, - ObjectArray = 2, - Undefined = 3, - Object = 4 -} -/** - * A value encoder writes javascript values to a write buffer. Encoders will be asked - * in turn (ordered by their tag value, descending) whether they can encode a given value - * This means encoders with higher tag values have priority. Since the default encoders - * have tag values from 0-4, they can be easily overridden. - */ -export interface ValueEncoder { - /** - * Returns true if this encoder wants to encode this value. - * @param value the value to be encoded - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - is(value: any): boolean; - /** - * Write the given value to the buffer. Will only be called if {@link is(value)} returns true. - * @param buf The buffer to write to - * @param value The value to be written - * @param recursiveEncode A function that will use the encoders registered on the {@link MessageEncoder} - * to write a value to the underlying buffer. This is used mostly to write structures like an array - * without having to know how to encode the values in the array - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - write(buf: WriteBuffer, value: any, recursiveEncode: (buf: WriteBuffer, value: any) => void): void; -} - -/** - * Reads javascript values from a read buffer - */ -export interface ValueDecoder { - /** - * Reads a value from a read buffer. This method will be called for the decoder that is - * registered for the tag associated with the value encoder that encoded this value. - * @param buf The read buffer to read from - * @param recursiveDecode A function that will use the decoders registered on the {@link MessageEncoder} - * to read values from the underlying read buffer. This is used mostly to decode structures like an array - * without having to know how to decode the values in the aray. - */ - read(buf: ReadBuffer, recursiveDecode: (buf: ReadBuffer) => unknown): unknown; -} - -/** - * A MessageDecoder parses a ReadBuffer into a RCPMessage - */ - -export class MessageDecoder { - protected decoders: Map = new Map(); - - constructor() { - this.registerDecoder(ObjectType.JSON, { - read: buf => { - const json = buf.readString(); - return JSON.parse(json); - } - }); - this.registerDecoder(ObjectType.ByteArray, { - read: buf => buf.readBytes() - }); - this.registerDecoder(ObjectType.ObjectArray, { - read: buf => this.readArray(buf) - }); - - this.registerDecoder(ObjectType.Undefined, { - read: () => undefined - }); - - this.registerDecoder(ObjectType.Object, { - read: (buf, recursiveRead) => { - const propertyCount = buf.readInt(); - const result = Object.create({}); - for (let i = 0; i < propertyCount; i++) { - const key = buf.readString(); - const value = recursiveRead(buf); - result[key] = value; - } - return result; - } - }); - } - - registerDecoder(tag: number, decoder: ValueDecoder): void { - if (this.decoders.has(tag)) { - throw new Error(`Decoder already registered: ${tag}`); - } - this.decoders.set(tag, decoder); - } - - parse(buf: ReadBuffer): RPCMessage { - try { - const msgType = buf.readByte(); - - switch (msgType) { - case MessageType.Request: - return this.parseRequest(buf); - case MessageType.Notification: - return this.parseNotification(buf); - case MessageType.Reply: - return this.parseReply(buf); - case MessageType.ReplyErr: - return this.parseReplyErr(buf); - case MessageType.Cancel: - return this.parseCancel(buf); - } - throw new Error(`Unknown message type: ${msgType}`); - } catch (e) { - // exception does not show problematic content: log it! - console.log('failed to parse message: ' + buf); - throw e; - } - } - - protected parseCancel(msg: ReadBuffer): CancelMessage { - const callId = msg.readInt(); - return { - type: MessageType.Cancel, - id: callId - }; - } - - protected parseRequest(msg: ReadBuffer): RequestMessage { - const callId = msg.readInt(); - const method = msg.readString(); - let args = this.readArray(msg); - // convert `null` to `undefined`, since we don't use `null` in internal plugin APIs - args = args.map(arg => arg === null ? undefined : arg); // eslint-disable-line no-null/no-null - - return { - type: MessageType.Request, - id: callId, - method: method, - args: args - }; - } - - protected parseNotification(msg: ReadBuffer): NotificationMessage { - const callId = msg.readInt(); - const method = msg.readString(); - let args = this.readArray(msg); - // convert `null` to `undefined`, since we don't use `null` in internal plugin APIs - args = args.map(arg => arg === null ? undefined : arg); // eslint-disable-line no-null/no-null - - return { - type: MessageType.Notification, - id: callId, - method: method, - args: args - }; - } - - parseReply(msg: ReadBuffer): ReplyMessage { - const callId = msg.readInt(); - const value = this.readTypedValue(msg); - return { - type: MessageType.Reply, - id: callId, - res: value - }; - } - - parseReplyErr(msg: ReadBuffer): ReplyErrMessage { - const callId = msg.readInt(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let err: any = this.readTypedValue(msg); - if (err && err.$isError) { - err = new Error(); - err.name = err.name; - err.message = err.message; - err.stack = err.stack; - } - return { - type: MessageType.ReplyErr, - id: callId, - err: err - }; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readArray(buf: ReadBuffer): any[] { - const length = buf.readInt(); - const result = new Array(length); - for (let i = 0; i < length; i++) { - result[i] = this.readTypedValue(buf); - } - return result; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readTypedValue(buf: ReadBuffer): any { - const type = buf.readInt(); - const decoder = this.decoders.get(type); - if (!decoder) { - throw new Error(`No decoder for tag ${type}`); - } - return decoder.read(buf, innerBuffer => this.readTypedValue(innerBuffer)); - } -} -/** - * A MessageEncoder writes RCPMessage objects to a WriteBuffer. Note that it is - * up to clients to commit the message. This allows for multiple messages being - * encoded before sending. - */ -export class MessageEncoder { - protected readonly encoders: [number, ValueEncoder][] = []; - protected readonly registeredTags: Set = new Set(); - - constructor() { - // encoders will be consulted in reverse order of registration, so the JSON fallback needs to be last - this.registerEncoder(ObjectType.JSON, { - is: () => true, - write: (buf, value) => { - buf.writeString(JSON.stringify(value)); - } - }); - this.registerEncoder(ObjectType.Object, { - is: value => typeof value === 'object', - write: (buf, object, recursiveEncode) => { - const properties = Object.keys(object); - const relevant = []; - for (const property of properties) { - const value = object[property]; - if (typeof value !== 'function') { - relevant.push([property, value]); - } - } - - buf.writeInt(relevant.length); - for (const [property, value] of relevant) { - buf.writeString(property); - recursiveEncode(buf, value); - } - } - }); - this.registerEncoder(ObjectType.Undefined, { - is: value => (typeof value === 'undefined'), - write: () => { } - }); - - this.registerEncoder(ObjectType.ObjectArray, { - is: value => Array.isArray(value), - write: (buf, value) => { - this.writeArray(buf, value); - } - }); - - this.registerEncoder(ObjectType.ByteArray, { - is: value => value instanceof ArrayBuffer, - write: (buf, value) => { - buf.writeBytes(value); - } - }); - } - - registerEncoder(tag: number, encoder: ValueEncoder): void { - if (this.registeredTags.has(tag)) { - throw new Error(`Tag already registered: ${tag}`); - } - this.registeredTags.add(tag); - this.encoders.push([tag, encoder]); - } - - cancel(buf: WriteBuffer, requestId: number): void { - buf.writeByte(MessageType.Cancel); - buf.writeInt(requestId); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - notification(buf: WriteBuffer, requestId: number, method: string, args: any[]): void { - buf.writeByte(MessageType.Notification); - buf.writeInt(requestId); - buf.writeString(method); - this.writeArray(buf, args); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request(buf: WriteBuffer, requestId: number, method: string, args: any[]): void { - buf.writeByte(MessageType.Request); - buf.writeInt(requestId); - buf.writeString(method); - this.writeArray(buf, args); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - replyOK(buf: WriteBuffer, requestId: number, res: any): void { - buf.writeByte(MessageType.Reply); - buf.writeInt(requestId); - this.writeTypedValue(buf, res); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - replyErr(buf: WriteBuffer, requestId: number, err: any): void { - buf.writeByte(MessageType.ReplyErr); - buf.writeInt(requestId); - this.writeTypedValue(buf, err); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - writeTypedValue(buf: WriteBuffer, value: any): void { - for (let i: number = this.encoders.length - 1; i >= 0; i--) { - if (this.encoders[i][1].is(value)) { - buf.writeInt(this.encoders[i][0]); - this.encoders[i][1].write(buf, value, (innerBuffer, innerValue) => { - this.writeTypedValue(innerBuffer, innerValue); - }); - return; - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - writeArray(buf: WriteBuffer, value: any[]): void { - buf.writeInt(value.length); - for (let i = 0; i < value.length; i++) { - this.writeTypedValue(buf, value[i]); - } - } - -} diff --git a/packages/core/src/common/message-rpc/rpc-message-encoder.spec.ts b/packages/core/src/common/message-rpc/rpc-message-encoder.spec.ts new file mode 100644 index 0000000000000..32060af0c487e --- /dev/null +++ b/packages/core/src/common/message-rpc/rpc-message-encoder.spec.ts @@ -0,0 +1,39 @@ +// ***************************************************************************** +// Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 +// ***************************************************************************** +import { expect } from 'chai'; +import { ArrayBufferReadBuffer, ArrayBufferWriteBuffer } from './array-buffer-message-buffer'; +import { RpcMessageDecoder, RpcMessageEncoder } from './rpc-message-encoder'; + +describe('message buffer test', () => { + it('encode object', () => { + const buffer = new ArrayBuffer(1024); + const writer = new ArrayBufferWriteBuffer(buffer); + + const encoder = new RpcMessageEncoder(); + const jsonMangled = JSON.parse(JSON.stringify(encoder)); + + encoder.writeTypedValue(writer, encoder); + + const written = writer.getCurrentContents(); + + const reader = new ArrayBufferReadBuffer(written); + + const decoder = new RpcMessageDecoder(); + const decoded = decoder.readTypedValue(reader); + + expect(decoded).deep.equal(jsonMangled); + }); +}); diff --git a/packages/core/src/common/message-rpc/rpc-message-encoder.ts b/packages/core/src/common/message-rpc/rpc-message-encoder.ts new file mode 100644 index 0000000000000..8262fbcc8628d --- /dev/null +++ b/packages/core/src/common/message-rpc/rpc-message-encoder.ts @@ -0,0 +1,477 @@ +// ***************************************************************************** +// Copyright (C) 2022 Red Hat, 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 WITH Classpath-exception-2.0 +// ***************************************************************************** +// partly based on https://github.com/microsoft/vscode/blob/435f8a4cae52fc9850766af92d5df3c492f59341/src/vs/workbench/services/extensions/common/rpcProtocol. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { ResponseError } from 'vscode-languageserver-protocol'; +import { toArrayBuffer } from './array-buffer-message-buffer'; +import { getUintType, UintType, ReadBuffer, WriteBuffer } from './message-buffer'; + +/** + * This code lets you encode rpc protocol messages (request/reply/notification/error/cancel) + * into a channel write buffer and decode the same messages from a read buffer. + * Custom encoders/decoders can be registered to specially handling certain types of values + * to be encoded. Clients are responsible for ensuring that the set of tags for encoders + * is distinct and the same at both ends of a channel. + */ + +export type RpcMessage = RequestMessage | ReplyMessage | ReplyErrMessage | CancelMessage | NotificationMessage; + +export const enum RpcMessageType { + Request = 1, + Notification = 2, + Reply = 3, + ReplyErr = 4, + Cancel = 5, +} + +export interface CancelMessage { + type: RpcMessageType.Cancel; + id: number; +} + +export interface RequestMessage { + type: RpcMessageType.Request; + id: number; + method: string; + args: any[]; +} + +export interface NotificationMessage { + type: RpcMessageType.Notification; + id: number; + method: string; + args: any[]; +} + +export interface ReplyMessage { + type: RpcMessageType.Reply; + id: number; + res: any; +} + +export interface ReplyErrMessage { + type: RpcMessageType.ReplyErr; + id: number; + err: any; +} + +export interface SerializedError { + readonly $isError: true; + readonly name: string; + readonly message: string; + readonly stack: string; +} + +export function transformErrorForSerialization(error: Error): SerializedError { + if (error instanceof Error) { + const { name, message } = error; + const stack: string = (error).stacktrace || error.stack; + return { + $isError: true, + name, + message, + stack + }; + } + + // return as is + return error; +} + +/** + * The tag values for the default {@link ValueEncoder}s & {@link ValueDecoder}s + */ + +export enum ObjectType { + JSON = 1, + ArrayBuffer = 2, + ByteArray = 3, + UNDEFINED = 4, + ObjectArray = 5, + RESPONSE_ERROR = 6, + ERROR = 7 + +} + +/** + * A value encoder writes javascript values to a write buffer. Encoders will be asked + * in turn (ordered by their tag value, descending) whether they can encode a given value + * This means encoders with higher tag values have priority. Since the default encoders + * have tag values from 1-7, they can be easily overridden. + */ +export interface ValueEncoder { + /** + * Returns true if this encoder wants to encode this value. + * @param value the value to be encoded + */ + is(value: any): boolean; + /** + * Write the given value to the buffer. Will only be called if {@link is(value)} returns true. + * @param buf The buffer to write to + * @param value The value to be written + * @param recursiveEncode A function that will use the encoders registered on the {@link MessageEncoder} + * to write a value to the underlying buffer. This is used mostly to write structures like an array + * without having to know how to encode the values in the array + */ + write(buf: WriteBuffer, value: any, recursiveEncode?: (buf: WriteBuffer, value: any) => void): void; +} + +/** + * Reads javascript values from a read buffer + */ +export interface ValueDecoder { + /** + * Reads a value from a read buffer. This method will be called for the decoder that is + * registered for the tag associated with the value encoder that encoded this value. + * @param buf The read buffer to read from + * @param recursiveDecode A function that will use the decoders registered on the {@link RpcMessageDecoder} + * to read values from the underlying read buffer. This is used mostly to decode structures like an array + * without having to know how to decode the values in the array. + */ + read(buf: ReadBuffer, recursiveDecode: (buf: ReadBuffer) => unknown): unknown; +} + +/** + * A `RpcMessageDecoder` parses a a binary message received via {@link ReadBuffer} into a {@link RpcMessage} + */ +export class RpcMessageDecoder { + + protected decoders: Map = new Map(); + /** + * Declares the Uint8 type (i.e. the amount of bytes) necessary to store a decoder tag + * value in the buffer. + */ + protected tagIntType: UintType; + + constructor() { + this.registerDecoder(ObjectType.JSON, { + read: buf => JSON.parse(buf.readString()) + }); + + this.registerDecoder(ObjectType.UNDEFINED, { + read: () => undefined + }); + + this.registerDecoder(ObjectType.ERROR, { + read: buf => { + const serializedError: SerializedError = JSON.parse(buf.readString()); + const error = new Error(serializedError.message); + Object.assign(error, serializedError); + return error; + } + }); + + this.registerDecoder(ObjectType.RESPONSE_ERROR, { + read: buf => { + const error = JSON.parse(buf.readString()); + return new ResponseError(error.code, error.message, error.data); + } + }); + + this.registerDecoder(ObjectType.ByteArray, { + read: buf => new Uint8Array(buf.readBytes()) + }); + + this.registerDecoder(ObjectType.ArrayBuffer, { + read: buf => buf.readBytes() + }); + + this.registerDecoder(ObjectType.ObjectArray, { + read: buf => { + const encodedSeparately = buf.readUint8() === 1; + + if (!encodedSeparately) { + return this.readTypedValue(buf); + } + const length = buf.readInteger(); + const result = new Array(length); + for (let i = 0; i < length; i++) { + result[i] = this.readTypedValue(buf); + } + return result; + } + }); + } + + /** + * Registers a new {@link ValueDecoder} for the given tag. + * After the successful registration the {@link tagIntType} is recomputed + * by retrieving the highest tag value and calculating the required Uint size to store it. + * @param tag the tag for which the decoder should be registered. + * @param decoder the decoder that should be registered. + */ + registerDecoder(tag: number, decoder: ValueDecoder): void { + if (this.decoders.has(tag)) { + throw new Error(`Decoder already registered: ${tag}`); + } + this.decoders.set(tag, decoder); + const maxTagId = Array.from(this.decoders.keys()).sort().reverse()[0]; + this.tagIntType = getUintType(maxTagId); + } + + readTypedValue(buf: ReadBuffer): any { + const type = buf.readUint8(); + const decoder = this.decoders.get(type); + if (!decoder) { + throw new Error(`No decoder registered for tag ${type}`); + } + return decoder.read(buf, innerBuffer => this.readTypedValue(innerBuffer)); + } + + parse(buf: ReadBuffer): RpcMessage { + try { + const msgType = buf.readUint8(); + + switch (msgType) { + case RpcMessageType.Request: + return this.parseRequest(buf); + case RpcMessageType.Notification: + return this.parseNotification(buf); + case RpcMessageType.Reply: + return this.parseReply(buf); + case RpcMessageType.ReplyErr: + return this.parseReplyErr(buf); + case RpcMessageType.Cancel: + return this.parseCancel(buf); + } + throw new Error(`Unknown message type: ${msgType}`); + } catch (e) { + // exception does not show problematic content: log it! + console.log('failed to parse message: ' + buf); + throw e; + } + } + + protected parseCancel(msg: ReadBuffer): CancelMessage { + const callId = msg.readUint32(); + return { + type: RpcMessageType.Cancel, + id: callId + }; + } + + protected parseRequest(msg: ReadBuffer): RequestMessage { + const callId = msg.readUint32(); + const method = msg.readString(); + let args = this.readTypedValue(msg) as any[]; + // convert `null` to `undefined`, since we don't use `null` in internal plugin APIs + args = args.map(arg => arg === null ? undefined : arg); // eslint-disable-line no-null/no-null + + return { + type: RpcMessageType.Request, + id: callId, + method: method, + args: args + }; + } + + protected parseNotification(msg: ReadBuffer): NotificationMessage { + const callId = msg.readUint32(); + const method = msg.readString(); + let args = this.readTypedValue(msg) as any[]; + // convert `null` to `undefined`, since we don't use `null` in internal plugin APIs + args = args.map(arg => arg === null ? undefined : arg); // eslint-disable-line no-null/no-null + + return { + type: RpcMessageType.Notification, + id: callId, + method: method, + args: args + }; + } + + parseReply(msg: ReadBuffer): ReplyMessage { + const callId = msg.readUint32(); + const value = this.readTypedValue(msg); + return { + type: RpcMessageType.Reply, + id: callId, + res: value + }; + } + + parseReplyErr(msg: ReadBuffer): ReplyErrMessage { + const callId = msg.readUint32(); + const err = this.readTypedValue(msg); + + return { + type: RpcMessageType.ReplyErr, + id: callId, + err + }; + } +} + +/** + * A `RpcMessageEncoder` writes {@link RpcMessage} objects to a {@link WriteBuffer}. Note that it is + * up to clients to commit the message. This allows for multiple messages being + * encoded before sending. + */ +export class RpcMessageEncoder { + + protected readonly encoders: [number, ValueEncoder][] = []; + protected readonly registeredTags: Set = new Set(); + protected tagIntType: UintType; + + constructor() { + this.registerEncoders(); + } + + protected registerEncoders(): void { + // encoders will be consulted in reverse order of registration, so the JSON fallback needs to be last + this.registerEncoder(ObjectType.JSON, { + is: () => true, + write: (buf, value) => { + buf.writeString(JSON.stringify(value)); + } + }); + + this.registerEncoder(ObjectType.UNDEFINED, { + // eslint-disable-next-line no-null/no-null + is: value => value == null, + write: () => { } + }); + + this.registerEncoder(ObjectType.ERROR, { + is: value => value instanceof Error, + write: (buf, value: Error) => buf.writeString(JSON.stringify(transformErrorForSerialization(value))) + }); + + this.registerEncoder(ObjectType.RESPONSE_ERROR, { + is: value => value instanceof ResponseError, + write: (buf, value) => buf.writeString(JSON.stringify(value)) + }); + + this.registerEncoder(ObjectType.ByteArray, { + is: value => value instanceof Uint8Array, + write: (buf, value: Uint8Array) => { + /* When running in a nodejs context the received Uint8Array might be + a nodejs Buffer allocated from node's Buffer pool, which is not transferrable. + Therefore we use the `toArrayBuffer` utility method to retrieve the correct ArrayBuffer */ + const arrayBuffer = toArrayBuffer(value); + buf.writeBytes(arrayBuffer); + } + }); + + this.registerEncoder(ObjectType.ArrayBuffer, { + is: value => value instanceof ArrayBuffer, + write: (buf, value: ArrayBuffer) => buf.writeBytes(value) + }); + + this.registerEncoder(ObjectType.ObjectArray, { + is: value => Array.isArray(value), + write: (buf, args: any[]) => { + const encodeSeparately = this.requiresSeparateEncoding(args); + buf.writeUint8(encodeSeparately ? 1 : 0); + if (!encodeSeparately) { + this.writeTypedValue(buf, args, ObjectType.ObjectArray); + } else { + buf.writeInteger(args.length); + for (let i = 0; i < args.length; i++) { + this.writeTypedValue(buf, args[i], ObjectType.ObjectArray); + } + } + } + }); + } + + /** + * Registers a new {@link ValueEncoder} for the given tag. + * After the successful registration the {@link tagIntType} is recomputed + * by retrieving the highest tag value and calculating the required Uint size to store it. + * @param tag the tag for which the encoder should be registered. + * @param decoder the encoder that should be registered. + */ + registerEncoder(tag: number, encoder: ValueEncoder): void { + if (this.registeredTags.has(tag)) { + throw new Error(`Tag already registered: ${tag}`); + } + this.registeredTags.add(tag); + this.encoders.push([tag, encoder]); + const maxTagId = this.encoders.map(value => value[0]).sort().reverse()[0]; + this.tagIntType = getUintType(maxTagId); + } + + /** + * Processes the given array of request arguments to determine whether it contains + * arguments that require separate encoding (e.g. buffers) i.e. each argument needs to be encoded individually. + * If there are no arguments that require separate encoding the entire array can be encoded in one go with + * the fallback JSON encoder. + * @param args The request args. + * @returns `true` if the arguments require separate encoding, `false` otherwise. + */ + protected requiresSeparateEncoding(args: any[]): boolean { + return args.find(arg => arg instanceof Uint8Array || arg instanceof ArrayBuffer) !== undefined; + } + + writeString(buf: WriteBuffer, value: string): void { + buf.writeString(value); + } + + /** + * Writes the given value into the given {@link WriteBuffer}. Is potentially + * reused by some of the registered {@link ValueEncoder}s. Value encoders can pass + * their tag value as `excludeTag` to avoid encoding with the same parent encoder in case of + * recursive encoding. + * @param buf The buffer to write to. + * @param value The value that should be encoded. + * @param excludeTag Tag of an encode that should not be considered. + */ + writeTypedValue(buf: WriteBuffer, value: any, excludeTag: number = -1): void { + for (let i: number = this.encoders.length - 1; i >= 0; i--) { + const encoder = this.encoders[i]; + if (encoder[0] !== excludeTag && encoder[1].is(value)) { + buf.writeUint8(this.encoders[i][0]); + this.encoders[i][1].write(buf, value, (innerBuffer, innerValue) => { + this.writeTypedValue(innerBuffer, innerValue); + }); + return; + } + } + } + + cancel(buf: WriteBuffer, requestId: number): void { + buf.writeUint8(RpcMessageType.Cancel); + buf.writeUint32(requestId); + } + + notification(buf: WriteBuffer, requestId: number, method: string, args: any[]): void { + buf.writeUint8(RpcMessageType.Notification); + buf.writeUint32(requestId); + buf.writeString(method); + this.writeTypedValue(buf, args); + } + + request(buf: WriteBuffer, requestId: number, method: string, args: any[]): void { + buf.writeUint8(RpcMessageType.Request); + buf.writeUint32(requestId); + buf.writeString(method); + this.writeTypedValue(buf, args); + } + + replyOK(buf: WriteBuffer, requestId: number, res: any): void { + buf.writeUint8(RpcMessageType.Reply); + buf.writeUint32(requestId); + this.writeTypedValue(buf, res); + } + + replyErr(buf: WriteBuffer, requestId: number, err: any): void { + buf.writeUint8(RpcMessageType.ReplyErr); + buf.writeUint32(requestId); + this.writeTypedValue(buf, err); + } +} diff --git a/packages/core/src/common/message-rpc/rpc-protocol.ts b/packages/core/src/common/message-rpc/rpc-protocol.ts index f9ea1c93cc044..93bb04ea3e17d 100644 --- a/packages/core/src/common/message-rpc/rpc-protocol.ts +++ b/packages/core/src/common/message-rpc/rpc-protocol.ts @@ -1,55 +1,80 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ +// ***************************************************************************** +// Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 +// ***************************************************************************** /* eslint-disable @typescript-eslint/no-explicit-any */ import { Emitter, Event } from '../event'; import { Deferred } from '../promise-util'; -import { Channel } from './channel'; +import { Channel, MessageProvider } from './channel'; import { ReadBuffer } from './message-buffer'; -import { MessageDecoder, MessageEncoder, MessageType } from './message-encoder'; +import { RpcMessage, RpcMessageDecoder, RpcMessageEncoder, RpcMessageType } from './rpc-message-encoder'; +import { CancellationToken, CancellationTokenSource } from '../../../shared/vscode-languageserver-protocol'; + /** - * A RCPServer reads rcp request and notification messages and sends the reply values or + * Handles request messages received by the {@link RpcServer}. + */ +export type RequestHandler = (method: string, args: any[]) => Promise; + +const CANCELLATION_TOKEN_KEY = 'add.cancellation.token'; +/** + * Initialization options for {@link RpcClient}s and {@link RpcServer}s. + */ +export interface RpcInitializationOptions extends RPCConnectionOptions { + /** + * Boolean flag to indicate whether the client/server should be used as as standalone component or is part of + * a {@link RpcConnection}. Default is `true` + */ + standalone?: boolean, +} + +/** + * A `RpcServer` reads rcp request and notification messages and sends the reply values or * errors from the request to the channel. + * It can either be instantiated as a standalone component or as part of a {@link RpcConnection}. */ -export class RPCServer { - protected readonly encoder: MessageEncoder = new MessageEncoder(); - protected readonly decoder: MessageDecoder = new MessageDecoder(); - protected onNotificationEmitter: Emitter<{ method: string; args: any[]; }> = new Emitter(); +export class RpcServer { + protected readonly encoder: RpcMessageEncoder; + protected readonly decoder: RpcMessageDecoder; + + protected readonly onNotificationEmitter: Emitter<{ method: string; args: any[]; }> = new Emitter(); + protected readonly cancellationTokenSources = new Map(); get onNotification(): Event<{ method: string; args: any[]; }> { return this.onNotificationEmitter.event; } - constructor(protected channel: Channel, public readonly requestHandler: (method: string, args: any[]) => Promise) { - const registration = channel.onMessage((data: ReadBuffer) => this.handleMessage(data)); - channel.onClose(() => registration.dispose()); + constructor(protected channel: Channel, public readonly requestHandler: RequestHandler, options: RpcInitializationOptions = {}) { + this.encoder = options.encoder ?? new RpcMessageEncoder(); + this.decoder = options.decoder ?? new RpcMessageDecoder(); + if (options.standalone ?? true) { + const registration = channel.onMessage((msg: MessageProvider) => this.handleMessage(this.decoder.parse(msg()))); + channel.onClose(() => registration.dispose()); + } } - handleMessage(data: ReadBuffer): void { - const message = this.decoder.parse(data); + handleMessage(message: RpcMessage): void { switch (message.type) { - case MessageType.Cancel: { + case RpcMessageType.Cancel: { this.handleCancel(message.id); break; } - case MessageType.Request: { + case RpcMessageType.Request: { this.handleRequest(message.id, message.method, message.args); break; } - case MessageType.Notification: { + case RpcMessageType.Notification: { this.handleNotify(message.id, message.method, message.args); break; } @@ -57,63 +82,73 @@ export class RPCServer { } protected handleCancel(id: number): void { - // implement cancellation - /* const token = this.cancellationTokens.get(id); - if (token) { - this.cancellationTokens.delete(id); - token.cancel(); - } else { - console.warn(`cancel: no token for message: ${id}`); - }*/ + const cancellationTokenSource = this.cancellationTokenSources.get(id); + if (cancellationTokenSource) { + this.cancellationTokenSources.delete(id); + cancellationTokenSource.cancel(); + } } protected async handleRequest(id: number, method: string, args: any[]): Promise { + const output = this.channel.getWriteBuffer(); + + const addToken = args.length && args[args.length - 1] === CANCELLATION_TOKEN_KEY ? args.pop() : false; + if (addToken) { + const tokenSource = new CancellationTokenSource(); + this.cancellationTokenSources.set(id, tokenSource); + args.push(tokenSource.token); + } + try { - // console.log(`handling request ${method} with id ${id}`); const result = await this.requestHandler(method, args); + this.cancellationTokenSources.delete(id); this.encoder.replyOK(output, id, result); - // console.log(`handled request ${method} with id ${id}`); } catch (err) { + this.cancellationTokenSources.delete(id); this.encoder.replyErr(output, id, err); - console.log(`error on request ${method} with id ${id}`); } output.commit(); } protected async handleNotify(id: number, method: string, args: any[]): Promise { - // console.log(`handling notification ${method} with id ${id}`); this.onNotificationEmitter.fire({ method, args }); } } /** - * An RpcClient sends requests and notifications to a remote server. + * A `RpcClient` sends requests and notifications to a remote server. * Clients can get a promise for the request result that will be either resolved or * rejected depending on the success of the request. - * The RpcClient keeps track of outstanding requests and matches replies to the appropriate request + * The `RpcClient` keeps track of outstanding requests and matches replies to the appropriate request * Currently, there is no timeout handling implemented in the client. + * It can either be instantiated as a standalone component or as part of a {@link RpcConnection}. */ export class RpcClient { protected readonly pendingRequests: Map> = new Map(); + protected nextMessageId: number = 0; - protected readonly encoder: MessageEncoder = new MessageEncoder(); - protected readonly decoder: MessageDecoder = new MessageDecoder(); + protected readonly encoder: RpcMessageEncoder; + protected readonly decoder: RpcMessageDecoder; - constructor(protected channel: Channel) { - const registration = channel.onMessage((data: ReadBuffer) => this.handleMessage(data)); - channel.onClose(() => registration.dispose()); + constructor(public readonly channel: Channel, options: RpcInitializationOptions = {}) { + this.encoder = options.encoder ?? new RpcMessageEncoder(); + this.decoder = options.decoder ?? new RpcMessageDecoder(); + if (options.standalone ?? true) { + const registration = channel.onMessage(readBuffer => this.handleMessage(this.decoder.parse(readBuffer()))); + channel.onClose(() => registration.dispose()); + } } - handleMessage(data: ReadBuffer): void { - const message = this.decoder.parse(data); + handleMessage(message: RpcMessage): void { + switch (message.type) { - case MessageType.Reply: { + case RpcMessageType.Reply: { this.handleReply(message.id, message.res); break; } - case MessageType.ReplyErr: { + case RpcMessageType.ReplyErr: { this.handleReplyErr(message.id, message.err); break; } @@ -122,7 +157,6 @@ export class RpcClient { protected handleReply(id: number, value: any): void { const replyHandler = this.pendingRequests.get(id); - // console.log(`received reply with id ${id}`); if (replyHandler) { this.pendingRequests.delete(id); replyHandler.resolve(value); @@ -132,22 +166,39 @@ export class RpcClient { } protected handleReplyErr(id: number, error: any): void { - const replyHandler = this.pendingRequests.get(id); - if (replyHandler) { - this.pendingRequests.delete(id); - // console.log(`received error id ${id}`); - replyHandler.reject(error); - } else { - console.warn(`error: no handler for message: ${id}`); + try { + const replyHandler = this.pendingRequests.get(id); + if (replyHandler) { + this.pendingRequests.delete(id); + replyHandler.reject(error); + } else { + console.warn(`error: no handler for message: ${id}`); + } + } catch (err) { + throw err; } } sendRequest(method: string, args: any[]): Promise { + const id = this.nextMessageId++; const reply = new Deferred(); - // console.log(`sending request ${method} with id ${id}`); + const cancellationToken: CancellationToken | undefined = args.length && CancellationToken.is(args[args.length - 1]) ? args.pop() : undefined; + if (cancellationToken && cancellationToken.isCancellationRequested) { + return Promise.reject(this.cancelError()); + } + + if (cancellationToken) { + args.push(CANCELLATION_TOKEN_KEY); + cancellationToken.onCancellationRequested(() => { + this.sendCancel(id); + this.pendingRequests.get(id)?.reject(this.cancelError()); + } + ); + } this.pendingRequests.set(id, reply); + const output = this.channel.getWriteBuffer(); this.encoder.request(output, id, method, args); output.commit(); @@ -155,9 +206,75 @@ export class RpcClient { } sendNotification(method: string, args: any[]): void { - // console.log(`sending notification ${method} with id ${this.nextMessageId + 1}`); const output = this.channel.getWriteBuffer(); this.encoder.notification(output, this.nextMessageId++, method, args); output.commit(); } + + sendCancel(requestId: number): void { + const output = this.channel.getWriteBuffer(); + this.encoder.cancel(output, requestId); + output.commit(); + } + + cancelError(): Error { + const error = new Error('"Request has already been canceled by the sender"'); + error.name = 'Cancel'; + return error; + } +} + +/** + * Initialization options for a {@link RpcConnection}. + */ +export interface RPCConnectionOptions { + /** + * The message encoder that should be used. If `undefined` the default {@link RpcMessageEncoder} will be used. + */ + encoder?: RpcMessageEncoder, + /** + * The message decoder that should be used. If `undefined` the default {@link RpcMessageDecoder} will be used. + */ + decoder?: RpcMessageDecoder +} +/** + * A RpcConnection can be used to to establish a bi-directional RPC connection. It is capable of + * both sending & receiving requests and notifications to/from the channel. It acts as + * a {@link RpcServer} and a {@link RpcClient} at the same time. + */ +export class RpcConnection { + protected rpcClient: RpcClient; + protected rpcServer: RpcServer; + protected decoder = new RpcMessageDecoder(); + + get onNotification(): Event<{ method: string; args: any[]; }> { + return this.rpcServer.onNotification; + } + + constructor(readonly channel: Channel, public readonly requestHandler: (method: string, args: any[]) => Promise, options: RPCConnectionOptions = {}) { + this.decoder = options.decoder ?? new RpcMessageDecoder(); + this.rpcClient = new RpcClient(channel, { standalone: false, ...options }); + this.rpcServer = new RpcServer(channel, requestHandler, { standalone: false, ...options }); + const registration = channel.onMessage(data => this.handleMessage(data())); + channel.onClose(() => registration.dispose()); + } + + handleMessage(data: ReadBuffer): void { + const message = this.decoder.parse(data); + switch (message.type) { + case RpcMessageType.Reply: + case RpcMessageType.ReplyErr: { + this.rpcClient.handleMessage(message); + } + default: + this.rpcServer.handleMessage(message); + } + } + sendRequest(method: string, args: any[]): Promise { + return this.rpcClient.sendRequest(method, args); + } + + sendNotification(method: string, args: any[]): void { + this.rpcClient.sendNotification(method, args); + } } diff --git a/packages/core/src/common/message-rpc/rpc-proxy.ts b/packages/core/src/common/message-rpc/rpc-proxy.ts deleted file mode 100644 index 3578f64560942..0000000000000 --- a/packages/core/src/common/message-rpc/rpc-proxy.ts +++ /dev/null @@ -1,93 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2021 Red Hat, 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 WITH Classpath-exception-2.0 - ********************************************************************************/ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Deferred } from '../promise-util'; -import { Channel } from './channel'; -import { RpcClient, RPCServer } from './rpc-protocol'; - -/** - * A proxy handler that will send any method invocation on the proxied object - * as a rcp protocol message over a channel. - */ -export class RpcProxyHandler implements ProxyHandler { - private channelDeferred: Deferred = new Deferred(); - - onChannelOpen(channel: Channel): void { - const client = new RpcClient(channel); - this.channelDeferred.resolve(client); - } - - get?(target: T, p: string | symbol, receiver: any): any { - const isNotify = this.isNotification(p); - return (...args: any[]) => { - const method = p.toString(); - return this.channelDeferred.promise.then((connection: RpcClient) => - new Promise((resolve, reject) => { - try { - if (isNotify) { - // console.info(`Send notification ${method}`); - connection.sendNotification(method, args); - resolve(undefined); - } else { - // console.info(`Send request ${method}`); - const resultPromise = connection.sendRequest(method, args) as Promise; - resultPromise.then((result: any) => { - // console.info(`request succeeded: ${method}`); - resolve(result); - }).catch(e => { - reject(e); - }); - } - } catch (err) { - reject(err); - } - }) - ); - }; - } - - /** - * Return whether the given property represents a notification. If true, - * the promise returned from the invocation will resolve immediatey to `undefined` - * - * A property leads to a notification rather than a method call if its name - * begins with `notify` or `on`. - * - * @param p - The property being called on the proxy. - * @return Whether `p` represents a notification. - */ - protected isNotification(p: PropertyKey): boolean { - return p.toString().startsWith('notify') || p.toString().startsWith('on'); - } -} - -export class RpcHandler { - constructor(readonly target: any) { - } - - onChannelOpen(channel: Channel): void { - const server = new RPCServer(channel, (method: string, args: any[]) => this.handleRequest(method, args)); - server.onNotification((e: { method: string, args: any }) => this.onNotification(e.method, e.args)); - } - - protected async handleRequest(method: string, args: any[]): Promise { - return this.target[method](...args); - } - - protected onNotification(method: string, args: any[]): void { - this.target[method](args); - } -} diff --git a/packages/core/src/common/message-rpc/websocket-client-channel.ts b/packages/core/src/common/message-rpc/websocket-client-channel.ts deleted file mode 100644 index bf07088f18448..0000000000000 --- a/packages/core/src/common/message-rpc/websocket-client-channel.ts +++ /dev/null @@ -1,229 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2018 TypeFox 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 WITH Classpath-exception-2.0 - ********************************************************************************/ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import ReconnectingWebSocket from 'reconnecting-websocket'; -import { v4 as uuid } from 'uuid'; -import { Channel } from './channel'; -import { ReadBuffer, WriteBuffer } from './message-buffer'; -import { ArrayBufferReadBuffer, ArrrayBufferWriteBuffer } from './array-buffer-message-buffer'; -import { Deferred } from '../promise-util'; -import { Emitter, Event } from '../event'; -import { Endpoint } from 'src/browser'; - -/** - * An attempt at a channel implementation over a websocket with fallback to http. - */ - -export interface WebSocketOptions { - /** - * True by default. - */ - reconnecting?: boolean; -} - -export const HttpFallbackOptions = Symbol('HttpFallbackOptions'); - -export interface HttpFallbackOptions { - /** Determines whether Theia is allowed to use the http fallback. True by default. */ - allowed: boolean; - /** Number of failed websocket connection attempts before the fallback is triggered. 2 by default. */ - maxAttempts: number; - /** The maximum duration (in ms) after which the http request should timeout. 5000 by default. */ - pollingTimeout: number; - /** The timeout duration (in ms) after a request was answered with an error code. 5000 by default. */ - errorTimeout: number; - /** The minimum timeout duration (in ms) between two http requests. 0 by default. */ - requestTimeout: number; -} - -export const DEFAULT_HTTP_FALLBACK_OPTIONS: HttpFallbackOptions = { - allowed: true, - maxAttempts: 2, - errorTimeout: 5000, - pollingTimeout: 5000, - requestTimeout: 0 -}; - -export class WebSocketClientChannel implements Channel { - - protected readonly readyDeferred: Deferred = new Deferred(); - - protected readonly onCloseEmitter: Emitter = new Emitter(); - get onClose(): Event { - return this.onCloseEmitter.event; - } - - protected readonly onMessageEmitter: Emitter = new Emitter(); - get onMessage(): Event { - return this.onMessageEmitter.event; - } - - protected readonly onErrorEmitter: Emitter = new Emitter(); - get onError(): Event { - return this.onErrorEmitter.event; - } - - protected readonly socket: ReconnectingWebSocket; - protected useHttpFallback = false; - protected websocketErrorCounter = 0; - protected httpFallbackId = uuid(); - protected httpFallbackDisconnected = true; - - constructor(protected readonly httpFallbackOptions: HttpFallbackOptions | undefined) { - const url = this.createWebSocketUrl('/services'); - const socket = this.createWebSocket(url); - socket.onerror = event => this.handleSocketError(event); - socket.onopen = () => { - this.fireSocketDidOpen(); - }; - socket.onclose = ({ code, reason }) => { - this.onCloseEmitter.fire(); - }; - socket.onmessage = ({ data }) => { - this.onMessageEmitter.fire(new ArrayBufferReadBuffer(data)); - }; - this.socket = socket; - window.addEventListener('offline', () => this.tryReconnect()); - window.addEventListener('online', () => this.tryReconnect()); - } - - getWriteBuffer(): WriteBuffer { - const result = new ArrrayBufferWriteBuffer(); - const httpUrl = this.createHttpWebSocketUrl('/services'); - if (this.useHttpFallback) { - result.writeString(this.httpFallbackId); - result.writeString('true'); - result.onCommit(buffer => { - fetch(httpUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/octet-stream' - }, - body: buffer - }); - }); - - } else if (this.socket.readyState < WebSocket.CLOSING) { - result.onCommit(buffer => { - this.socket.send(buffer); - }); - } - return result; - - } - - close(): void { - this.socket.close(); - } - - get ready(): Promise { - return this.readyDeferred.promise; - } - - handleSocketError(event: unknown): void { - this.websocketErrorCounter += 1; - if (this.httpFallbackOptions?.allowed && this.websocketErrorCounter >= this.httpFallbackOptions?.maxAttempts) { - this.useHttpFallback = true; - this.socket.close(); - const httpUrl = this.createHttpWebSocketUrl('/services'); - this.readyDeferred.resolve(); - this.doLongPolling(httpUrl); - console.warn( - 'Could not establish a websocket connection. The application will be using the HTTP fallback mode. This may affect performance and the behavior of some features.' - ); - } - this.onErrorEmitter.fire(event); - console.error(event); - } - - async doLongPolling(url: string): Promise { - let timeoutDuration = this.httpFallbackOptions?.requestTimeout || 0; - const controller = new AbortController(); - const pollingId = window.setTimeout(() => controller.abort(), this.httpFallbackOptions?.pollingTimeout); - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - signal: controller.signal, - keepalive: true, - body: JSON.stringify({ id: this.httpFallbackId, polling: true }) - }); - if (response.status === 200) { - window.clearTimeout(pollingId); - if (this.httpFallbackDisconnected) { - this.fireSocketDidOpen(); - } - const bytes = await response.arrayBuffer(); - this.onMessageEmitter.fire(new ArrayBufferReadBuffer(bytes)); - } else { - timeoutDuration = this.httpFallbackOptions?.errorTimeout || 0; - this.httpFallbackDisconnected = true; - this.onCloseEmitter.fire(); - throw new Error('Response has error code: ' + response.status); - } - } catch (e) { - console.error('Error occurred during long polling', e); - } - setTimeout(() => this.doLongPolling(url), timeoutDuration); - } - - /** - * Creates a websocket URL to the current location - */ - protected createWebSocketUrl(path: string): string { - const endpoint = new Endpoint({ path }); - return endpoint.getWebSocketUrl().toString(); - } - - protected createHttpWebSocketUrl(path: string): string { - const endpoint = new Endpoint({ path }); - return endpoint.getRestUrl().toString(); - } - - /** - * Creates a web socket for the given url - */ - protected createWebSocket(url: string): ReconnectingWebSocket { - const socket = new ReconnectingWebSocket(url, undefined, { - maxReconnectionDelay: 10000, - minReconnectionDelay: 1000, - reconnectionDelayGrowFactor: 1.3, - connectionTimeout: 10000, - maxRetries: Infinity, - debug: false - }); - socket.binaryType = 'arraybuffer'; - return socket; - } - - protected fireSocketDidOpen(): void { - // Once a websocket connection has opened, disable the http fallback - if (this.httpFallbackOptions?.allowed) { - this.httpFallbackOptions.allowed = false; - } - this.readyDeferred.resolve(); - } - - protected tryReconnect(): void { - if (!this.useHttpFallback && this.socket.readyState !== WebSocket.CONNECTING) { - this.socket.reconnect(); - } - } - -} diff --git a/packages/core/src/common/messaging/abstract-connection-provider.ts b/packages/core/src/common/messaging/abstract-connection-provider.ts index d4c5c3bd3aaf3..02f7696ff297c 100644 --- a/packages/core/src/common/messaging/abstract-connection-provider.ts +++ b/packages/core/src/common/messaging/abstract-connection-provider.ts @@ -15,11 +15,10 @@ // ***************************************************************************** import { injectable, interfaces } from 'inversify'; -import { ConsoleLogger, createWebSocketConnection, Logger } from 'vscode-ws-jsonrpc'; import { Emitter, Event } from '../event'; +import { Channel, ChannelMultiplexer } from '../message-rpc/channel'; import { ConnectionHandler } from './handler'; import { JsonRpcProxy, JsonRpcProxyFactory } from './proxy-factory'; -import { WebSocketChannel } from './web-socket-channel'; /** * Factor common logic according to `ElectronIpcConnectionProvider` and @@ -45,9 +44,6 @@ export abstract class AbstractConnectionProvider throw new Error('abstract'); } - protected channelIdSeq = 0; - protected readonly channels = new Map(); - protected readonly onIncomingMessageActivityEmitter: Emitter = new Emitter(); get onIncomingMessageActivity(): Event { return this.onIncomingMessageActivityEmitter.event; @@ -75,50 +71,39 @@ export abstract class AbstractConnectionProvider return factory.createProxy(); } + protected channelMultiPlexer: ChannelMultiplexer; + + constructor() { + this.channelMultiPlexer = this.createMultiplexer(); + } + + protected createMultiplexer(): ChannelMultiplexer { + return new ChannelMultiplexer(this.createMainChannel()); + } + /** * Install a connection handler for the given path. */ listen(handler: ConnectionHandler, options?: AbstractOptions): void { this.openChannel(handler.path, channel => { - const connection = createWebSocketConnection(channel, this.createLogger()); - connection.onDispose(() => channel.close()); - handler.onConnection(connection); + handler.onConnection(channel); }, options); } - openChannel(path: string, handler: (channel: WebSocketChannel) => void, options?: AbstractOptions): void { - const id = this.channelIdSeq++; - const channel = this.createChannel(id); - this.channels.set(id, channel); - channel.onClose(() => { - if (this.channels.delete(channel.id)) { - const { reconnecting } = { reconnecting: true, ...options }; - if (reconnecting) { - this.openChannel(path, handler, options); - } - } else { - console.error('The ws channel does not exist', channel.id); + async openChannel(path: string, handler: (channel: Channel) => void, options?: AbstractOptions): Promise { + const newChannel = await this.channelMultiPlexer.open(path); + newChannel.onClose(() => { + const { reconnecting } = { reconnecting: true, ...options }; + if (reconnecting) { + this.openChannel(path, handler, options); } }); - channel.onOpen(() => handler(channel)); - channel.open(path); + handler(newChannel); } - protected abstract createChannel(id: number): WebSocketChannel; - - protected handleIncomingRawMessage(data: string): void { - const message: WebSocketChannel.Message = JSON.parse(data); - const channel = this.channels.get(message.id); - if (channel) { - channel.handleMessage(message); - } else { - console.error('The ws channel does not exist', message.id); - } - this.onIncomingMessageActivityEmitter.fire(undefined); - } - - protected createLogger(): Logger { - return new ConsoleLogger(); - } + /** + * Create the main connection that is used for multiplexing all channels. + */ + protected abstract createMainChannel(): Channel; } diff --git a/packages/core/src/common/messaging/handler.ts b/packages/core/src/common/messaging/handler.ts index ed03d9d331206..1e790d38aeec3 100644 --- a/packages/core/src/common/messaging/handler.ts +++ b/packages/core/src/common/messaging/handler.ts @@ -14,11 +14,11 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { MessageConnection } from 'vscode-ws-jsonrpc'; +import { Channel } from '../message-rpc/channel'; export const ConnectionHandler = Symbol('ConnectionHandler'); export interface ConnectionHandler { readonly path: string; - onConnection(connection: MessageConnection): void; + onConnection(connection: Channel): void; } diff --git a/packages/core/src/common/messaging/proxy-factory.spec.ts b/packages/core/src/common/messaging/proxy-factory.spec.ts index 2fd0700a41034..37280e4dbfdaa 100644 --- a/packages/core/src/common/messaging/proxy-factory.spec.ts +++ b/packages/core/src/common/messaging/proxy-factory.spec.ts @@ -15,21 +15,11 @@ // ***************************************************************************** import * as chai from 'chai'; -import { ConsoleLogger } from '../../node/messaging/logger'; import { JsonRpcProxyFactory, JsonRpcProxy } from './proxy-factory'; -import { createMessageConnection } from 'vscode-jsonrpc/lib/main'; -import * as stream from 'stream'; +import { ChannelPipe } from '../message-rpc/channel.spec'; const expect = chai.expect; -class NoTransform extends stream.Transform { - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - override _transform(chunk: any, encoding: string, callback: Function): void { - callback(undefined, chunk); - } -} - class TestServer { requests: string[] = []; doStuff(arg: string): Promise { @@ -102,15 +92,12 @@ function getSetup(): { const server = new TestServer(); const serverProxyFactory = new JsonRpcProxyFactory(client); - const client2server = new NoTransform(); - const server2client = new NoTransform(); - const serverConnection = createMessageConnection(server2client, client2server, new ConsoleLogger()); - serverProxyFactory.listen(serverConnection); + const pipe = new ChannelPipe(); + serverProxyFactory.listen(pipe.right); const serverProxy = serverProxyFactory.createProxy(); const clientProxyFactory = new JsonRpcProxyFactory(server); - const clientConnection = createMessageConnection(client2server, server2client, new ConsoleLogger()); - clientProxyFactory.listen(clientConnection); + clientProxyFactory.listen(pipe.left); const clientProxy = clientProxyFactory.createProxy(); return { client, diff --git a/packages/core/src/common/messaging/proxy-factory.ts b/packages/core/src/common/messaging/proxy-factory.ts index f8869449eae94..11752f256d623 100644 --- a/packages/core/src/common/messaging/proxy-factory.ts +++ b/packages/core/src/common/messaging/proxy-factory.ts @@ -16,10 +16,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { MessageConnection, ResponseError } from 'vscode-ws-jsonrpc'; +import { ResponseError } from 'vscode-ws-jsonrpc'; import { ApplicationError } from '../application-error'; -import { Event, Emitter } from '../event'; import { Disposable } from '../disposable'; +import { Emitter, Event } from '../event'; +import { Channel } from '../message-rpc/channel'; +import { RequestHandler, RpcConnection } from '../message-rpc/rpc-protocol'; import { ConnectionHandler } from './handler'; export type JsonRpcServer = Disposable & { @@ -45,13 +47,19 @@ export class JsonRpcConnectionHandler implements ConnectionHan readonly factoryConstructor: new () => JsonRpcProxyFactory = JsonRpcProxyFactory ) { } - onConnection(connection: MessageConnection): void { + onConnection(connection: Channel): void { const factory = new this.factoryConstructor(); const proxy = factory.createProxy(); factory.target = this.targetFactory(proxy); factory.listen(connection); } } +/** + * Factory for creating a new {@link RpcConnection} for a given chanel and {@link RequestHandler}. + */ +export type RpcConnectionFactory = (channel: Channel, requestHandler: RequestHandler) => RpcConnection; + +const defaultRPCConnectionFactory: RpcConnectionFactory = (channel, requestHandler) => new RpcConnection(channel, requestHandler); /** * Factory for JSON-RPC proxy objects. @@ -95,13 +103,14 @@ export class JsonRpcConnectionHandler implements ConnectionHan * * @param - The type of the object to expose to JSON-RPC. */ + export class JsonRpcProxyFactory implements ProxyHandler { protected readonly onDidOpenConnectionEmitter = new Emitter(); protected readonly onDidCloseConnectionEmitter = new Emitter(); - protected connectionPromiseResolve: (connection: MessageConnection) => void; - protected connectionPromise: Promise; + protected connectionPromiseResolve: (connection: RpcConnection) => void; + protected connectionPromise: Promise; /** * Build a new JsonRpcProxyFactory. @@ -109,7 +118,7 @@ export class JsonRpcProxyFactory implements ProxyHandler { * @param target - The object to expose to JSON-RPC methods calls. If this * is omitted, the proxy won't be able to handle requests, only send them. */ - constructor(public target?: any) { + constructor(public target?: any, protected rpcConnectionFactory = defaultRPCConnectionFactory) { this.waitForConnection(); } @@ -118,7 +127,7 @@ export class JsonRpcProxyFactory implements ProxyHandler { this.connectionPromiseResolve = resolve ); this.connectionPromise.then(connection => { - connection.onClose(() => + connection.channel.onClose(() => this.onDidCloseConnectionEmitter.fire(undefined) ); this.onDidOpenConnectionEmitter.fire(undefined); @@ -131,11 +140,10 @@ export class JsonRpcProxyFactory implements ProxyHandler { * This connection will be used to send/receive JSON-RPC requests and * response. */ - listen(connection: MessageConnection): void { - connection.onRequest((prop, ...args) => this.onRequest(prop, ...args)); - connection.onNotification((prop, ...args) => this.onNotification(prop, ...args)); - connection.onDispose(() => this.waitForConnection()); - connection.listen(); + listen(channel: Channel): void { + const connection = this.rpcConnectionFactory(channel, (meth, args) => this.onRequest(meth, ...args)); + connection.onNotification(event => this.onNotification(event.method, ...event.args)); + this.connectionPromiseResolve(connection); } @@ -239,10 +247,10 @@ export class JsonRpcProxyFactory implements ProxyHandler { new Promise((resolve, reject) => { try { if (isNotify) { - connection.sendNotification(method, ...args); + connection.sendNotification(method, args); resolve(undefined); } else { - const resultPromise = connection.sendRequest(method, ...args) as Promise; + const resultPromise = connection.sendRequest(method, args) as Promise; resultPromise .catch((err: any) => reject(this.deserializeError(capturedError, err))) .then((result: any) => resolve(result)); @@ -293,3 +301,4 @@ export class JsonRpcProxyFactory implements ProxyHandler { } } + diff --git a/packages/core/src/common/messaging/web-socket-channel.ts b/packages/core/src/common/messaging/web-socket-channel.ts index 28dff9400068a..ce1d7501cf3bf 100644 --- a/packages/core/src/common/messaging/web-socket-channel.ts +++ b/packages/core/src/common/messaging/web-socket-channel.ts @@ -16,157 +16,92 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { IWebSocket } from 'vscode-ws-jsonrpc/lib/socket/socket'; -import { Disposable, DisposableCollection } from '../disposable'; -import { Emitter } from '../event'; - -export class WebSocketChannel implements IWebSocket { - +import { Emitter, Event } from '../event'; +import { WriteBuffer } from '../message-rpc'; +import { ArrayBufferReadBuffer, ArrayBufferWriteBuffer } from '../message-rpc/array-buffer-message-buffer'; +import { Channel, MessageProvider, ChannelCloseEvent } from '../message-rpc/channel'; + +/** + * A channel that manages the main websocket connection between frontend and backend. All service channels + * are reusing this main channel. (multiplexing). An {@link IWebSocket} abstraction is used to keep the implementation + * independent of the actual websocket implementation and its execution context (backend vs. frontend). + */ +export class WebSocketChannel implements Channel { static wsPath = '/services'; - protected readonly closeEmitter = new Emitter<[number, string]>(); - protected readonly toDispose = new DisposableCollection(this.closeEmitter); - - constructor( - readonly id: number, - protected readonly doSend: (content: string) => void - ) { } - - dispose(): void { - this.toDispose.dispose(); - } - - protected checkNotDisposed(): void { - if (this.toDispose.disposed) { - throw new Error('The channel has been disposed.'); - } - } - - handleMessage(message: WebSocketChannel.Message): void { - if (message.kind === 'ready') { - this.fireOpen(); - } else if (message.kind === 'data') { - this.fireMessage(message.content); - } else if (message.kind === 'close') { - this.fireClose(message.code, message.reason); - } - } - - open(path: string): void { - this.checkNotDisposed(); - this.doSend(JSON.stringify({ - kind: 'open', - id: this.id, - path - })); - } - - ready(): void { - this.checkNotDisposed(); - this.doSend(JSON.stringify({ - kind: 'ready', - id: this.id - })); + protected readonly onCloseEmitter: Emitter = new Emitter(); + get onClose(): Event { + return this.onCloseEmitter.event; } - send(content: string): void { - this.checkNotDisposed(); - this.doSend(JSON.stringify({ - kind: 'data', - id: this.id, - content - })); + protected readonly onMessageEmitter: Emitter = new Emitter(); + get onMessage(): Event { + return this.onMessageEmitter.event; } - close(code: number = 1000, reason: string = ''): void { - if (this.closing) { - // Do not try to close the channel if it is already closing. - return; - } - this.checkNotDisposed(); - this.doSend(JSON.stringify({ - kind: 'close', - id: this.id, - code, - reason - })); - this.fireClose(code, reason); + protected readonly onErrorEmitter: Emitter = new Emitter(); + get onError(): Event { + return this.onErrorEmitter.event; } - tryClose(code: number = 1000, reason: string = ''): void { - if (this.closing || this.toDispose.disposed) { - // Do not try to close the channel if it is already closing or disposed. - return; - } - this.doSend(JSON.stringify({ - kind: 'close', - id: this.id, - code, - reason - })); - this.fireClose(code, reason); + constructor(protected readonly socket: IWebSocket) { + socket.onClose((reason, code) => this.onCloseEmitter.fire({ reason, code })); + socket.onError(error => this.onErrorEmitter.fire(error)); + socket.onMessage(buffer => this.onMessageEmitter.fire(() => new ArrayBufferReadBuffer(buffer))); } - protected fireOpen: () => void = () => { }; - onOpen(cb: () => void): void { - this.checkNotDisposed(); - this.fireOpen = cb; - this.toDispose.push(Disposable.create(() => this.fireOpen = () => { })); - } + getWriteBuffer(): WriteBuffer { + const result = new ArrayBufferWriteBuffer(); - protected fireMessage: (data: any) => void = () => { }; - onMessage(cb: (data: any) => void): void { - this.checkNotDisposed(); - this.fireMessage = cb; - this.toDispose.push(Disposable.create(() => this.fireMessage = () => { })); - } + result.onCommit(buffer => { + if (this.socket.isConnected()) { + this.socket.send(buffer); + } + }); - fireError: (reason: any) => void = () => { }; - onError(cb: (reason: any) => void): void { - this.checkNotDisposed(); - this.fireError = cb; - this.toDispose.push(Disposable.create(() => this.fireError = () => { })); + return result; } - protected closing = false; - protected fireClose(code: number, reason: string): void { - if (this.closing) { - return; - } - this.closing = true; - try { - this.closeEmitter.fire([code, reason]); - } finally { - this.closing = false; - } - this.dispose(); + close(): void { + this.socket.close(); + this.onCloseEmitter.dispose(); + this.onMessageEmitter.dispose(); + this.onErrorEmitter.dispose(); } - onClose(cb: (code: number, reason: string) => void): Disposable { - this.checkNotDisposed(); - return this.closeEmitter.event(([code, reason]) => cb(code, reason)); - } - } -export namespace WebSocketChannel { - export interface OpenMessage { - kind: 'open' - id: number - path: string - } - export interface ReadyMessage { - kind: 'ready' - id: number - } - export interface DataMessage { - kind: 'data' - id: number - content: string - } - export interface CloseMessage { - kind: 'close' - id: number - code: number - reason: string - } - export type Message = OpenMessage | ReadyMessage | DataMessage | CloseMessage; + +/** + * An abstraction that enables reuse of the `{@link WebSocketChannel} class in the frontend and backend + * independent of the actual underlying socket implementation. + */ +export interface IWebSocket { + /** + * Sends the given message over the web socket in binary format. + * @param message The binary message. + */ + send(message: ArrayBuffer): void; + /** + * Closes the websocket from the local side. + */ + close(): void; + /** + * The connection state of the web socket. + */ + isConnected(): boolean; + /** + * Listener callback to handle incoming messages. + * @param cb The callback. + */ + onMessage(cb: (message: ArrayBuffer) => void): void; + /** + * Listener callback to handle socket errors. + * @param cb The callback. + */ + onError(cb: (reason: any) => void): void; + /** + * Listener callback to handle close events (Remote side). + * @param cb The callback. + */ + onClose(cb: (reason: string, code?: number) => void): void; } + diff --git a/packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts b/packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts index b3d8ce8ab9415..366cfd9cb17bf 100644 --- a/packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts +++ b/packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts @@ -16,8 +16,10 @@ import { Event as ElectronEvent, ipcRenderer } from '@theia/electron/shared/electron'; import { injectable, interfaces } from 'inversify'; +import { Emitter, Event } from '../../common'; +import { ArrayBufferReadBuffer, ArrayBufferWriteBuffer } from '../../common/message-rpc/array-buffer-message-buffer'; +import { Channel, MessageProvider } from '../../common/message-rpc/channel'; import { JsonRpcProxy } from '../../common/messaging'; -import { WebSocketChannel } from '../../common/messaging/web-socket-channel'; import { AbstractConnectionProvider } from '../../common/messaging/abstract-connection-provider'; import { THEIA_ELECTRON_IPC_CHANNEL_NAME } from '../../electron-common/messaging/electron-connection-handler'; @@ -34,17 +36,25 @@ export class ElectronIpcConnectionProvider extends AbstractConnectionProvider(path, arg); } - constructor() { - super(); - ipcRenderer.on(THEIA_ELECTRON_IPC_CHANNEL_NAME, (event: ElectronEvent, data: string) => { - this.handleIncomingRawMessage(data); - }); - } - - protected createChannel(id: number): WebSocketChannel { - return new WebSocketChannel(id, content => { - ipcRenderer.send(THEIA_ELECTRON_IPC_CHANNEL_NAME, content); + protected createMainChannel(): Channel { + const onMessageEmitter = new Emitter(); + ipcRenderer.on(THEIA_ELECTRON_IPC_CHANNEL_NAME, (_event: ElectronEvent, data: Uint8Array) => { + onMessageEmitter.fire(() => new ArrayBufferReadBuffer(data.buffer)); }); + return { + close: () => Event.None, + getWriteBuffer: () => { + const writer = new ArrayBufferWriteBuffer(); + writer.onCommit(buffer => + // The ipcRenderer cannot handle ArrayBuffers directly=> we have to convert to Uint8Array. + ipcRenderer.send(THEIA_ELECTRON_IPC_CHANNEL_NAME, new Uint8Array(buffer)) + ); + return writer; + }, + onClose: Event.None, + onError: Event.None, + onMessage: onMessageEmitter.event + }; } } diff --git a/packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts b/packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts index 6f75ea31d0dae..e038b6a31f387 100644 --- a/packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts +++ b/packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts @@ -15,9 +15,9 @@ // ***************************************************************************** import { injectable } from 'inversify'; -import { WebSocketChannel } from '../../common/messaging/web-socket-channel'; -import { WebSocketConnectionProvider, WebSocketOptions } from '../../browser/messaging/ws-connection-provider'; import { FrontendApplicationContribution } from '../../browser/frontend-application'; +import { WebSocketConnectionProvider, WebSocketOptions } from '../../browser/messaging/ws-connection-provider'; +import { Channel } from '../../common'; /** * Customized connection provider between the frontend and the backend in electron environment. @@ -34,16 +34,14 @@ export class ElectronWebSocketConnectionProvider extends WebSocketConnectionProv onStop(): void { this.stopping = true; - // Close the websocket connection `onStop`. Otherwise, the channels will be closed with 30 sec (`MessagingContribution#checkAliveTimeout`) delay. + // Manually close the websocket connections `onStop`. Otherwise, the channels will be closed with 30 sec (`MessagingContribution#checkAliveTimeout`) delay. // https://github.com/eclipse-theia/theia/issues/6499 - for (const channel of [...this.channels.values()]) { - // `1001` indicates that an endpoint is "going away", such as a server going down or a browser having navigated away from a page. - // But we cannot use `1001`: https://github.com/TypeFox/vscode-ws-jsonrpc/issues/15 - channel.close(1000, 'The frontend is "going away"...'); - } + // `1001` indicates that an endpoint is "going away", such as a server going down or a browser having navigated away from a page. + + this.channelMultiPlexer.closeUnderlyingChannel({ reason: 'The frontend is "going away"', code: 1001 }); } - override openChannel(path: string, handler: (channel: WebSocketChannel) => void, options?: WebSocketOptions): void { + override async openChannel(path: string, handler: (channel: Channel) => void, options?: WebSocketOptions): Promise { if (!this.stopping) { super.openChannel(path, handler, options); } diff --git a/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts b/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts index 071796c5cf0ca..afb9a90a1e963 100644 --- a/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts +++ b/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts @@ -14,26 +14,24 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { IpcMainEvent, ipcMain, WebContents } from '@theia/electron/shared/electron'; +import { ipcMain, IpcMainEvent, WebContents } from '@theia/electron/shared/electron'; import { inject, injectable, named, postConstruct } from 'inversify'; -import { MessageConnection } from 'vscode-ws-jsonrpc'; -import { createWebSocketConnection } from 'vscode-ws-jsonrpc/lib/socket/connection'; +import { Emitter, Event, WriteBuffer } from '../../common'; import { ContributionProvider } from '../../common/contribution-provider'; -import { WebSocketChannel } from '../../common/messaging/web-socket-channel'; -import { MessagingContribution } from '../../node/messaging/messaging-contribution'; -import { ConsoleLogger } from '../../node/messaging/logger'; +import { ArrayBufferReadBuffer, ArrayBufferWriteBuffer } from '../../common/message-rpc/array-buffer-message-buffer'; +import { Channel, ChannelCloseEvent, ChannelMultiplexer, MessageProvider } from '../../common/message-rpc/channel'; import { ElectronConnectionHandler, THEIA_ELECTRON_IPC_CHANNEL_NAME } from '../../electron-common/messaging/electron-connection-handler'; +import { MessagingContribution } from '../../node/messaging/messaging-contribution'; import { ElectronMainApplicationContribution } from '../electron-main-application'; import { ElectronMessagingService } from './electron-messaging-service'; - /** * This component replicates the role filled by `MessagingContribution` but for Electron. * Unlike the WebSocket based implementation, we do not expect to receive * connection events. Instead, we'll create channels based on incoming `open` * events on the `ipcMain` channel. - * * This component allows communication between renderer process (frontend) and electron main process. */ + @injectable() export class ElectronMessagingContribution implements ElectronMainApplicationContribution, ElectronMessagingService { @@ -43,89 +41,105 @@ export class ElectronMessagingContribution implements ElectronMainApplicationCon @inject(ContributionProvider) @named(ElectronConnectionHandler) protected readonly connectionHandlers: ContributionProvider; - protected readonly channelHandlers = new MessagingContribution.ConnectionHandlers(); - protected readonly windowChannels = new Map>(); + protected readonly channelHandlers = new MessagingContribution.ConnectionHandlers(); + /** + * Each electron window has a main chanel and its own multiplexer to route multiple client messages the same IPC connection. + */ + protected readonly windowChannelMultiplexer = new Map(); @postConstruct() protected init(): void { - ipcMain.on(THEIA_ELECTRON_IPC_CHANNEL_NAME, (event: IpcMainEvent, data: string) => { - this.handleIpcMessage(event, data); + ipcMain.on(THEIA_ELECTRON_IPC_CHANNEL_NAME, (event: IpcMainEvent, data: Uint8Array) => { + this.handleIpcEvent(event, data); }); } + protected handleIpcEvent(event: IpcMainEvent, data: Uint8Array): void { + const sender = event.sender; + try { + // Get the multiplexer for a given window id + const windowChannelData = this.windowChannelMultiplexer.get(sender.id)!; + if (!windowChannelData) { + const mainChannel = this.createWindowMainChannel(sender); + const multiPlexer = new ChannelMultiplexer(mainChannel); + multiPlexer.onDidOpenChannel(openEvent => { + const { channel, id } = openEvent; + if (this.channelHandlers.route(id, channel)) { + console.debug(`Opening channel for service path '${id}'.`); + channel.onClose(() => console.debug(`Closing channel on service path '${id}'.`)); + } + }); + + sender.once('did-navigate', () => multiPlexer.closeUnderlyingChannel({ reason: 'Window was refreshed' })); // When refreshing the browser window. + sender.once('destroyed', () => multiPlexer.closeUnderlyingChannel({ reason: 'Window was closed' })); // When closing the browser window. + this.windowChannelMultiplexer.set(sender.id, { channel: mainChannel, multiPlexer }); + } + windowChannelData.channel.onMessageEmitter.fire(() => new ArrayBufferReadBuffer(data.buffer)); + } catch (error) { + console.error('IPC: Failed to handle message', { error, data }); + } + } + + /** + * Creates the main channel to a window. + * @param sender The window that the channel should be established to. + */ + protected createWindowMainChannel(sender: WebContents): ElectronWebContentChannel { + return new ElectronWebContentChannel(sender); + } + onStart(): void { for (const contribution of this.messagingContributions.getContributions()) { contribution.configure(this); } for (const connectionHandler of this.connectionHandlers.getContributions()) { this.channelHandlers.push(connectionHandler.path, (params, channel) => { - const connection = createWebSocketConnection(channel, new ConsoleLogger()); - connectionHandler.onConnection(connection); + connectionHandler.onConnection(channel); }); } } - listen(spec: string, callback: (params: ElectronMessagingService.PathParams, connection: MessageConnection) => void): void { - this.ipcChannel(spec, (params, channel) => { - const connection = createWebSocketConnection(channel, new ConsoleLogger()); - callback(params, connection); - }); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ipcChannel(spec: string, callback: (params: any, channel: WebSocketChannel) => void): void { + ipcChannel(spec: string, callback: (params: any, channel: Channel) => void): void { this.channelHandlers.push(spec, callback); } +} + +/** + * Used to establish a connection between the ipcMain and the Electron frontend (window). + * Messages a transferred via electron IPC. + */ +export class ElectronWebContentChannel implements Channel { + protected readonly onCloseEmitter: Emitter = new Emitter(); + get onClose(): Event { + return this.onCloseEmitter.event; + } - protected handleIpcMessage(event: IpcMainEvent, data: string): void { - const sender = event.sender; - try { - // Get the channel map for a given window id - let channels = this.windowChannels.get(sender.id)!; - if (!channels) { - this.windowChannels.set(sender.id, channels = new Map()); - } - // Start parsing the message to extract the channel id and route - const message: WebSocketChannel.Message = JSON.parse(data.toString()); - // Someone wants to open a logical channel - if (message.kind === 'open') { - const { id, path } = message; - const channel = this.createChannel(id, sender); - if (this.channelHandlers.route(path, channel)) { - channel.ready(); - channels.set(id, channel); - channel.onClose(() => channels.delete(id)); - } else { - console.error('Cannot find a service for the path: ' + path); - } - } else { - const { id } = message; - const channel = channels.get(id); - if (channel) { - channel.handleMessage(message); - } else { - console.error('The ipc channel does not exist', id); - } - } - const close = () => { - for (const channel of Array.from(channels.values())) { - channel.close(undefined, 'webContent destroyed'); - } - channels.clear(); - }; - sender.once('did-navigate', close); // When refreshing the browser window. - sender.once('destroyed', close); // When closing the browser window. - } catch (error) { - console.error('IPC: Failed to handle message', { error, data }); - } + // Make the message emitter public so that we can easily forward messages received from the ipcMain. + readonly onMessageEmitter: Emitter = new Emitter(); + get onMessage(): Event { + return this.onMessageEmitter.event; + } + + protected readonly onErrorEmitter: Emitter = new Emitter(); + get onError(): Event { + return this.onErrorEmitter.event; + } + + constructor(protected readonly sender: Electron.WebContents) { } - protected createChannel(id: number, sender: WebContents): WebSocketChannel { - return new WebSocketChannel(id, content => { - if (!sender.isDestroyed()) { - sender.send(THEIA_ELECTRON_IPC_CHANNEL_NAME, content); + getWriteBuffer(): WriteBuffer { + const writer = new ArrayBufferWriteBuffer(); + + writer.onCommit(buffer => { + if (!this.sender.isDestroyed()) { + this.sender.send(THEIA_ELECTRON_IPC_CHANNEL_NAME, new Uint8Array(buffer)); } }); - } + return writer; + } + close(): void { + } } diff --git a/packages/core/src/electron-main/messaging/electron-messaging-service.ts b/packages/core/src/electron-main/messaging/electron-messaging-service.ts index dde3fdde1d181..874d51237b4fd 100644 --- a/packages/core/src/electron-main/messaging/electron-messaging-service.ts +++ b/packages/core/src/electron-main/messaging/electron-messaging-service.ts @@ -14,20 +14,14 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import type { MessageConnection } from 'vscode-jsonrpc'; -import type { WebSocketChannel } from '../../common/messaging/web-socket-channel'; +import { Channel } from '../../common/message-rpc/channel'; export interface ElectronMessagingService { - /** - * Accept a JSON-RPC connection on the given path. - * A path supports the route syntax: https://github.com/rcs/route-parser#what-can-i-use-in-my-routes. - */ - listen(path: string, callback: (params: ElectronMessagingService.PathParams, connection: MessageConnection) => void): void; /** * Accept an ipc channel on the given path. * A path supports the route syntax: https://github.com/rcs/route-parser#what-can-i-use-in-my-routes. */ - ipcChannel(path: string, callback: (params: ElectronMessagingService.PathParams, socket: WebSocketChannel) => void): void; + ipcChannel(path: string, callback: (params: ElectronMessagingService.PathParams, socket: Channel) => void): void; } export namespace ElectronMessagingService { export interface PathParams { diff --git a/packages/core/src/node/messaging/ipc-bootstrap.ts b/packages/core/src/node/messaging/ipc-bootstrap.ts index 0bac13bb163b8..df7139fda4d17 100644 --- a/packages/core/src/node/messaging/ipc-bootstrap.ts +++ b/packages/core/src/node/messaging/ipc-bootstrap.ts @@ -14,22 +14,47 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** +import { Socket } from 'net'; import 'reflect-metadata'; +import { Emitter } from '../../common'; +import { ArrayBufferReadBuffer, ArrayBufferWriteBuffer } from '../../common/message-rpc/array-buffer-message-buffer'; +import { Channel, ChannelCloseEvent, MessageProvider } from '../../common/message-rpc/channel'; import { dynamicRequire } from '../dynamic-require'; -import { ConsoleLogger } from 'vscode-ws-jsonrpc/lib/logger'; -import { createMessageConnection, IPCMessageReader, IPCMessageWriter, Trace } from 'vscode-ws-jsonrpc'; import { checkParentAlive, IPCEntryPoint } from './ipc-protocol'; checkParentAlive(); const entryPoint = IPCEntryPoint.getScriptFromEnv(); -const reader = new IPCMessageReader(process); -const writer = new IPCMessageWriter(process); -const logger = new ConsoleLogger(); -const connection = createMessageConnection(reader, writer, logger); -connection.trace(Trace.Off, { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - log: (message: any, data?: string) => console.log(message, data) -}); - -dynamicRequire<{ default: IPCEntryPoint }>(entryPoint).default(connection); + +dynamicRequire<{ default: IPCEntryPoint }>(entryPoint).default(createChannel()); + +function createChannel(): Channel { + const pipe = new Socket({ + fd: 4 + }); + + const onCloseEmitter = new Emitter(); + const onMessageEmitter = new Emitter(); + const onErrorEmitter = new Emitter(); + const eventEmitter: NodeJS.EventEmitter = process; + eventEmitter.on('error', error => onErrorEmitter.fire(error)); + eventEmitter.on('close', () => onCloseEmitter.fire({ reason: 'Process has been closed from remote site (parent)' })); + pipe.on('data', (data: Uint8Array) => { + onMessageEmitter.fire(() => new ArrayBufferReadBuffer(data.buffer)); + }); + + return { + close: () => process.exit(), + onClose: onCloseEmitter.event, + onError: onErrorEmitter.event, + onMessage: onMessageEmitter.event, + getWriteBuffer: () => { + const result = new ArrayBufferWriteBuffer(); + result.onCommit(buffer => { + pipe.write(new Uint8Array(buffer)); + }); + + return result; + } + }; +} diff --git a/packages/core/src/node/messaging/ipc-connection-provider.ts b/packages/core/src/node/messaging/ipc-connection-provider.ts index 84c256997257a..6fc31a193d033 100644 --- a/packages/core/src/node/messaging/ipc-connection-provider.ts +++ b/packages/core/src/node/messaging/ipc-connection-provider.ts @@ -15,10 +15,13 @@ // ***************************************************************************** import * as cp from 'child_process'; +import { inject, injectable } from 'inversify'; import * as path from 'path'; -import { injectable, inject } from 'inversify'; -import { Trace, Tracer, IPCMessageReader, IPCMessageWriter, createMessageConnection, MessageConnection, Message } from 'vscode-ws-jsonrpc'; -import { ILogger, ConnectionErrorHandler, DisposableCollection, Disposable } from '../../common'; +import { Writable } from 'stream'; +import { Message } from 'vscode-ws-jsonrpc'; +import { ConnectionErrorHandler, Disposable, DisposableCollection, Emitter, ILogger } from '../../common'; +import { ArrayBufferReadBuffer, ArrayBufferWriteBuffer } from '../../common/message-rpc/array-buffer-message-buffer'; +import { Channel, ChannelCloseEvent, MessageProvider } from '../../common/message-rpc/channel'; import { createIpcEnv } from './ipc-protocol'; export interface ResolvedIPCConnectionOptions { @@ -40,7 +43,7 @@ export class IPCConnectionProvider { @inject(ILogger) protected readonly logger: ILogger; - listen(options: IPCConnectionOptions, acceptor: (connection: MessageConnection) => void): Disposable { + listen(options: IPCConnectionOptions, acceptor: (connection: Channel) => void): Disposable { return this.doListen({ logger: this.logger, args: [], @@ -48,7 +51,7 @@ export class IPCConnectionProvider { }, acceptor); } - protected doListen(options: ResolvedIPCConnectionOptions, acceptor: (connection: MessageConnection) => void): Disposable { + protected doListen(options: ResolvedIPCConnectionOptions, acceptor: (connection: Channel) => void): Disposable { const childProcess = this.fork(options); const connection = this.createConnection(childProcess, options); const toStop = new DisposableCollection(); @@ -74,32 +77,41 @@ export class IPCConnectionProvider { return toStop; } - protected createConnection(childProcess: cp.ChildProcess, options: ResolvedIPCConnectionOptions): MessageConnection { - const reader = new IPCMessageReader(childProcess); - const writer = new IPCMessageWriter(childProcess); - const connection = createMessageConnection(reader, writer, { - error: (message: string) => this.logger.error(`[${options.serverName}: ${childProcess.pid}] ${message}`), - warn: (message: string) => this.logger.warn(`[${options.serverName}: ${childProcess.pid}] ${message}`), - info: (message: string) => this.logger.info(`[${options.serverName}: ${childProcess.pid}] ${message}`), - log: (message: string) => this.logger.info(`[${options.serverName}: ${childProcess.pid}] ${message}`) + protected createConnection(childProcess: cp.ChildProcess, options?: ResolvedIPCConnectionOptions): Channel { + + const onCloseEmitter = new Emitter(); + const onMessageEmitter = new Emitter(); + const onErrorEmitter = new Emitter(); + const pipe = childProcess.stdio[4] as Writable; + + pipe.on('data', (data: Uint8Array) => { + onMessageEmitter.fire(() => new ArrayBufferReadBuffer(data.buffer)); }); - const tracer: Tracer = { - log: (message: unknown, data?: string) => this.logger.debug(`[${options.serverName}: ${childProcess.pid}] ${message}` + (typeof data === 'string' ? ' ' + data : '')) - }; - connection.trace(Trace.Verbose, tracer); - this.logger.isDebug().then(isDebug => { - if (!isDebug) { - connection.trace(Trace.Off, tracer); + + childProcess.on('error', err => onErrorEmitter.fire(err)); + childProcess.on('exit', code => onCloseEmitter.fire({ reason: 'Child process been terminated', code: code ?? undefined })); + + return { + close: () => { }, + onClose: onCloseEmitter.event, + onError: onErrorEmitter.event, + onMessage: onMessageEmitter.event, + getWriteBuffer: () => { + const result = new ArrayBufferWriteBuffer(); + result.onCommit(buffer => { + pipe.write(new Uint8Array(buffer)); + }); + + return result; } - }); - return connection; + }; } protected fork(options: ResolvedIPCConnectionOptions): cp.ChildProcess { const forkOptions: cp.ForkOptions = { - silent: true, env: createIpcEnv(options), - execArgv: [] + execArgv: [], + stdio: ['pipe', 'pipe', 'pipe', 'ipc', 'pipe'] }; const inspectArgPrefix = `--${options.serverName}-inspect`; const inspectArg = process.argv.find(v => v.startsWith(inspectArgPrefix)); @@ -108,7 +120,9 @@ export class IPCConnectionProvider { } const childProcess = cp.fork(path.join(__dirname, 'ipc-bootstrap'), options.args, forkOptions); - childProcess.stdout!.on('data', data => this.logger.info(`[${options.serverName}: ${childProcess.pid}] ${data.toString().trim()}`)); + childProcess.stdout!.on('data', data => { + this.logger.info(`[${options.serverName}: ${childProcess.pid}] ${data.toString().trim()}`); + }); childProcess.stderr!.on('data', data => this.logger.error(`[${options.serverName}: ${childProcess.pid}] ${data.toString().trim()}`)); this.logger.debug(`[${options.serverName}: ${childProcess.pid}] IPC started`); diff --git a/packages/core/src/node/messaging/ipc-protocol.ts b/packages/core/src/node/messaging/ipc-protocol.ts index de9a77394b03e..03aa3944521c3 100644 --- a/packages/core/src/node/messaging/ipc-protocol.ts +++ b/packages/core/src/node/messaging/ipc-protocol.ts @@ -15,14 +15,14 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { MessageConnection } from 'vscode-ws-jsonrpc'; +import { Channel } from '../../common/message-rpc/channel'; const THEIA_PARENT_PID = 'THEIA_PARENT_PID'; const THEIA_ENTRY_POINT = 'THEIA_ENTRY_POINT'; export const ipcEntryPoint: string | undefined = process.env[THEIA_ENTRY_POINT]; -export type IPCEntryPoint = (connection: MessageConnection) => void; +export type IPCEntryPoint = (connection: Channel) => void; export namespace IPCEntryPoint { /** * Throws if `THEIA_ENTRY_POINT` is undefined or empty. diff --git a/packages/core/src/node/messaging/messaging-contribution.ts b/packages/core/src/node/messaging/messaging-contribution.ts index 2ee8764854780..f5d611307dae3 100644 --- a/packages/core/src/node/messaging/messaging-contribution.ts +++ b/packages/core/src/node/messaging/messaging-contribution.ts @@ -16,21 +16,18 @@ import * as http from 'http'; import * as https from 'https'; +import { Container, inject, injectable, interfaces, named, postConstruct } from 'inversify'; import { Server, Socket } from 'socket.io'; -import { injectable, inject, named, postConstruct, interfaces, Container } from 'inversify'; -import { MessageConnection } from 'vscode-ws-jsonrpc'; -import { createWebSocketConnection } from 'vscode-ws-jsonrpc/lib/socket/connection'; -import { IConnection } from 'vscode-ws-jsonrpc/lib/server/connection'; -import * as launch from 'vscode-ws-jsonrpc/lib/server/launch'; import { ContributionProvider, ConnectionHandler, bindContributionProvider } from '../../common'; -import { WebSocketChannel } from '../../common/messaging/web-socket-channel'; +import { IWebSocket, WebSocketChannel } from '../../common/messaging/web-socket-channel'; import { BackendApplicationContribution } from '../backend-application'; -import { MessagingService, WebSocketChannelConnection } from './messaging-service'; -import { ConsoleLogger } from './logger'; +import { MessagingService } from './messaging-service'; import { ConnectionContainerModule } from './connection-container-module'; import Route = require('route-parser'); import { WsRequestValidator } from '../ws-request-validators'; import { MessagingListener } from './messaging-listeners'; +import { toArrayBuffer } from '../../common/message-rpc/array-buffer-message-buffer'; +import { Channel, ChannelMultiplexer } from '../../common/message-rpc'; export const MessagingContainer = Symbol('MessagingContainer'); @@ -53,7 +50,7 @@ export class MessagingContribution implements BackendApplicationContribution, Me protected readonly messagingListener: MessagingListener; protected readonly wsHandlers = new MessagingContribution.ConnectionHandlers(); - protected readonly channelHandlers = new MessagingContribution.ConnectionHandlers(); + protected readonly channelHandlers = new MessagingContribution.ConnectionHandlers(); @postConstruct() protected init(): void { @@ -63,21 +60,7 @@ export class MessagingContribution implements BackendApplicationContribution, Me } } - listen(spec: string, callback: (params: MessagingService.PathParams, connection: MessageConnection) => void): void { - this.wsChannel(spec, (params, channel) => { - const connection = createWebSocketConnection(channel, new ConsoleLogger()); - callback(params, connection); - }); - } - - forward(spec: string, callback: (params: MessagingService.PathParams, connection: IConnection) => void): void { - this.wsChannel(spec, (params, channel) => { - const connection = launch.createWebSocketConnection(channel); - callback(params, WebSocketChannelConnection.create(connection, channel)); - }); - } - - wsChannel(spec: string, callback: (params: MessagingService.PathParams, channel: WebSocketChannel) => void): void { + wsChannel(spec: string, callback: (params: MessagingService.PathParams, channel: Channel) => void): void { this.channelHandlers.push(spec, (params, channel) => callback(params, channel)); } @@ -125,49 +108,15 @@ export class MessagingContribution implements BackendApplicationContribution, Me } protected handleChannels(socket: Socket): void { + const socketChannel = new WebSocketChannel(toIWebSocket(socket)); + const mulitplexer = new ChannelMultiplexer(socketChannel); const channelHandlers = this.getConnectionChannelHandlers(socket); - const channels = new Map(); - socket.on('message', data => { - try { - const message: WebSocketChannel.Message = JSON.parse(data.toString()); - if (message.kind === 'open') { - const { id, path } = message; - const channel = this.createChannel(id, socket); - if (channelHandlers.route(path, channel)) { - channel.ready(); - console.debug(`Opening channel for service path '${path}'. [ID: ${id}]`); - channels.set(id, channel); - channel.onClose(() => { - console.debug(`Closing channel on service path '${path}'. [ID: ${id}]`); - channels.delete(id); - }); - } else { - console.error('Cannot find a service for the path: ' + path); - } - } else { - const { id } = message; - const channel = channels.get(id); - if (channel) { - channel.handleMessage(message); - } else { - console.error('The ws channel does not exist', id); - } - } - } catch (error) { - console.error('Failed to handle message', { error, data }); + mulitplexer.onDidOpenChannel(event => { + if (channelHandlers.route(event.id, event.channel)) { + console.debug(`Opening channel for service path '${event.id}'.`); + event.channel.onClose(() => console.debug(`Closing channel on service path '${event.id}'.`)); } }); - socket.on('error', err => { - for (const channel of channels.values()) { - channel.fireError(err); - } - }); - socket.on('disconnect', reason => { - for (const channel of channels.values()) { - channel.close(undefined, reason); - } - channels.clear(); - }); } protected createSocketContainer(socket: Socket): Container { @@ -176,7 +125,7 @@ export class MessagingContribution implements BackendApplicationContribution, Me return connectionContainer; } - protected getConnectionChannelHandlers(socket: Socket): MessagingContribution.ConnectionHandlers { + protected getConnectionChannelHandlers(socket: Socket): MessagingContribution.ConnectionHandlers { const connectionContainer = this.createSocketContainer(socket); bindContributionProvider(connectionContainer, ConnectionHandler); connectionContainer.load(...this.connectionModules.getContributions()); @@ -184,21 +133,25 @@ export class MessagingContribution implements BackendApplicationContribution, Me const connectionHandlers = connectionContainer.getNamed>(ContributionProvider, ConnectionHandler); for (const connectionHandler of connectionHandlers.getContributions(true)) { connectionChannelHandlers.push(connectionHandler.path, (_, channel) => { - const connection = createWebSocketConnection(channel, new ConsoleLogger()); - connectionHandler.onConnection(connection); + connectionHandler.onConnection(channel); }); } return connectionChannelHandlers; } - protected createChannel(id: number, socket: Socket): WebSocketChannel { - return new WebSocketChannel(id, content => { - if (socket.connected) { - socket.send(content); - } - }); - } +} +function toIWebSocket(socket: Socket): IWebSocket { + return { + close: () => { + socket.disconnect(); + }, + isConnected: () => socket.connected, + onClose: cb => socket.on('disconnect', reason => cb(reason)), + onError: cb => socket.on('error', error => cb(error)), + onMessage: cb => socket.on('message', data => cb(toArrayBuffer(data))), + send: message => socket.emit('message', message) + }; } export namespace MessagingContribution { diff --git a/packages/core/src/node/messaging/messaging-service.ts b/packages/core/src/node/messaging/messaging-service.ts index 087f6d5850def..276b58734bcff 100644 --- a/packages/core/src/node/messaging/messaging-service.ts +++ b/packages/core/src/node/messaging/messaging-service.ts @@ -15,33 +15,21 @@ // ***************************************************************************** import { Socket } from 'socket.io'; -import { MessageConnection } from 'vscode-ws-jsonrpc'; -import { IConnection } from 'vscode-ws-jsonrpc/lib/server/connection'; -import { WebSocketChannel } from '../../common/messaging/web-socket-channel'; +import { Channel } from '../../common/message-rpc/channel'; export interface MessagingService { - /** - * Accept a JSON-RPC connection on the given path. - * A path supports the route syntax: https://github.com/rcs/route-parser#what-can-i-use-in-my-routes. - */ - listen(path: string, callback: (params: MessagingService.PathParams, connection: MessageConnection) => void): void; - /** - * Accept a raw JSON-RPC connection on the given path. - * A path supports the route syntax: https://github.com/rcs/route-parser#what-can-i-use-in-my-routes. - */ - forward(path: string, callback: (params: MessagingService.PathParams, connection: IConnection) => void): void; /** * Accept a web socket channel on the given path. * A path supports the route syntax: https://github.com/rcs/route-parser#what-can-i-use-in-my-routes. */ - wsChannel(path: string, callback: (params: MessagingService.PathParams, socket: WebSocketChannel) => void): void; + wsChannel(path: string, callback: (params: MessagingService.PathParams, channel: Channel) => void): void; /** * Accept a web socket connection on the given path. * A path supports the route syntax: https://github.com/rcs/route-parser#what-can-i-use-in-my-routes. * * #### Important - * Prefer JSON-RPC connections or web socket channels over web sockets. Clients can handle only limited amount of web sockets - * and excessive amount can cause performance degradation. All JSON-RPC connections and web socket channels share the single web socket connection. + * Prefer using web socket channels over establishing new web socket connection. Clients can handle only limited amount of web sockets + * and excessive amount can cause performance degradation. All web socket channels share a single web socket connection. */ ws(path: string, callback: (params: MessagingService.PathParams, socket: Socket) => void): void; } @@ -56,18 +44,3 @@ export namespace MessagingService { configure(service: MessagingService): void; } } - -export interface WebSocketChannelConnection extends IConnection { - channel: WebSocketChannel; -} -export namespace WebSocketChannelConnection { - export function is(connection: IConnection): connection is WebSocketChannelConnection { - return (connection as WebSocketChannelConnection).channel instanceof WebSocketChannel; - } - - export function create(connection: IConnection, channel: WebSocketChannel): WebSocketChannelConnection { - const result = connection as WebSocketChannelConnection; - result.channel = channel; - return result; - } -} diff --git a/packages/core/src/node/messaging/test/test-web-socket-channel.ts b/packages/core/src/node/messaging/test/test-web-socket-channel.ts index 2fbb17c9aa8ec..89f7071e8533f 100644 --- a/packages/core/src/node/messaging/test/test-web-socket-channel.ts +++ b/packages/core/src/node/messaging/test/test-web-socket-channel.ts @@ -16,32 +16,41 @@ import * as http from 'http'; import * as https from 'https'; -import { WebSocketChannel } from '../../../common/messaging/web-socket-channel'; -import { Disposable } from '../../../common/disposable'; import { AddressInfo } from 'net'; -import { io } from 'socket.io-client'; - -export class TestWebSocketChannel extends WebSocketChannel { +import { io, Socket } from 'socket.io-client'; +import { Channel, ChannelMultiplexer } from '../../../common'; +import { toArrayBuffer } from '../../../common/message-rpc/array-buffer-message-buffer'; +import { IWebSocket, WebSocketChannel } from '../../../common/messaging/web-socket-channel'; +export class TestWebSocketChannelSetup { + public readonly multiPlexer: ChannelMultiplexer; + public readonly channel: Channel; constructor({ server, path }: { server: http.Server | https.Server, path: string }) { - super(0, content => socket.send(content)); const socket = io(`ws://localhost:${(server.address() as AddressInfo).port}${WebSocketChannel.wsPath}`); - socket.on('error', error => - this.fireError(error) - ); - socket.on('disconnect', reason => - this.fireClose(0, reason) - ); - socket.on('message', data => { - this.handleMessage(JSON.parse(data.toString())); + this.channel = new WebSocketChannel(toIWebSocket(socket)); + this.multiPlexer = new ChannelMultiplexer(this.channel); + socket.on('connect', () => { + this.multiPlexer.open(path); }); - socket.on('connect', () => - this.open(path) - ); - this.toDispose.push(Disposable.create(() => socket.close())); + socket.connect(); } +} +function toIWebSocket(socket: Socket): IWebSocket { + return { + close: () => { + socket.removeAllListeners('disconnect'); + socket.removeAllListeners('error'); + socket.removeAllListeners('message'); + socket.close(); + }, + isConnected: () => socket.connected, + onClose: cb => socket.on('disconnect', reason => cb(reason)), + onError: cb => socket.on('error', reason => cb(reason)), + onMessage: cb => socket.on('message', data => cb(toArrayBuffer(data))), + send: message => socket.emit('message', message) + }; } diff --git a/packages/debug/src/browser/debug-session-connection.ts b/packages/debug/src/browser/debug-session-connection.ts index 4ef9db7818a74..2168697562231 100644 --- a/packages/debug/src/browser/debug-session-connection.ts +++ b/packages/debug/src/browser/debug-session-connection.ts @@ -16,13 +16,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { DebugProtocol } from 'vscode-debugprotocol'; import { Deferred } from '@theia/core/lib/common/promise-util'; -import { Event, Emitter, DisposableCollection, Disposable, MaybePromise } from '@theia/core'; +import { Event, Emitter, DisposableCollection, Disposable, MaybePromise, Channel } from '@theia/core'; import { OutputChannel } from '@theia/output/lib/browser/output-channel'; - -import { Channel } from '../common/debug-service'; - +import { DebugProtocol } from 'vscode-debugprotocol'; export type DebugRequestHandler = (request: DebugProtocol.Request) => MaybePromise; export interface DebugRequestTypes { @@ -116,6 +113,7 @@ const standardDebugEvents = new Set([ 'thread' ]); +// TODO: Proper message RPC for debug session protocol export class DebugSessionConnection implements Disposable { private sequence = 1; @@ -168,7 +166,7 @@ export class DebugSessionConnection implements Disposable { this.cancelPendingRequests(); this.onDidCloseEmitter.fire(); }); - connection.onMessage(data => this.handleMessage(data)); + connection.onMessage(data => this.handleMessage(data().readString())); return connection; } @@ -247,7 +245,7 @@ export class DebugSessionConnection implements Disposable { const dateStr = `${now.toLocaleString(undefined, { hour12: false })}.${now.getMilliseconds()}`; this.traceOutputChannel.appendLine(`${this.sessionId.substring(0, 8)} ${dateStr} theia -> adapter: ${JSON.stringify(message, undefined, 4)}`); } - connection.send(messageStr); + connection.getWriteBuffer().writeString(messageStr).commit(); } protected handleMessage(data: string): void { diff --git a/packages/debug/src/browser/debug-session-contribution.ts b/packages/debug/src/browser/debug-session-contribution.ts index 3bcee60f38d9a..2550c1c747673 100644 --- a/packages/debug/src/browser/debug-session-contribution.ts +++ b/packages/debug/src/browser/debug-session-contribution.ts @@ -26,10 +26,11 @@ import { DebugSessionOptions } from './debug-session-options'; import { OutputChannelManager, OutputChannel } from '@theia/output/lib/browser/output-channel'; import { DebugPreferences } from './debug-preferences'; import { DebugSessionConnection } from './debug-session-connection'; -import { Channel, DebugAdapterPath } from '../common/debug-service'; +import { DebugAdapterPath } from '../common/debug-service'; import { ContributionProvider } from '@theia/core/lib/common/contribution-provider'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { DebugContribution } from './debug-contribution'; +import { Channel } from '@theia/core/lib/common/message-rpc/channel'; /** * DebugSessionContribution symbol for DI. diff --git a/packages/debug/src/browser/debug-session.tsx b/packages/debug/src/browser/debug-session.tsx index 88b3a40435769..9245d06cc4785 100644 --- a/packages/debug/src/browser/debug-session.tsx +++ b/packages/debug/src/browser/debug-session.tsx @@ -16,31 +16,31 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import * as React from '@theia/core/shared/react'; import { LabelProvider } from '@theia/core/lib/browser'; -import { DebugProtocol } from 'vscode-debugprotocol'; -import { Emitter, Event, DisposableCollection, Disposable, MessageClient, MessageType, Mutable, ContributionProvider } from '@theia/core/lib/common'; -import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; -import { EditorManager } from '@theia/editor/lib/browser'; import { CompositeTreeElement } from '@theia/core/lib/browser/source-tree'; -import { DebugSessionConnection, DebugRequestTypes, DebugEventTypes } from './debug-session-connection'; -import { DebugThread, StoppedDetails, DebugThreadData } from './model/debug-thread'; -import { DebugScope } from './console/debug-console-items'; -import { DebugStackFrame } from './model/debug-stack-frame'; -import { DebugSource } from './model/debug-source'; -import { DebugBreakpoint, DebugBreakpointOptions } from './model/debug-breakpoint'; -import { DebugSourceBreakpoint } from './model/debug-source-breakpoint'; -import debounce = require('p-debounce'); +import { ContributionProvider, Disposable, DisposableCollection, Emitter, Event, MessageClient, MessageType, Mutable } from '@theia/core/lib/common'; +import { waitForEvent } from '@theia/core/lib/common/promise-util'; import URI from '@theia/core/lib/common/uri'; +import * as React from '@theia/core/shared/react'; +import { EditorManager } from '@theia/editor/lib/browser'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; +import { TerminalWidget, TerminalWidgetOptions } from '@theia/terminal/lib/browser/base/terminal-widget'; +import { DebugProtocol } from 'vscode-debugprotocol'; +import { DebugConfiguration, DebugConsoleMode } from '../common/debug-common'; import { BreakpointManager } from './breakpoint/breakpoint-manager'; +import { ExceptionBreakpoint, SourceBreakpoint } from './breakpoint/breakpoint-marker'; +import { DebugScope } from './console/debug-console-items'; +import { DebugContribution } from './debug-contribution'; +import { DebugEventTypes, DebugRequestTypes, DebugSessionConnection } from './debug-session-connection'; import { DebugSessionOptions, InternalDebugSessionOptions } from './debug-session-options'; -import { DebugConfiguration, DebugConsoleMode } from '../common/debug-common'; -import { SourceBreakpoint, ExceptionBreakpoint } from './breakpoint/breakpoint-marker'; -import { TerminalWidgetOptions, TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget'; +import { DebugBreakpoint, DebugBreakpointOptions } from './model/debug-breakpoint'; import { DebugFunctionBreakpoint } from './model/debug-function-breakpoint'; -import { FileService } from '@theia/filesystem/lib/browser/file-service'; -import { DebugContribution } from './debug-contribution'; -import { waitForEvent } from '@theia/core/lib/common/promise-util'; +import { DebugSource } from './model/debug-source'; +import { DebugSourceBreakpoint } from './model/debug-source-breakpoint'; +import { DebugStackFrame } from './model/debug-stack-frame'; +import { DebugThread, DebugThreadData, StoppedDetails } from './model/debug-thread'; +import debounce = require('p-debounce'); export enum DebugState { Inactive, diff --git a/packages/debug/src/node/debug-adapter-session.ts b/packages/debug/src/node/debug-adapter-session.ts index 03ff950d38a90..e1dabd57d98a7 100644 --- a/packages/debug/src/node/debug-adapter-session.ts +++ b/packages/debug/src/node/debug-adapter-session.ts @@ -26,7 +26,7 @@ import { DebugAdapterSession } from './debug-model'; import { DebugProtocol } from 'vscode-debugprotocol'; -import { Channel } from '../common/debug-service'; +import { Channel } from '@theia/core/lib/common/message-rpc/channel'; /** * [DebugAdapterSession](#DebugAdapterSession) implementation. @@ -53,7 +53,7 @@ export class DebugAdapterSessionImpl implements DebugAdapterSession { throw new Error('The session has already been started, id: ' + this.id); } this.channel = channel; - this.channel.onMessage((message: string) => this.write(message)); + this.channel.onMessage(message => this.write(message().readString())); this.channel.onClose(() => this.channel = undefined); } @@ -80,7 +80,7 @@ export class DebugAdapterSessionImpl implements DebugAdapterSession { protected send(message: string): void { if (this.channel) { - this.channel.send(message); + this.channel.getWriteBuffer().writeString(message); } } diff --git a/packages/debug/src/node/debug-model.ts b/packages/debug/src/node/debug-model.ts index a39352fabbddf..dd73d1d1a6880 100644 --- a/packages/debug/src/node/debug-model.ts +++ b/packages/debug/src/node/debug-model.ts @@ -26,7 +26,7 @@ import { DebugConfiguration } from '../common/debug-configuration'; import { IJSONSchema, IJSONSchemaSnippet } from '@theia/core/lib/common/json-schema'; import { MaybePromise } from '@theia/core/lib/common/types'; import { Event } from '@theia/core/lib/common/event'; -import { Channel } from '../common/debug-service'; +import { Channel } from '@theia/core/lib/common/message-rpc/channel'; // FIXME: break down this file to debug adapter and debug adapter contribution (see Theia file naming conventions) diff --git a/packages/filesystem/src/common/files.ts b/packages/filesystem/src/common/files.ts index 2ea5fff79f518..d66ff21e4dfdb 100644 --- a/packages/filesystem/src/common/files.ts +++ b/packages/filesystem/src/common/files.ts @@ -837,7 +837,7 @@ export function hasOpenReadWriteCloseCapability(provider: FileSystemProvider): p */ export interface FileSystemProviderWithFileReadStreamCapability extends FileSystemProvider { /** - * Read the contents of the given file as stream. + * Read the contents of the given file as stream. * @param resource The `URI` of the file. * * @return The `ReadableStreamEvents` for the readable stream of the given file. diff --git a/packages/filesystem/src/common/remote-file-system-provider.ts b/packages/filesystem/src/common/remote-file-system-provider.ts index 5edb5dbbad9e7..f67e198db75f7 100644 --- a/packages/filesystem/src/common/remote-file-system-provider.ts +++ b/packages/filesystem/src/common/remote-file-system-provider.ts @@ -42,11 +42,11 @@ export interface RemoteFileSystemServer extends JsonRpcServer; open(resource: string, opts: FileOpenOptions): Promise; close(fd: number): Promise; - read(fd: number, pos: number, length: number): Promise<{ bytes: number[]; bytesRead: number; }>; + read(fd: number, pos: number, length: number): Promise<{ bytes: Uint8Array; bytesRead: number; }>; readFileStream(resource: string, opts: FileReadStreamOptions, token: CancellationToken): Promise; - readFile(resource: string): Promise; - write(fd: number, pos: number, data: number[], offset: number, length: number): Promise; - writeFile(resource: string, content: number[], opts: FileWriteOptions): Promise; + readFile(resource: string): Promise; + write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise; + writeFile(resource: string, content: Uint8Array, opts: FileWriteOptions): Promise; delete(resource: string, opts: FileDeleteOptions): Promise; mkdir(resource: string): Promise; readdir(resource: string): Promise<[string, FileType][]>; @@ -70,7 +70,7 @@ export interface RemoteFileSystemClient { notifyDidChangeFile(event: { changes: RemoteFileChange[] }): void; notifyFileWatchError(): void; notifyDidChangeCapabilities(capabilities: FileSystemProviderCapabilities): void; - onFileStreamData(handle: number, data: number[]): void; + onFileStreamData(handle: number, data: Uint8Array): void; onFileStreamEnd(handle: number, error: RemoteFileStreamError | undefined): void; } @@ -169,7 +169,7 @@ export class RemoteFileSystemProvider implements Required, D this.onFileWatchErrorEmitter.fire(); }, notifyDidChangeCapabilities: capabilities => this.setCapabilities(capabilities), - onFileStreamData: (handle, data) => this.onFileStreamDataEmitter.fire([handle, Uint8Array.from(data)]), + onFileStreamData: (handle, data) => this.onFileStreamDataEmitter.fire([handle, data]), onFileStreamEnd: (handle, error) => this.onFileStreamEndEmitter.fire([handle, error]) }); const onInitialized = this.server.onDidOpenConnection(() => { @@ -224,7 +224,7 @@ export class RemoteFileSystemProvider implements Required, D async readFile(resource: URI): Promise { const bytes = await this.server.readFile(resource.toString()); - return Uint8Array.from(bytes); + return bytes; } readFileStream(resource: URI, opts: FileReadStreamOptions, token: CancellationToken): ReadableStreamEvents { @@ -264,11 +264,11 @@ export class RemoteFileSystemProvider implements Required, D } write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise { - return this.server.write(fd, pos, [...data.values()], offset, length); + return this.server.write(fd, pos, data, offset, length); } writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise { - return this.server.writeFile(resource.toString(), [...content.values()], opts); + return this.server.writeFile(resource.toString(), content, opts); } delete(resource: URI, opts: FileDeleteOptions): Promise { @@ -412,34 +412,33 @@ export class FileSystemProviderServer implements RemoteFileSystemServer { throw new Error('not supported'); } - async read(fd: number, pos: number, length: number): Promise<{ bytes: number[]; bytesRead: number; }> { + async read(fd: number, pos: number, length: number): Promise<{ bytes: Uint8Array; bytesRead: number; }> { if (hasOpenReadWriteCloseCapability(this.provider)) { const buffer = BinaryBuffer.alloc(this.BUFFER_SIZE); const bytes = buffer.buffer; const bytesRead = await this.provider.read(fd, pos, bytes, 0, length); - return { bytes: [...bytes.values()], bytesRead }; + return { bytes, bytesRead }; } throw new Error('not supported'); } - write(fd: number, pos: number, data: number[], offset: number, length: number): Promise { + write(fd: number, pos: number, data: Uint8Array, offset: number, length: number): Promise { if (hasOpenReadWriteCloseCapability(this.provider)) { - return this.provider.write(fd, pos, Uint8Array.from(data), offset, length); + return this.provider.write(fd, pos, data, offset, length); } throw new Error('not supported'); } - async readFile(resource: string): Promise { + async readFile(resource: string): Promise { if (hasReadWriteCapability(this.provider)) { - const buffer = await this.provider.readFile(new URI(resource)); - return [...buffer.values()]; + return this.provider.readFile(new URI(resource)); } throw new Error('not supported'); } - writeFile(resource: string, content: number[], opts: FileWriteOptions): Promise { + writeFile(resource: string, content: Uint8Array, opts: FileWriteOptions): Promise { if (hasReadWriteCapability(this.provider)) { - return this.provider.writeFile(new URI(resource), Uint8Array.from(content), opts); + return this.provider.writeFile(new URI(resource), content, opts); } throw new Error('not supported'); } @@ -497,7 +496,7 @@ export class FileSystemProviderServer implements RemoteFileSystemServer { if (hasFileReadStreamCapability(this.provider)) { const handle = this.readFileStreamSeq++; const stream = this.provider.readFileStream(new URI(resource), opts, token); - stream.on('data', data => this.client?.onFileStreamData(handle, [...data.values()])); + stream.on('data', data => this.client?.onFileStreamData(handle, data)); stream.on('error', error => { const code = error instanceof FileSystemProviderError ? error.code : undefined; const { name, message, stack } = error; diff --git a/packages/plugin-ext/src/common/connection.ts b/packages/plugin-ext/src/common/connection.ts index 48ae3adb36363..dfc19d630b47a 100644 --- a/packages/plugin-ext/src/common/connection.ts +++ b/packages/plugin-ext/src/common/connection.ts @@ -13,27 +13,38 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { Channel } from '@theia/debug/lib/common/debug-service'; import { ConnectionExt, ConnectionMain } from './plugin-api-rpc'; -import { Emitter } from '@theia/core/lib/common/event'; +import { Emitter, Event } from '@theia/core/lib/common/event'; +import { ChannelCloseEvent, MessageProvider } from '@theia/core/lib/common/message-rpc/channel'; +import { WriteBuffer, Channel } from '@theia/core'; +import { ArrayBufferReadBuffer, ArrayBufferWriteBuffer } from '@theia/core/lib/common/message-rpc/array-buffer-message-buffer'; /** * A channel communicating with a counterpart in a plugin host. */ export class PluginChannel implements Channel { - private messageEmitter: Emitter = new Emitter(); + private messageEmitter: Emitter = new Emitter(); private errorEmitter: Emitter = new Emitter(); - private closedEmitter: Emitter = new Emitter(); + private closedEmitter: Emitter = new Emitter(); constructor( - protected readonly id: string, + readonly id: string, protected readonly connection: ConnectionExt | ConnectionMain) { } + getWriteBuffer(): WriteBuffer { + const result = new ArrayBufferWriteBuffer(); + result.onCommit(buffer => { + this.connection.$sendMessage(this.id, new ArrayBufferReadBuffer(buffer).readString()); + }); + + return result; + } + send(content: string): void { this.connection.$sendMessage(this.id, content); } - fireMessageReceived(msg: string): void { + fireMessageReceived(msg: MessageProvider): void { this.messageEmitter.fire(msg); } @@ -42,21 +53,19 @@ export class PluginChannel implements Channel { } fireClosed(): void { - this.closedEmitter.fire(); + this.closedEmitter.fire({ reason: 'Plugin channel has been closed from the extension side' }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onMessage(cb: (data: any) => void): void { - this.messageEmitter.event(cb); + get onMessage(): Event { + return this.messageEmitter.event; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onError(cb: (reason: any) => void): void { - this.errorEmitter.event(cb); + get onError(): Event { + return this.errorEmitter.event; } - onClose(cb: (code: number, reason: string) => void): void { - this.closedEmitter.event(() => cb(-1, 'closed')); + get onClose(): Event { + return this.closedEmitter.event; } close(): void { @@ -80,7 +89,10 @@ export class ConnectionImpl implements ConnectionMain, ConnectionExt { */ async $sendMessage(id: string, message: string): Promise { if (this.connections.has(id)) { - this.connections.get(id)!.fireMessageReceived(message); + const writer = new ArrayBufferWriteBuffer(); + writer.writeString(message); + const reader = new ArrayBufferReadBuffer(writer.getCurrentContents()); + this.connections.get(id)!.fireMessageReceived(() => reader); } else { console.warn(`Received message for unknown connection: ${id}`); } diff --git a/packages/plugin-ext/src/main/browser/debug/plugin-debug-session-factory.ts b/packages/plugin-ext/src/main/browser/debug/plugin-debug-session-factory.ts index cd3615827c36c..e3b1ab50ee83c 100644 --- a/packages/plugin-ext/src/main/browser/debug/plugin-debug-session-factory.ts +++ b/packages/plugin-ext/src/main/browser/debug/plugin-debug-session-factory.ts @@ -30,7 +30,7 @@ import { TerminalOptionsExt } from '../../../common/plugin-api-rpc'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { DebugContribution } from '@theia/debug/lib/browser/debug-contribution'; import { ContributionProvider } from '@theia/core/lib/common/contribution-provider'; -import { Channel } from '@theia/debug/lib/common/debug-service'; +import { Channel } from '@theia/core/lib/common/message-rpc/channel'; export class PluginDebugSession extends DebugSession { constructor( diff --git a/packages/plugin-ext/src/plugin/node/debug/plugin-debug-adapter-session.ts b/packages/plugin-ext/src/plugin/node/debug/plugin-debug-adapter-session.ts index 890f46be08036..c5c307d363b4c 100644 --- a/packages/plugin-ext/src/plugin/node/debug/plugin-debug-adapter-session.ts +++ b/packages/plugin-ext/src/plugin/node/debug/plugin-debug-adapter-session.ts @@ -17,7 +17,7 @@ import { DebugAdapterSessionImpl } from '@theia/debug/lib/node/debug-adapter-session'; import * as theia from '@theia/plugin'; import { DebugAdapter } from '@theia/debug/lib/node/debug-model'; -import { Channel } from '@theia/debug/lib/common/debug-service'; +import { Channel } from '@theia/core/lib/common/message-rpc/channel'; /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/packages/task/src/node/task-server.slow-spec.ts b/packages/task/src/node/task-server.slow-spec.ts index fbe968348d9d2..cffb3aedb8dbd 100644 --- a/packages/task/src/node/task-server.slow-spec.ts +++ b/packages/task/src/node/task-server.slow-spec.ts @@ -17,20 +17,21 @@ /* eslint-disable no-unused-expressions */ // tslint:disable-next-line:no-implicit-dependencies -import 'reflect-metadata'; -import { createTaskTestContainer } from './test/task-test-container'; +import { isOSX, isWindows } from '@theia/core/lib/common/os'; +import { expectThrowsAsync } from '@theia/core/lib/common/test/expect'; +import URI from '@theia/core/lib/common/uri'; +import { FileUri } from '@theia/core/lib/node'; import { BackendApplication } from '@theia/core/lib/node/backend-application'; -import { TaskExitedEvent, TaskInfo, TaskServer, TaskWatcher, TaskConfiguration } from '../common'; -import { ProcessType, ProcessTaskConfiguration } from '../common/process/task-protocol'; +import { expect } from 'chai'; import * as http from 'http'; import * as https from 'https'; -import { isWindows, isOSX } from '@theia/core/lib/common/os'; -import { FileUri } from '@theia/core/lib/node'; +import 'reflect-metadata'; +import { TaskConfiguration, TaskExitedEvent, TaskInfo, TaskServer, TaskWatcher } from '../common'; +import { ProcessTaskConfiguration, ProcessType } from '../common/process/task-protocol'; +import { createTaskTestContainer } from './test/task-test-container'; +import { TestWebSocketChannelSetup } from '@theia/core/lib/node/messaging/test/test-web-socket-channel'; import { terminalsPath } from '@theia/terminal/lib/common/terminal-protocol'; -import { expectThrowsAsync } from '@theia/core/lib/common/test/expect'; -import { TestWebSocketChannel } from '@theia/core/lib/node/messaging/test/test-web-socket-channel'; -import { expect } from 'chai'; -import URI from '@theia/core/lib/common/uri'; +import { RpcConnection } from '@theia/core'; // test scripts that we bundle with tasks const commandShortRunning = './task'; @@ -106,26 +107,37 @@ describe('Task server / back-end', function (): void { // hook-up to terminal's ws and confirm that it outputs expected tasks' output await new Promise((resolve, reject) => { - const channel = new TestWebSocketChannel({ server, path: `${terminalsPath}/${terminalId}` }); - channel.onError(reject); - channel.onClose((code, reason) => reject(new Error(`channel is closed with '${code}' code and '${reason}' reason`))); - channel.onMessage(msg => { - // check output of task on terminal is what we expect - const expected = `${isOSX ? 'tasking osx' : 'tasking'}... ${someString}`; - // Instead of waiting for one message from the terminal, we wait for several ones as the very first message can be something unexpected. - // For instance: `nvm is not compatible with the \"PREFIX\" environment variable: currently set to \"/usr/local\"\r\n` - const currentMessage = msg.toString(); - messages.unshift(currentMessage); - if (currentMessage.indexOf(expected) !== -1) { - resolve(); - channel.close(); - return; - } - if (messages.length >= messagesToWaitFor) { - reject(new Error(`expected sub-string not found in terminal output. Expected: "${expected}" vs Actual messages: ${JSON.stringify(messages)}`)); - channel.close(); - } + const setup = new TestWebSocketChannelSetup({ server, path: `${terminalsPath}/${terminalId}` }); + setup.multiPlexer.onDidOpenChannel(event => { + const channel = event.channel; + const connection = new RpcConnection(channel, (method, args) => { + reject(`Received unexpected request: ${method} with args: ${args} `); + return Promise.reject(); + }); + channel.onError(reject); + channel.onClose(() => reject(new Error('Channel has been closed'))); + connection.onNotification(not => { + // check output of task on terminal is what we expect + const expected = `${isOSX ? 'tasking osx' : 'tasking'}... ${someString}`; + // Instead of waiting for one message from the terminal, we wait for several ones as the very first message can be something unexpected. + // For instance: `nvm is not compatible with the \"PREFIX\" environment variable: currently set to \"/usr/local\"\r\n` + const currentMessage = not.args[0]; + messages.unshift(currentMessage); + if (currentMessage.indexOf(expected) !== -1) { + resolve(); + channel.close(); + return; + } + if (messages.length >= messagesToWaitFor) { + reject(new Error(`expected sub-string not found in terminal output. Expected: "${expected}" vs Actual messages: ${JSON.stringify(messages)}`)); + channel.close(); + } + }); + channel.onMessage(reader => { + + }); }); + }); }); diff --git a/packages/terminal/src/browser/terminal-widget-impl.ts b/packages/terminal/src/browser/terminal-widget-impl.ts index 311b9122e3659..848aad4a4d780 100644 --- a/packages/terminal/src/browser/terminal-widget-impl.ts +++ b/packages/terminal/src/browser/terminal-widget-impl.ts @@ -14,30 +14,30 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { Terminal, RendererType } from 'xterm'; -import { FitAddon } from 'xterm-addon-fit'; -import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; -import { ContributionProvider, Disposable, Event, Emitter, ILogger, DisposableCollection } from '@theia/core'; -import { Widget, Message, WebSocketConnectionProvider, StatefulWidget, isFirefox, MessageLoop, KeyCode, codicon } from '@theia/core/lib/browser'; +import { ContributionProvider, Disposable, DisposableCollection, Emitter, Event, ILogger } from '@theia/core'; +import { codicon, isFirefox, KeyCode, Message, MessageLoop, StatefulWidget, WebSocketConnectionProvider, Widget } from '@theia/core/lib/browser'; +import { Key } from '@theia/core/lib/browser/keys'; import { isOSX } from '@theia/core/lib/common'; +import { nls } from '@theia/core/lib/common/nls'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import URI from '@theia/core/lib/common/uri'; +import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; +import { RequestHandler, RpcConnection } from '@theia/core/lib/common/message-rpc/rpc-protocol'; +import { CommandLineOptions, ShellCommandBuilder } from '@theia/process/lib/common/shell-command-builder'; import { WorkspaceService } from '@theia/workspace/lib/browser'; -import { ShellTerminalServerProxy, IShellTerminalPreferences } from '../common/shell-terminal-protocol'; -import { terminalsPath } from '../common/terminal-protocol'; +import { RendererType, Terminal } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; import { IBaseTerminalServer, TerminalProcessInfo } from '../common/base-terminal-protocol'; +import { IShellTerminalPreferences, ShellTerminalServerProxy } from '../common/shell-terminal-protocol'; +import { terminalsPath } from '../common/terminal-protocol'; import { TerminalWatcher } from '../common/terminal-watcher'; -import { TerminalWidgetOptions, TerminalWidget, TerminalDimensions } from './base/terminal-widget'; -import { MessageConnection } from '@theia/core/shared/vscode-ws-jsonrpc'; -import { Deferred } from '@theia/core/lib/common/promise-util'; -import { TerminalPreferences, TerminalRendererType, isTerminalRendererType, DEFAULT_TERMINAL_RENDERER_TYPE, CursorStyle } from './terminal-preferences'; -import { TerminalContribution } from './terminal-contribution'; -import URI from '@theia/core/lib/common/uri'; import { TerminalService } from './base/terminal-service'; -import { TerminalSearchWidgetFactory, TerminalSearchWidget } from './search/terminal-search-widget'; +import { TerminalDimensions, TerminalWidget, TerminalWidgetOptions } from './base/terminal-widget'; +import { TerminalSearchWidget, TerminalSearchWidgetFactory } from './search/terminal-search-widget'; +import { TerminalContribution } from './terminal-contribution'; import { TerminalCopyOnSelectionHandler } from './terminal-copy-on-selection-handler'; +import { CursorStyle, DEFAULT_TERMINAL_RENDERER_TYPE, isTerminalRendererType, TerminalPreferences, TerminalRendererType } from './terminal-preferences'; import { TerminalThemeService } from './terminal-theme-service'; -import { CommandLineOptions, ShellCommandBuilder } from '@theia/process/lib/common/shell-command-builder'; -import { Key } from '@theia/core/lib/browser/keys'; -import { nls } from '@theia/core/lib/common/nls'; export const TERMINAL_WIDGET_FACTORY_ID = 'terminal'; @@ -58,7 +58,7 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget protected searchBox: TerminalSearchWidget; protected restored = false; protected closeOnDispose = true; - protected waitForConnection: Deferred | undefined; + protected waitForConnection: Deferred | undefined; protected hoverMessage: HTMLDivElement; protected lastTouchEnd: TouchEvent | undefined; protected isAttachedCloseListener: boolean = false; @@ -507,16 +507,23 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget } this.toDisposeOnConnect.dispose(); this.toDispose.push(this.toDisposeOnConnect); - const waitForConnection = this.waitForConnection = new Deferred(); + const waitForConnection = this.waitForConnection = new Deferred(); this.webSocketConnectionProvider.listen({ path: `${terminalsPath}/${this.terminalId}`, onConnection: connection => { - connection.onNotification('onData', (data: string) => this.write(data)); + const requestHandler: RequestHandler = _method => this.logger.warn('Received an unhandled RPC request from the terminal process'); + + const rpc = new RpcConnection(connection, requestHandler); + rpc.onNotification(event => { + if (event.method === 'onData') { + this.write(event.args[0]); + } + }); // Excludes the device status code emitted by Xterm.js const sendData = (data?: string) => { if (data && !this.deviceStatusCodes.has(data) && !this.disableEnterWhenAttachCloseListener()) { - return connection.sendRequest('write', data); + return rpc.sendRequest('write', [data]); } }; @@ -524,12 +531,10 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget disposable.push(this.term.onData(sendData)); disposable.push(this.term.onBinary(sendData)); - connection.onDispose(() => disposable.dispose()); + connection.onClose(() => disposable.dispose()); - this.toDisposeOnConnect.push(connection); - connection.listen(); if (waitForConnection) { - waitForConnection.resolve(connection); + waitForConnection.resolve(rpc); } } }, { reconnecting: false }); @@ -579,7 +584,7 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget sendText(text: string): void { if (this.waitForConnection) { this.waitForConnection.promise.then(connection => - connection.sendRequest('write', text) + connection.sendRequest('write', [text]) ); } } diff --git a/packages/terminal/src/node/terminal-backend-contribution.slow-spec.ts b/packages/terminal/src/node/terminal-backend-contribution.slow-spec.ts index 6d39ddd973f20..437f9458621b8 100644 --- a/packages/terminal/src/node/terminal-backend-contribution.slow-spec.ts +++ b/packages/terminal/src/node/terminal-backend-contribution.slow-spec.ts @@ -14,13 +14,13 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { createTerminalTestContainer } from './test/terminal-test-container'; import { BackendApplication } from '@theia/core/lib/node/backend-application'; -import { IShellTerminalServer } from '../common/shell-terminal-protocol'; import * as http from 'http'; import * as https from 'https'; +import { IShellTerminalServer } from '../common/shell-terminal-protocol'; +import { createTerminalTestContainer } from './test/terminal-test-container'; +import { TestWebSocketChannelSetup } from '@theia/core/lib/node/messaging/test/test-web-socket-channel'; import { terminalsPath } from '../common/terminal-protocol'; -import { TestWebSocketChannel } from '@theia/core/lib/node/messaging/test/test-web-socket-channel'; describe('Terminal Backend Contribution', function (): void { @@ -45,13 +45,19 @@ describe('Terminal Backend Contribution', function (): void { it('is data received from the terminal ws server', async () => { const terminalId = await shellTerminalServer.create({}); await new Promise((resolve, reject) => { - const channel = new TestWebSocketChannel({ server, path: `${terminalsPath}/${terminalId}` }); + const path = `${terminalsPath}/${terminalId}`; + const { channel, multiPlexer } = new TestWebSocketChannelSetup({ server, path }); channel.onError(reject); - channel.onClose((code, reason) => reject(new Error(`channel is closed with '${code}' code and '${reason}' reason`))); - channel.onOpen(() => { - resolve(); - channel.close(); + channel.onClose(event => reject(new Error(`channel is closed with '${event.code}' code and '${event.reason}' reason}`))); + + multiPlexer.onDidOpenChannel(event => { + if (event.id === path) { + resolve(); + channel.close(); + } }); + }); }); + }); diff --git a/packages/terminal/src/node/terminal-backend-contribution.ts b/packages/terminal/src/node/terminal-backend-contribution.ts index 4675b7a32290c..358f91dcdf472 100644 --- a/packages/terminal/src/node/terminal-backend-contribution.ts +++ b/packages/terminal/src/node/terminal-backend-contribution.ts @@ -15,10 +15,11 @@ // ***************************************************************************** import { injectable, inject, named } from '@theia/core/shared/inversify'; -import { ILogger } from '@theia/core/lib/common'; +import { ILogger, RequestHandler } from '@theia/core/lib/common'; import { TerminalProcess, ProcessManager } from '@theia/process/lib/node'; import { terminalsPath } from '../common/terminal-protocol'; import { MessagingService } from '@theia/core/lib/node/messaging/messaging-service'; +import { RpcConnection } from '@theia/core/'; @injectable() export class TerminalBackendContribution implements MessagingService.Contribution { @@ -30,19 +31,28 @@ export class TerminalBackendContribution implements MessagingService.Contributio protected readonly logger: ILogger; configure(service: MessagingService): void { - service.listen(`${terminalsPath}/:id`, (params: { id: string }, connection) => { + service.wsChannel(`${terminalsPath}/:id`, (params: { id: string }, connection) => { const id = parseInt(params.id, 10); const termProcess = this.processManager.get(id); if (termProcess instanceof TerminalProcess) { const output = termProcess.createOutputStream(); - output.on('data', data => connection.sendNotification('onData', data.toString())); - connection.onRequest('write', (data: string) => termProcess.write(data)); + // Create a RPC connection to the terminal process + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const requestHandler: RequestHandler = async (method: string, args: any[]) => { + if (method === 'write' && args[0]) { + termProcess.write(args[0]); + } else { + this.logger.warn('Terminal process received a request with unsupported method or argument', { method, args }); + } + }; + + const rpc = new RpcConnection(connection, requestHandler); + output.on('data', data => { + rpc.sendNotification('onData', [data]); + }); connection.onClose(() => output.dispose()); - connection.listen(); - } else { - connection.dispose(); } }); } - } + diff --git a/yarn.lock b/yarn.lock index 9f56f314ef37e..489dd97a539a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2192,6 +2192,13 @@ resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== +"@types/chai-spies@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@types%2fchai-spies/-/chai-spies-1.0.3.tgz#a52dc61af3853ec9b80965040811d15dfd401542" + integrity sha512-RBZjhVuK7vrg4rWMt04UF5zHYwfHnpk5mIWu3nQvU3AKGDixXzSjZ6v0zke6pBcaJqMv3IBZ5ibLWPMRDL0sLw== + dependencies: + "@types/chai" "*" + "@types/chai-string@^1.4.0": version "1.4.2" resolved "https://registry.yarnpkg.com/@types/chai-string/-/chai-string-1.4.2.tgz#0f116504a666b6c6a3c42becf86634316c9a19ac" @@ -2199,9 +2206,9 @@ dependencies: "@types/chai" "*" -"@types/chai@*", "@types/chai@^4.2.7": +"@types/chai@*", "@types/chai@4.3.0", "@types/chai@^4.2.7": version "4.3.0" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.0.tgz#23509ebc1fa32f1b4d50d6a66c4032d5b8eaabdc" + resolved "https://registry.yarnpkg.com/@types%2fchai/-/chai-4.3.0.tgz#23509ebc1fa32f1b4d50d6a66c4032d5b8eaabdc" integrity sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw== "@types/component-emitter@^1.2.10": @@ -3890,11 +3897,28 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= +chai-spies@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/chai-spies/-/chai-spies-1.0.0.tgz#d16b39336fb316d03abf8c375feb23c0c8bb163d" + integrity sha512-elF2ZUczBsFoP07qCfMO/zeggs8pqCf3fZGyK5+2X4AndS8jycZYID91ztD9oQ7d/0tnS963dPkd0frQEThDsg== + chai-string@^1.4.0: version "1.5.0" resolved "https://registry.yarnpkg.com/chai-string/-/chai-string-1.5.0.tgz#0bdb2d8a5f1dbe90bc78ec493c1c1c180dd4d3d2" integrity sha512-sydDC3S3pNAQMYwJrs6dQX0oBQ6KfIPuOZ78n7rocW0eJJlsHPh2t3kwW7xfwYA/1Bf6/arGtSUo16rxR2JFlw== +chai@4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.4.tgz#b55e655b31e1eac7099be4c08c21964fce2e6c49" + integrity sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.2" + deep-eql "^3.0.1" + get-func-name "^2.0.0" + pathval "^1.1.1" + type-detect "^4.0.5" + chai@^4.2.0: version "4.3.6" resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.6.tgz#ffe4ba2d9fa9d6680cc0b370adae709ec9011e9c"