diff --git a/packages/addons/src/browser/chrome-devtools.contribution.ts b/packages/addons/src/browser/chrome-devtools.contribution.ts index 674b1d0b84..6b603c12f0 100644 --- a/packages/addons/src/browser/chrome-devtools.contribution.ts +++ b/packages/addons/src/browser/chrome-devtools.contribution.ts @@ -1,20 +1,12 @@ import { Autowired } from '@opensumi/di'; -import { AppConfig, ClientAppContribution } from '@opensumi/ide-core-browser'; +import { AppConfig, ClientAppContribution, Disposable } from '@opensumi/ide-core-browser'; +import { DevtoolsLantencyCommand, EDevtoolsEvent } from '@opensumi/ide-core-common/lib/devtools'; import { Domain } from '@opensumi/ide-core-common/lib/di-helper'; import { ConnectionRTTBrowserService, ConnectionRTTBrowserServiceToken } from './connection-rtt-service'; -enum DevtoolsEvent { - Latency = 'devtools:latency', -} - -enum DevtoolsCommand { - Start = 'start', - Stop = 'stop', -} - @Domain(ClientAppContribution) -export class ChromeDevtoolsContribution implements ClientAppContribution { +export class ChromeDevtoolsContribution extends Disposable implements ClientAppContribution { @Autowired(AppConfig) private readonly appConfig: AppConfig; @@ -25,23 +17,35 @@ export class ChromeDevtoolsContribution implements ClientAppContribution { static INTERVAL = 1000; + protected lantencyHandler = (event: CustomEvent) => { + const { command } = event.detail; + if (command === DevtoolsLantencyCommand.Start) { + if (!this.interval) { + this.startRTTInterval(); + } + } else if (command === DevtoolsLantencyCommand.Stop) { + if (this.interval) { + global.clearInterval(this.interval); + this.interval = undefined; + } + } + }; + initialize() { // only runs when devtools supoprt is enabled if (this.appConfig.devtools) { // receive notification from opensumi devtools by custom event - window.addEventListener(DevtoolsEvent.Latency, (event) => { - const { command } = event.detail; - if (command === DevtoolsCommand.Start) { - if (!this.interval) { - this.startRTTInterval(); - } - } else if (command === DevtoolsCommand.Stop) { + window.addEventListener(EDevtoolsEvent.Latency, this.lantencyHandler); + + this.addDispose( + Disposable.create(() => { + window.removeEventListener(EDevtoolsEvent.Latency, this.lantencyHandler); if (this.interval) { global.clearInterval(this.interval); this.interval = undefined; } - } - }); + }), + ); // if opensumi devtools has started capturing before this contribution point is registered if (window.__OPENSUMI_DEVTOOLS_GLOBAL_HOOK__?.captureRPC) { diff --git a/packages/connection/__test__/common/rpc/utils.ts b/packages/connection/__test__/common/rpc/utils.ts index f0163ac8b1..913922e512 100644 --- a/packages/connection/__test__/common/rpc/utils.ts +++ b/packages/connection/__test__/common/rpc/utils.ts @@ -5,8 +5,9 @@ import { MessageChannel, MessagePort } from 'worker_threads'; import { Type } from '@furyjs/fury'; -import { ProxyJson, WSChannel, createWebSocketConnection } from '@opensumi/ide-connection'; +import { ProxyJson, WSChannel } from '@opensumi/ide-connection'; import { NetSocketConnection } from '@opensumi/ide-connection/lib/common/connection'; +import { createWebSocketConnection } from '@opensumi/ide-connection/lib/common/message'; import { Deferred } from '@opensumi/ide-core-common'; import { normalizedIpcHandlerPathAsync } from '@opensumi/ide-core-common/lib/utils/ipc'; import { MessageConnection } from '@opensumi/vscode-jsonrpc'; diff --git a/packages/connection/src/common/capturer.ts b/packages/connection/src/common/capturer.ts index a17e769815..a3252cec53 100644 --- a/packages/connection/src/common/capturer.ts +++ b/packages/connection/src/common/capturer.ts @@ -1,3 +1,12 @@ +import { + DisposableStore, + IDisposable, + isUint8Array, + randomString, + transformErrorForSerialization, +} from '@opensumi/ide-core-common'; +import { DevtoolsLantencyCommand, EDevtoolsEvent } from '@opensumi/ide-core-common/lib/devtools'; + declare global { interface Window { __OPENSUMI_DEVTOOLS_GLOBAL_HOOK__: any; @@ -22,15 +31,172 @@ export interface ICapturedMessage { type: MessageType; serviceMethod: string; arguments?: any; - requestId?: string; + requestId?: string | number; status?: ResponseStatus; data?: any; error?: any; + + source?: string; } +const _global = (typeof window !== 'undefined' ? window : global) || { + __OPENSUMI_DEVTOOLS_GLOBAL_HOOK__: undefined, +}; + export function getCapturer() { - if (typeof window !== 'undefined' && window.__OPENSUMI_DEVTOOLS_GLOBAL_HOOK__?.captureRPC) { - return window.__OPENSUMI_DEVTOOLS_GLOBAL_HOOK__.captureRPC; + const hook = _global.__OPENSUMI_DEVTOOLS_GLOBAL_HOOK__; + if (hook) { + return hook.captureRPC; } return; } + +export class Capturer implements IDisposable { + protected _disposables = new DisposableStore(); + + protected capturer: ((data: any) => void) | null = null; + protected prefix: string; + + protected setupListener = (event: CustomEvent) => { + const { command } = event.detail; + if (command === DevtoolsLantencyCommand.Start) { + this.capturer = getCapturer(); + } else if (command === DevtoolsLantencyCommand.Stop) { + this.capturer = null; + } + }; + + constructor(protected source: string) { + this.prefix = randomString(6); + this.capturer = getCapturer(); + + // capturer should only be used in browser environment + if (typeof _global.addEventListener === 'function') { + _global.addEventListener(EDevtoolsEvent.Latency, this.setupListener); + this._disposables.add({ + dispose: () => { + _global.removeEventListener(EDevtoolsEvent.Latency, this.setupListener); + }, + }); + } + } + + capture(message: ICapturedMessage): void { + if (!this.capturer) { + return; + } + + const data: ICapturedMessage = { + ...message, + source: this.source, + }; + + if (data.data) { + if (isUint8Array(data.data)) { + data.data = ''; + } + } + + if (message.requestId) { + data.requestId = `${this.prefix}-${message.requestId}`; + } + + if (message.error) { + data.error = transformErrorForSerialization(message.error); + } + + this.capturer(data); + } + + captureOnRequest(requestId: ICapturedMessage['requestId'], serviceMethod: string, args: any[]): void { + if (!this.capturer) { + return; + } + + this.capture({ type: MessageType.OnRequest, requestId: `↓${requestId}`, serviceMethod, arguments: args }); + } + + captureOnRequestResult(requestId: ICapturedMessage['requestId'], serviceMethod: string, data: any): void { + if (!this.capturer) { + return; + } + + this.capture({ + type: MessageType.OnRequestResult, + status: ResponseStatus.Success, + requestId: `↓${requestId}`, + serviceMethod, + data, + }); + } + + captureOnRequestFail(requestId: ICapturedMessage['requestId'], serviceMethod: string, error: any): void { + if (!this.capturer) { + return; + } + + this.capture({ + type: MessageType.OnRequestResult, + status: ResponseStatus.Fail, + requestId: `↓${requestId}`, + serviceMethod, + error, + }); + } + + captureSendRequest(requestId: ICapturedMessage['requestId'] | number, serviceMethod: string, args: any[]): void { + if (!this.capturer) { + return; + } + + this.capture({ type: MessageType.SendRequest, requestId, serviceMethod, arguments: args }); + } + + captureSendRequestResult(requestId: ICapturedMessage['requestId'], serviceMethod: string, data: any): void { + if (!this.capturer) { + return; + } + + this.capture({ + type: MessageType.RequestResult, + status: ResponseStatus.Success, + requestId, + serviceMethod, + data, + }); + } + + captureSendRequestFail(requestId: ICapturedMessage['requestId'], serviceMethod: string, error: any): void { + if (!this.capturer) { + return; + } + + this.capture({ + type: MessageType.RequestResult, + status: ResponseStatus.Fail, + requestId, + serviceMethod, + error, + }); + } + + captureSendNotification(requestId: ICapturedMessage['requestId'], serviceMethod: string, args: any[]): void { + if (!this.capturer) { + return; + } + + this.capture({ type: MessageType.SendNotification, serviceMethod, arguments: args, requestId }); + } + + captureOnNotification(requestId: ICapturedMessage['requestId'], serviceMethod: string, args: any[]): void { + if (!this.capturer) { + return; + } + + this.capture({ type: MessageType.OnNotification, serviceMethod, arguments: args, requestId: `↓${requestId}` }); + } + + dispose(): void { + this._disposables.dispose(); + } +} diff --git a/packages/connection/src/common/index.ts b/packages/connection/src/common/index.ts index 3524154dae..394d4833a9 100644 --- a/packages/connection/src/common/index.ts +++ b/packages/connection/src/common/index.ts @@ -1,4 +1,3 @@ -export * from './message'; export * from './rpc-service/proxy'; export * from './rpc/multiplexer'; export * from './rpcProtocol'; diff --git a/packages/connection/src/common/rpc-service/center.ts b/packages/connection/src/common/rpc-service/center.ts index 0e364ba228..908642f8a9 100644 --- a/packages/connection/src/common/rpc-service/center.ts +++ b/packages/connection/src/common/rpc-service/center.ts @@ -1,5 +1,4 @@ import { Deferred } from '@opensumi/ide-core-common'; -import { MessageConnection } from '@opensumi/vscode-jsonrpc'; import { METHOD_NOT_REGISTERED } from '../constants'; import { TSumiProtocol } from '../rpc'; @@ -10,6 +9,8 @@ import { ProxyJson, ProxySumi } from './proxy'; import { ProxyBase } from './proxy/base'; import { ProtocolRegistry, ServiceRegistry } from './registry'; +import type { MessageConnection } from '@opensumi/vscode-jsonrpc'; + const safeProcess: { pid: string } = typeof process === 'undefined' ? { pid: 'unknown' } : (process as any); export class RPCServiceCenter { diff --git a/packages/connection/src/common/rpc-service/proxy/base.ts b/packages/connection/src/common/rpc-service/proxy/base.ts index 4e514c4add..ff48488908 100644 --- a/packages/connection/src/common/rpc-service/proxy/base.ts +++ b/packages/connection/src/common/rpc-service/proxy/base.ts @@ -1,6 +1,5 @@ -import { Deferred } from '@opensumi/ide-core-common'; +import { Deferred, DisposableStore } from '@opensumi/ide-core-common'; -import { ICapturedMessage, MessageType, ResponseStatus, getCapturer } from '../../capturer'; import { ILogger, IRPCServiceMap } from '../../types'; import type { ServiceRegistry } from '../registry'; @@ -20,14 +19,13 @@ export abstract class ProxyBase { protected connectionPromise: Deferred = new Deferred(); + protected _disposables = new DisposableStore(); + protected abstract engine: 'json' | 'sumi'; - capturer: (data: any) => void; constructor(public registry: ServiceRegistry, logger?: ILogger) { this.logger = logger || console; - this.capturer = getCapturer(); - this.registry.onServicesUpdate((services) => { if (this.connection) { this.bindMethods(services); @@ -35,105 +33,13 @@ export abstract class ProxyBase { }); } - // capture messages for opensumi devtools - private capture(message: ICapturedMessage): void { - this.capturer({ - ...message, - engine: this.engine, - }); - } - protected nextRequestId() { return String(requestId++); } - protected captureOnRequest(requestId: string, serviceMethod: string, args: any[]): void { - if (!this.capturer) { - return; - } - this.capture({ type: MessageType.OnRequest, requestId, serviceMethod, arguments: args }); - } - - protected captureOnRequestResult(requestId: string, serviceMethod: string, data: any): void { - if (!this.capturer) { - return; - } - this.capture({ - type: MessageType.OnRequestResult, - status: ResponseStatus.Success, - requestId, - serviceMethod, - data, - }); - } - - protected captureOnRequestFail(requestId: string, serviceMethod: string, error: any): void { - if (!this.capturer) { - return; - } - - this.logger.warn(`request exec ${serviceMethod} error`, error); - - this.capture({ - type: MessageType.OnRequestResult, - status: ResponseStatus.Fail, - requestId, - serviceMethod, - error, - }); - } - - protected captureOnNotification(serviceMethod: string, args: any[]): void { - if (!this.capturer) { - return; - } - this.capture({ type: MessageType.OnNotification, serviceMethod, arguments: args }); - } - - protected captureSendRequest(requestId: string, serviceMethod: string, args: any[]): void { - if (!this.capturer) { - return; - } - this.capture({ type: MessageType.SendRequest, requestId, serviceMethod, arguments: args }); - } - - protected captureSendRequestResult(requestId: string, serviceMethod: string, data: any): void { - if (!this.capturer) { - return; - } - - this.capture({ - type: MessageType.RequestResult, - status: ResponseStatus.Success, - requestId, - serviceMethod, - data, - }); - } - - protected captureSendRequestFail(requestId: string, serviceMethod: string, error: any): void { - if (!this.capturer) { - return; - } - - this.capture({ - type: MessageType.RequestResult, - status: ResponseStatus.Fail, - requestId, - serviceMethod, - error, - }); - } - - protected captureSendNotification(serviceMethod: string, args: any[]): void { - if (!this.capturer) { - return; - } - this.capture({ type: MessageType.SendNotification, serviceMethod, arguments: args }); - } - listen(connection: T): void { this.connection = connection; + this._disposables.add(this.connection); this.bindMethods(this.registry.methods()); connection.listen(); @@ -145,9 +51,7 @@ export abstract class ProxyBase { } dispose(): void { - if (this.connection) { - this.connection.dispose(); - } + this._disposables.dispose(); } public abstract invoke(prop: string, ...args: any[]): Promise; diff --git a/packages/connection/src/common/rpc-service/proxy/json.ts b/packages/connection/src/common/rpc-service/proxy/json.ts index d6221a5ce9..2f109a9c41 100644 --- a/packages/connection/src/common/rpc-service/proxy/json.ts +++ b/packages/connection/src/common/rpc-service/proxy/json.ts @@ -1,9 +1,10 @@ -import { MessageConnection } from '@opensumi/vscode-jsonrpc'; - +import { Capturer } from '../../capturer'; import { METHOD_NOT_REGISTERED } from '../../constants'; import { ProxyBase } from './base'; +import type { MessageConnection } from '@opensumi/vscode-jsonrpc'; + interface IRPCResult { error: boolean; data: any; @@ -11,12 +12,13 @@ interface IRPCResult { export class ProxyJson extends ProxyBase { protected engine = 'json' as const; + protected capturer = this._disposables.add(new Capturer(this.engine)); protected bindMethods(methods: string[]): void { for (const method of methods) { if (method.startsWith('on')) { this.connection.onNotification(method, async (...args: any[]) => { - this.captureOnNotification(method, args); + this.capturer.captureOnNotification('_', method, args); try { await this.registry.invoke(method, ...this.serializeArguments(args)); } catch (e) { @@ -26,19 +28,19 @@ export class ProxyJson extends ProxyBase { } else { this.connection.onRequest(method, async (...args: any[]) => { const requestId = this.nextRequestId(); - this.captureOnRequest(requestId, method, args); + this.capturer.captureOnRequest(requestId, method, args); try { const result = await this.registry.invoke(method, ...this.serializeArguments(args)); - this.captureOnRequestResult(requestId, method, result); + this.capturer.captureOnRequestResult(requestId, method, result); return { error: false, data: result, }; } catch (e) { - this.captureOnRequestFail(requestId, method, e); + this.capturer.captureOnRequestFail(requestId, method, e); return { error: true, @@ -63,7 +65,7 @@ export class ProxyJson extends ProxyBase { // 调用方法为 on 开头时,作为单项通知 if (prop.startsWith('on')) { - this.captureSendNotification(prop, args); + this.capturer.captureSendNotification('_', prop, args); if (isSingleArray) { this.connection.sendNotification(prop, [...args]); } else { @@ -81,7 +83,7 @@ export class ProxyJson extends ProxyBase { requestResult = this.connection.sendRequest(prop, ...args) as Promise; } - this.captureSendRequest(requestId, prop, args); + this.capturer.captureSendRequest(requestId, prop, args); const result: IRPCResult = await requestResult; @@ -91,10 +93,10 @@ export class ProxyJson extends ProxyBase { error.stack = result.data.stack; } - this.captureSendRequestFail(requestId, prop, result.data); + this.capturer.captureSendRequestFail(requestId, prop, result.data); throw error; } else { - this.captureSendRequestResult(requestId, prop, result.data); + this.capturer.captureSendRequestResult(requestId, prop, result.data); return result.data; } } @@ -128,11 +130,11 @@ export class ProxyJson extends ProxyBase { connection.onRequest((method) => { if (!this.registry.has(method)) { const requestId = this.nextRequestId(); - this.captureOnRequest(requestId, method, []); + this.capturer.captureOnRequest(requestId, method, []); const result = { data: METHOD_NOT_REGISTERED, }; - this.captureOnRequestFail(requestId, method, result.data); + this.capturer.captureOnRequestFail(requestId, method, result.data); return result; } }); diff --git a/packages/connection/src/common/rpc-service/proxy/sumi.ts b/packages/connection/src/common/rpc-service/proxy/sumi.ts index c8e7a5fff9..42a525aa60 100644 --- a/packages/connection/src/common/rpc-service/proxy/sumi.ts +++ b/packages/connection/src/common/rpc-service/proxy/sumi.ts @@ -10,7 +10,6 @@ export class ProxySumi extends ProxyBase { for (const method of methods) { if (method.startsWith('on')) { this.connection.onNotification(method, async (...args: any[]) => { - this.captureOnNotification(method, args); try { await this.registry.invoke(method, ...args); } catch (e) { @@ -18,19 +17,7 @@ export class ProxySumi extends ProxyBase { } }); } else { - this.connection.onRequest(method, async (...args: any[]) => { - const requestId = this.nextRequestId(); - this.captureOnRequest(requestId, method, args); - - try { - const result = await this.registry.invoke(method, ...args); - this.captureOnRequestResult(requestId, method, result); - return result; - } catch (e) { - this.captureOnRequestFail(requestId, method, e); - throw e; - } - }); + this.connection.onRequest(method, async (...args: any[]) => await this.registry.invoke(method, ...args)); } } } @@ -40,29 +27,15 @@ export class ProxySumi extends ProxyBase { // 调用方法为 on 开头时,作为单项通知 if (prop.startsWith('on')) { - this.captureSendNotification(prop, args); this.connection.sendNotification(prop, ...args); } else { - // generate a unique requestId to associate request and requestResult - const requestId = this.nextRequestId(); - this.captureSendRequest(requestId, prop, args); - try { - const result = await this.connection.sendRequest(prop, ...args); - this.captureSendRequestResult(requestId, prop, result); - return result; - } catch (error) { - this.captureSendRequestFail(requestId, prop, error); - throw error; - } + return await this.connection.sendRequest(prop, ...args); } } listen(connection: SumiConnection): void { super.listen(connection); - connection.onRequestNotFound((method) => { - const requestId = this.nextRequestId(); - this.captureOnRequest(requestId, method, []); - this.captureOnRequestFail(requestId, method, METHOD_NOT_REGISTERED); + connection.onRequestNotFound(() => { throw METHOD_NOT_REGISTERED; }); } diff --git a/packages/connection/src/common/rpc/connection.ts b/packages/connection/src/common/rpc/connection.ts index 6410677510..722d325c8d 100644 --- a/packages/connection/src/common/rpc/connection.ts +++ b/packages/connection/src/common/rpc/connection.ts @@ -2,7 +2,7 @@ import { getDebugLogger } from '@opensumi/ide-core-common'; import { CancellationToken, CancellationTokenSource, - DisposableCollection, + DisposableStore, EventQueue, IDisposable, canceled, @@ -10,6 +10,7 @@ import { } from '@opensumi/ide-utils'; import { IReadableStream, isReadableStream, listenReadable } from '@opensumi/ide-utils/lib/stream'; +import { Capturer } from '../capturer'; import { BaseConnection, NetSocketConnection, WSWebSocketConnection } from '../connection'; import { METHOD_NOT_REGISTERED } from '../constants'; import { ILogger } from '../types'; @@ -34,10 +35,14 @@ const nullHeaders = {}; export interface ISumiConnectionOptions { timeout?: number; logger?: ILogger; + /** + * The name of the connection, used for debugging(and can see in opensumi-devtools). + */ + name?: string; } export class SumiConnection implements IDisposable { - protected disposable = new DisposableCollection(); + protected disposable = new DisposableStore(); private _requestHandlers = new Map>(); private _starRequestHandler: TRequestNotFoundHandler | undefined; @@ -56,16 +61,24 @@ export class SumiConnection implements IDisposable { public io = new MessageIO(); protected logger: ILogger; + protected capturer: Capturer; + constructor(protected socket: BaseConnection, protected options: ISumiConnectionOptions = {}) { if (options.logger) { this.logger = options.logger; } else { this.logger = getDebugLogger(); } + + this.capturer = new Capturer(options.name || 'sumi'); + this.disposable.add(this.capturer); } sendNotification(method: string, ...args: any[]) { - this.socket.send(this.io.Notification(this._requestId++, method, nullHeaders, args)); + const requestId = this._requestId++; + + this.capturer.captureSendNotification(requestId, method, args); + this.socket.send(this.io.Notification(requestId, method, nullHeaders, args)); } sendRequest(method: string, ...args: any[]) { @@ -74,6 +87,8 @@ export class SumiConnection implements IDisposable { this._callbacks.set(requestId, (headers, error, result) => { if (error) { + this.traceRequestError(requestId, method, args, error); + if (error === METHOD_NOT_REGISTERED) { // we should not treat `METHOD_NOT_REGISTERED` as an error. // it is a special case, it means the method is not registered on the other side. @@ -81,11 +96,12 @@ export class SumiConnection implements IDisposable { return; } - this.traceRequestError(method, args, error); reject(error); return; } + this.capturer.captureSendRequestResult(requestId, method, result); + resolve(result); }); @@ -107,6 +123,8 @@ export class SumiConnection implements IDisposable { cancellationToken.onCancellationRequested(() => this.cancelRequest(requestId)); } + this.capturer.captureSendRequest(requestId, method, args); + this.socket.send( this.io.Request( requestId, @@ -267,6 +285,8 @@ export class SumiConnection implements IDisposable { switch (opType) { case OperationType.Request: { + this.capturer.captureOnRequest(requestId, method, args); + let promise: Promise; try { @@ -285,6 +305,8 @@ export class SumiConnection implements IDisposable { } const onSuccess = (result: any) => { + this.capturer.captureOnRequestResult(requestId, method, result); + if (isReadableStream(result)) { const responseHeaders: IResponseHeaders = { chunked: true, @@ -305,7 +327,8 @@ export class SumiConnection implements IDisposable { }; const onError = (err: Error) => { - this.traceRequestError(method, args, err); + this.traceRequestError(requestId, method, args, err); + this.socket.send(this.io.Error(requestId, method, nullHeaders, err)); this._cancellationTokenSources.delete(requestId); }; @@ -314,7 +337,10 @@ export class SumiConnection implements IDisposable { break; } case OperationType.Notification: { + this.capturer.captureOnNotification(requestId, method, args); + const handler = this._notificationHandlers.get(method); + if (handler) { handler(...args); } else if (this._starNotificationHandler) { @@ -341,7 +367,7 @@ export class SumiConnection implements IDisposable { } }); if (toDispose) { - this.disposable.push(toDispose); + this.disposable.add(toDispose); } } @@ -357,7 +383,8 @@ export class SumiConnection implements IDisposable { return new SumiConnection(new NetSocketConnection(socket), options); } - private traceRequestError(method: string, args: any[], error: any) { + private traceRequestError(requestId: number, method: string, args: any[], error: any) { + this.capturer.captureSendRequestFail(requestId, method, error); this.logger.error(`Error handling request ${method} with args `, args, error); } } diff --git a/packages/connection/src/common/ws-channel.ts b/packages/connection/src/common/ws-channel.ts index dd31499118..7fafef899c 100644 --- a/packages/connection/src/common/ws-channel.ts +++ b/packages/connection/src/common/ws-channel.ts @@ -5,7 +5,6 @@ import { DisposableCollection } from '@opensumi/ide-core-common'; import { IConnectionShape } from './connection/types'; import { oneOf7 } from './fury-extends/one-of'; -import { createWebSocketConnection } from './message'; import { ISumiConnectionOptions, SumiConnection } from './rpc/connection'; import { ILogger } from './types'; @@ -213,9 +212,6 @@ export class WSChannel { onceClose(cb: (code: number, reason: string) => void) { return this.emitter.once('close', cb); } - createMessageConnection() { - return createWebSocketConnection(this); - } createConnection() { return { diff --git a/packages/core-browser/src/progress/progress.service.tsx b/packages/core-browser/src/progress/progress.service.tsx index 5fad7b1627..73cb0f8754 100644 --- a/packages/core-browser/src/progress/progress.service.tsx +++ b/packages/core-browser/src/progress/progress.service.tsx @@ -25,6 +25,7 @@ import { dispose, localize, parseLinkedText, + randomString, strings, timeout, toDisposable, @@ -317,7 +318,7 @@ export class ProgressService implements IProgressService { buttons.push(...options.buttons); } - const notificationKey = Math.random().toString(18).slice(2, 5); + const notificationKey = randomString(3); const indicator = this.injector.get(ProgressIndicator); this.registerProgressIndicator(notificationKey, indicator); diff --git a/packages/core-common/src/devtools/index.ts b/packages/core-common/src/devtools/index.ts new file mode 100644 index 0000000000..c37514a976 --- /dev/null +++ b/packages/core-common/src/devtools/index.ts @@ -0,0 +1,8 @@ +export enum EDevtoolsEvent { + Latency = 'devtools:latency', +} + +export enum DevtoolsLantencyCommand { + Start = 'start', + Stop = 'stop', +} diff --git a/packages/extension-manager/src/browser/vsx-extension.service.ts b/packages/extension-manager/src/browser/vsx-extension.service.ts index 89b1832667..d6cca61ace 100644 --- a/packages/extension-manager/src/browser/vsx-extension.service.ts +++ b/packages/extension-manager/src/browser/vsx-extension.service.ts @@ -10,6 +10,7 @@ import { URI, fuzzyScore, localize, + pMemoize, } from '@opensumi/ide-core-browser'; import { WorkbenchEditorService } from '@opensumi/ide-editor/lib/browser'; import { ExtensionManagementService } from '@opensumi/ide-extension/lib/browser/extension-management.service'; @@ -208,6 +209,7 @@ export class VSXExtensionService extends Disposable implements IVSXExtensionServ }); } + @pMemoize((keyword: string) => keyword) async search(keyword: string) { const param: VSXSearchParam = { query: keyword, diff --git a/packages/extension/__tests__/browser/main.thread.editor.test.ts b/packages/extension/__tests__/browser/main.thread.editor.test.ts index 115c742b5b..2e61dfe77d 100644 --- a/packages/extension/__tests__/browser/main.thread.editor.test.ts +++ b/packages/extension/__tests__/browser/main.thread.editor.test.ts @@ -2,8 +2,7 @@ import path from 'path'; import isEqual from 'lodash/isEqual'; -import { IContextKeyService, URI } from '@opensumi/ide-core-browser'; -import { CorePreferences, MonacoOverrideServiceRegistry } from '@opensumi/ide-core-browser'; +import { CorePreferences, IContextKeyService, MonacoOverrideServiceRegistry, URI } from '@opensumi/ide-core-browser'; import { injectMockPreferences } from '@opensumi/ide-core-browser/__mocks__/preference'; import { useMockStorage } from '@opensumi/ide-core-browser/__mocks__/storage'; import { @@ -12,10 +11,15 @@ import { Emitter, IApplicationService, IEventBus, - IFileServiceClient, OS, + sleep, } from '@opensumi/ide-core-common'; +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; import { IEditorOpenType, IResource } from '@opensumi/ide-editor'; +import { + TestEditorDocumentProvider, + TestResourceResolver, +} from '@opensumi/ide-editor/__tests__/browser/test-providers'; import { EditorModule, EditorPreferences, @@ -37,10 +41,11 @@ import { BaseFileSystemEditorDocumentProvider } from '@opensumi/ide-editor/lib/b import { FileSystemResourceProvider } from '@opensumi/ide-editor/lib/browser/fs-resource/fs-resource'; import { LanguageService } from '@opensumi/ide-editor/lib/browser/language/language.service'; import { ResourceServiceImpl } from '@opensumi/ide-editor/lib/browser/resource.service'; -import { EditorComponentRegistry, EditorOpenType } from '@opensumi/ide-editor/lib/browser/types'; import { + EditorComponentRegistry, EditorGroupChangeEvent, EditorGroupIndexChangedEvent, + EditorOpenType, EditorSelectionChangeEvent, EditorVisibleChangeEvent, } from '@opensumi/ide-editor/lib/browser/types'; @@ -60,8 +65,10 @@ import * as TypeConverts from '@opensumi/ide-extension/lib/common/vscode/convert import { ExtensionDocumentDataManagerImpl } from '@opensumi/ide-extension/lib/hosted/api/vscode/doc'; import { MockFileServiceClient } from '@opensumi/ide-file-service/__mocks__/file-service-client'; import { FileServiceContribution } from '@opensumi/ide-file-service/lib/browser/file-service-contribution'; +import { IFileServiceClient } from '@opensumi/ide-file-service/lib/common'; import { MonacoService } from '@opensumi/ide-monaco'; import * as monaco from '@opensumi/ide-monaco'; +import { MockContextKeyService } from '@opensumi/ide-monaco/__mocks__/monaco.context-key.service'; import MonacoServiceImpl from '@opensumi/ide-monaco/lib/browser/monaco.service'; import { MonacoOverrideServiceRegistryImpl } from '@opensumi/ide-monaco/lib/browser/override.service.registry'; import { IDialogService } from '@opensumi/ide-overlay'; @@ -73,9 +80,6 @@ import { IConfigurationService, } from '@opensumi/monaco-editor-core/esm/vs/platform/configuration/common/configuration'; -import { createBrowserInjector } from '../../../../tools/dev-tool/src/injector-helper'; -import { TestEditorDocumentProvider, TestResourceResolver } from '../../../editor/__tests__/browser/test-providers'; -import { MockContextKeyService } from '../../../monaco/__mocks__/monaco.context-key.service'; import { createMockPairRPCProtocol } from '../../__mocks__/initRPCProtocol'; import { MainThreadEditorService } from '../../src/browser/vscode/api/main.thread.editor'; import * as types from '../../src/common/vscode/ext-types'; @@ -108,7 +112,6 @@ describe('MainThreadEditor Test Suites', () => { let extEditor: ExtensionHostEditorService; let workbenchEditorService: WorkbenchEditorService; let eventBus: IEventBus; - let monacoservice: MonacoService; const disposables: types.OutputChannel[] = []; beforeAll(async () => { @@ -216,7 +219,6 @@ describe('MainThreadEditor Test Suites', () => { 'editor.previewMode': true, }, }); - monacoservice = injector.get(MonacoService); workbenchEditorService = injector.get(WorkbenchEditorService); const extHostDocs = rpcProtocolExt.set( ExtHostAPIIdentifier.ExtHostDocuments, @@ -350,34 +352,63 @@ describe('MainThreadEditor Test Suites', () => { ); }); - it('should receive onDidChangeTextEditorVisibleRanges event when editor visible range has changed', (done) => { - const resource: IResource = { - name: 'test-file', - uri: URI.file(path.join(__dirname, 'main.thread.output.test2.ts')), - icon: 'file', - }; - const disposer = extEditor.onDidChangeTextEditorVisibleRanges((e) => { - disposer.dispose(); - const converted = e.visibleRanges.map((v) => TypeConverts.Range.from(v)); - expect(converted.length).toBe(1); - expect(converted[0]).toEqual(range); - done(); - }); - const range = { - startLineNumber: 1, - startColumn: 12, - endLineNumber: 1, - endColumn: 12, - }; - eventBus.fire( - new EditorVisibleChangeEvent({ - group: workbenchEditorService.currentEditorGroup, - resource: (workbenchEditorService.currentResource as IResource) || resource, - visibleRanges: [new monaco.Range(1, 12, 1, 12)], - editorUri: workbenchEditorService.currentResource!.uri!, - }), - ); - }); + it( + 'should receive onDidChangeTextEditorVisibleRanges event when editor visible range has changed', + async () => { + const editorDocModelService: IEditorDocumentModelService = injector.get(IEditorDocumentModelService); + await editorDocModelService.createModelReference(URI.file(path.join(__dirname, 'main.thread.output.test2.ts'))); + + const resource: IResource = { + name: 'test-file1', + uri: URI.file(path.join(__dirname, 'main.thread.output.test2.ts')), + icon: 'file', + }; + + const defered = new Deferred(); + const disposer = extEditor.onDidChangeTextEditorVisibleRanges((e) => { + // e.payload.uri 是 mock 的,这里用 textEditor.id 来判断 + if (!(e.textEditor as any).id.includes(resource.uri.toString())) { + return; + } + + disposer.dispose(); + const converted = e.visibleRanges.map((v) => TypeConverts.Range.from(v)); + expect(converted.length).toBe(1); + expect(converted[0]).toEqual({ + startLineNumber: 1, + startColumn: 12, + endLineNumber: 1, + endColumn: 12, + }); + + defered.resolve(); + }); + + eventBus.fire( + new EditorGroupChangeEvent({ + group: workbenchEditorService.currentEditorGroup, + newOpenType: workbenchEditorService.currentEditorGroup.currentOpenType, + newResource: resource, + oldOpenType: null, + oldResource: null, + }), + ); + + await sleep(3 * 1000); + + eventBus.fire( + new EditorVisibleChangeEvent({ + group: workbenchEditorService.currentEditorGroup, + resource, + visibleRanges: [new monaco.Range(1, 12, 1, 12)], + editorUri: resource.uri, + }), + ); + + await defered.promise; + }, + 10 * 1000, + ); it.skip('should receive onDidChangeTextEditorViewColumn event when editor view column has changed', (done) => { extEditor.onDidChangeTextEditorViewColumn((e) => { diff --git a/packages/extension/__tests__/hosted/api/vscode/ext.host.task.test.ts b/packages/extension/__tests__/hosted/api/vscode/ext.host.task.test.ts index 6a2545dc64..b68e91804b 100644 --- a/packages/extension/__tests__/hosted/api/vscode/ext.host.task.test.ts +++ b/packages/extension/__tests__/hosted/api/vscode/ext.host.task.test.ts @@ -11,6 +11,8 @@ import { Uri, } from '@opensumi/ide-core-common'; import { ITaskDefinitionRegistry, TaskDefinitionRegistryImpl } from '@opensumi/ide-core-common/lib/task-definition'; +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; +import { mockService } from '@opensumi/ide-dev-tool/src/mock-injector'; import { IEditorDocumentModelService, WorkbenchEditorService } from '@opensumi/ide-editor/lib/browser'; import { ExtensionDocumentDataManagerImpl } from '@opensumi/ide-extension/lib/hosted/api/vscode/doc'; import { ExtHostMessage } from '@opensumi/ide-extension/lib/hosted/api/vscode/ext.host.message'; @@ -32,6 +34,12 @@ import { ITerminalService, ITerminalTheme, } from '@opensumi/ide-terminal-next'; +import { + MockMainLayoutService, + MockTerminalProfileInternalService, + MockTerminalService, + MockTerminalThemeService, +} from '@opensumi/ide-terminal-next/__tests__/browser/mock.service'; import { createTerminalClientFactory2 } from '@opensumi/ide-terminal-next/lib/browser/terminal.client'; import { TerminalController } from '@opensumi/ide-terminal-next/lib/browser/terminal.controller'; import { TerminalEnvironmentService } from '@opensumi/ide-terminal-next/lib/browser/terminal.environment.service'; @@ -44,14 +52,6 @@ import { ITerminalPreference } from '@opensumi/ide-terminal-next/lib/common/pref import { IVariableResolverService } from '@opensumi/ide-variable'; import { IWorkspaceService } from '@opensumi/ide-workspace'; -import { createBrowserInjector } from '../../../../../../tools/dev-tool/src/injector-helper'; -import { mockService } from '../../../../../../tools/dev-tool/src/mock-injector'; -import { - MockMainLayoutService, - MockTerminalProfileInternalService, - MockTerminalService, - MockTerminalThemeService, -} from '../../../../../terminal-next/__tests__/browser/mock.service'; import { mockExtensionProps } from '../../../../__mocks__/extensions'; import { createMockPairRPCProtocol } from '../../../../__mocks__/initRPCProtocol'; import { MainthreadTasks } from '../../../../src/browser/vscode/api/main.thread.tasks'; diff --git a/packages/extension/src/browser/extension-node.service.ts b/packages/extension/src/browser/extension-node.service.ts index 03bd44b69d..ba6df4c829 100644 --- a/packages/extension/src/browser/extension-node.service.ts +++ b/packages/extension/src/browser/extension-node.service.ts @@ -150,6 +150,7 @@ export class NodeExtProcessService implements AbstractNodeExtProcessService(); + + triggerPropertiesChange = debounce( + () => { + const changes: IEditorStatusChangeDTO[] = []; + this.propertiesChangeCache.forEach((change) => { + changes.push(change); + }); + this.propertiesChangeCache.clear(); + + this.proxy.$acceptPropertiesChanges(changes); + }, + 300, + { + maxWait: 500, + leading: true, + trailing: true, + }, + ); + + /** + * 按 id 缓存 change, 每次 change 都会合并到缓存中,debounce 发送给插件进程 + */ + protected batchPropertiesChanges(change: Partial & { id: string }) { + const { id } = change; + + let propertiesChange = this.propertiesChangeCache.get(id); + if (!propertiesChange) { + propertiesChange = {} as IEditorStatusChangeDTO; + } + + propertiesChange = merge(propertiesChange, change); + this.propertiesChangeCache.set(id, propertiesChange); + + this.triggerPropertiesChange(); + } + startEvents() { this.addDispose( this.eventBus.on(EditorGroupChangeEvent, (event) => { @@ -337,7 +376,7 @@ export class MainThreadEditorService extends WithEventBus implements IMainThread const selectionChange = (e: EditorSelectionChangeEvent) => { const editorId = getTextEditorId(e.payload.group, e.payload.editorUri, e.payload.side); - this.proxy.$acceptPropertiesChange({ + this.batchPropertiesChanges({ id: editorId, selections: { selections: e.payload.selections, @@ -371,7 +410,7 @@ export class MainThreadEditorService extends WithEventBus implements IMainThread debounce( (e: EditorVisibleChangeEvent) => { const editorId = getTextEditorId(e.payload.group, e.payload.resource.uri); - this.proxy.$acceptPropertiesChange({ + this.batchPropertiesChanges({ id: editorId, visibleRanges: e.payload.visibleRanges, }); @@ -388,7 +427,7 @@ export class MainThreadEditorService extends WithEventBus implements IMainThread e.payload.group.currentEditor && (e.payload.group.currentEditor as IMonacoImplEditor).monacoEditor.getModel() ) { - this.proxy.$acceptPropertiesChange({ + this.batchPropertiesChanges({ id: editorId, options: getEditorOption((e.payload.group.currentEditor as IMonacoImplEditor).monacoEditor), }); @@ -399,7 +438,7 @@ export class MainThreadEditorService extends WithEventBus implements IMainThread this.eventBus.on(EditorGroupIndexChangedEvent, (e) => { if (isGroupEditorState(e.payload.group)) { const editorId = getTextEditorId(e.payload.group, e.payload.group.currentResource!.uri); - this.proxy.$acceptPropertiesChange({ + this.batchPropertiesChanges({ id: editorId, viewColumn: getViewColumn(e.payload.group), }); diff --git a/packages/extension/src/common/vscode/converter.ts b/packages/extension/src/common/vscode/converter.ts index cb7c172015..e78714410d 100644 --- a/packages/extension/src/common/vscode/converter.ts +++ b/packages/extension/src/common/vscode/converter.ts @@ -26,6 +26,7 @@ import { objects, once, path, + randomString, } from '@opensumi/ide-core-common'; import * as debugModel from '@opensumi/ide-debug'; import { IEvaluatableExpression } from '@opensumi/ide-debug/lib/common/evaluatable-expression'; @@ -256,7 +257,7 @@ export namespace MarkdownString { let changed = false; data = cloneAndChange(data, (value) => { if (Uri.isUri(value)) { - const key = `__uri_${Math.random().toString(16).slice(2, 8)}`; + const key = `__uri_${randomString(6)}`; bucket[key] = value; changed = true; return key; diff --git a/packages/extension/src/common/vscode/editor.ts b/packages/extension/src/common/vscode/editor.ts index 26388e8ff6..60e7b501c2 100644 --- a/packages/extension/src/common/vscode/editor.ts +++ b/packages/extension/src/common/vscode/editor.ts @@ -1,4 +1,4 @@ -import { ILineChange, IRange, ISelection } from '@opensumi/ide-core-common'; +import { ILineChange, IRange, ISelection, MaybePromise } from '@opensumi/ide-core-common'; import { IDecorationApplyOptions, IDecorationRenderOptions, @@ -17,8 +17,8 @@ import type { RenderLineNumbersType as MonacoRenderLineNumbersType } from '@open export * from './custom-editor'; export * from './enums'; export interface IExtensionHostEditorService { - $acceptChange(change: IEditorChangeDTO); - $acceptPropertiesChange(change: IEditorStatusChangeDTO); + $acceptChange(change: IEditorChangeDTO): MaybePromise; + $acceptPropertiesChanges(changes: IEditorStatusChangeDTO[]): MaybePromise; } export interface IMainThreadEditorsService { diff --git a/packages/extension/src/common/vscode/scm.ts b/packages/extension/src/common/vscode/scm.ts index 7d5711604d..a61edb1f66 100644 --- a/packages/extension/src/common/vscode/scm.ts +++ b/packages/extension/src/common/vscode/scm.ts @@ -1,9 +1,7 @@ -import { CancellationToken } from '@opensumi/vscode-jsonrpc/lib/common/cancellation'; - import { IExtensionDescription } from './extension'; import { VSCommand } from './model.api'; -import type { IDisposable, Uri, UriComponents } from '@opensumi/ide-core-common'; +import type { CancellationToken, IDisposable, Uri, UriComponents } from '@opensumi/ide-core-common'; import type vscode from 'vscode'; export interface ObjectIdentifier { @@ -93,9 +91,9 @@ export type SCMRawResource = [ ]; export interface SCMInputActionButtonDto { - command: CommandDto; - icon?: UriComponents | { light: UriComponents; dark: UriComponents } | vscode.ThemeIcon; - enabled: boolean; + command: CommandDto; + icon?: UriComponents | { light: UriComponents; dark: UriComponents } | vscode.ThemeIcon; + enabled: boolean; } export type SCMRawResourceSplice = [number /* start */, number /* delete count */, SCMRawResource[]]; diff --git a/packages/extension/src/hosted/api/vscode/editor/editor.host.ts b/packages/extension/src/hosted/api/vscode/editor/editor.host.ts index fda72b08e4..1eb009e5c8 100644 --- a/packages/extension/src/hosted/api/vscode/editor/editor.host.ts +++ b/packages/extension/src/hosted/api/vscode/editor/editor.host.ts @@ -60,10 +60,6 @@ export class ExtensionHostEditorService implements IExtensionHostEditorService { constructor(rpcProtocol: IRPCProtocol, public readonly documents: ExtensionDocumentDataManager) { this._proxy = rpcProtocol.getProxy(MainThreadAPIIdentifier.MainThreadEditors); - // this._proxy.$getInitialState().then((change) => { - // console.log('$getInitialState', change); - // this.$acceptChange(change); - // }); } $acceptChange(change: IEditorChangeDTO) { @@ -160,9 +156,11 @@ export class ExtensionHostEditorService implements IExtensionHostEditorService { return this.openResource(uri, options); } - $acceptPropertiesChange(change: IEditorStatusChangeDTO) { - if (this._editors.get(change.id)) { - this._editors.get(change.id)!.acceptStatusChange(change); + $acceptPropertiesChanges(changes: IEditorStatusChangeDTO[]) { + for (const change of changes) { + if (this._editors.get(change.id)) { + this._editors.get(change.id)!.acceptStatusChange(change); + } } } diff --git a/packages/theme/src/browser/icon.service.ts b/packages/theme/src/browser/icon.service.ts index 274ca13169..494c45f0aa 100644 --- a/packages/theme/src/browser/icon.service.ts +++ b/packages/theme/src/browser/icon.service.ts @@ -13,6 +13,7 @@ import { PreferenceService, URI, WithEventBus, + randomString, } from '@opensumi/ide-core-browser'; import { StaticResourceService } from '@opensumi/ide-core-browser/lib/static-resource'; @@ -165,7 +166,7 @@ export class IconService extends WithEventBus implements IIconService { } protected getRandomIconClass(prefix = '') { - return `${prefix}icon-${Math.random().toString(36).slice(-8)}`; + return `${prefix}icon-${randomString(6)}`; } protected getMaskStyleSheet(iconUrl: string, className: string, baseTheme?: string): string { diff --git a/packages/utils/src/decorators.ts b/packages/utils/src/decorators.ts index c7f34d8ef2..64887256fc 100644 --- a/packages/utils/src/decorators.ts +++ b/packages/utils/src/decorators.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ // Some code copied and modified from https://github.com/microsoft/vscode/blob/1.44.0/src/vs/base/common/decorators.ts +import { isPromise } from './types'; + export function createDecorator(mapFn: (fn: Function, key: string) => Function): Function { return (target: any, key: string, descriptor: any) => { let fnKey: string | null = null; @@ -164,3 +166,48 @@ export function es5ClassCompat(target: any): any { Object.setPrototypeOf(_.prototype, target.prototype); return _; } + +/** + * Store promises so that only one promise exists at a time + */ +export function pMemoize(hashFn: (...params: any[]) => string) { + return function (target: any, key: string, descriptor: any) { + let fnKey: string | null = null; + let fn: (() => any) | null = null; + + if (typeof descriptor.value === 'function') { + fnKey = 'value'; + fn = descriptor.value; + } else if (typeof descriptor.get === 'function') { + fnKey = 'get'; + fn = descriptor.get; + } + + if (!fn) { + throw new Error('not supported'); + } + + descriptor[fnKey!] = function (...args: any[]) { + const self = this; + const memoizeKey = `$memoizedPromise:${hashFn(...args)}:${key}`; + + if (!this.hasOwnProperty(memoizeKey)) { + const promise = fn!.apply(this, args); + if (!isPromise(promise)) { + throw new Error(`return type of ${key} is not promise, please use memoize instead`); + } + promise.finally(() => { + delete self[memoizeKey]; + }); + Object.defineProperty(this, memoizeKey, { + configurable: true, + enumerable: false, + writable: true, + value: promise, + }); + } + + return this[memoizeKey]; + }; + }; +} diff --git a/packages/utils/src/event.ts b/packages/utils/src/event.ts index 916b65a848..ac182a84b2 100644 --- a/packages/utils/src/event.ts +++ b/packages/utils/src/event.ts @@ -9,6 +9,7 @@ import { Disposable, DisposableStore, IDisposable, combinedDisposable, toDisposa import { onUnexpectedError } from './errors'; import { once as onceFn } from './functional'; import { LinkedList } from './linked-list'; +import { randomString } from './uuid'; /** * 重要备注 @@ -420,7 +421,7 @@ class LeakageMonitor { private _stacks: Map | undefined; private _warnCountdown = 0; - constructor(readonly customThreshold?: number, readonly name: string = Math.random().toString(18).slice(2, 5)) {} + constructor(readonly customThreshold?: number, readonly name: string = randomString(3)) {} dispose(): void { if (this._stacks) { diff --git a/packages/utils/src/functional.ts b/packages/utils/src/functional.ts index 3611628e23..83134fe0c4 100644 --- a/packages/utils/src/functional.ts +++ b/packages/utils/src/functional.ts @@ -20,16 +20,6 @@ export function once(this: any, fn: T): T { } as any as T; } -export function makeRandomHexString(length: number): string { - const chars = ['0', '1', '2', '3', '4', '5', '6', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; - let result = ''; - for (let i = 0; i < length; i++) { - const idx = Math.floor(chars.length * Math.random()); - result += chars[idx]; - } - return result; -} - export function removeObjectFromArray(array: Array, object: T, comparator?: (o1: T, o2: T) => boolean) { let index = -1; if (comparator) { diff --git a/packages/utils/src/uuid.ts b/packages/utils/src/uuid.ts index 06a8b7f0f5..8783c0e152 100644 --- a/packages/utils/src/uuid.ts +++ b/packages/utils/src/uuid.ts @@ -4,3 +4,13 @@ import { nanoid } from 'nanoid'; export function uuid(size?: number): string { return nanoid(size); } + +export function randomString(size: number, radix = 18): string { + return Math.random() + .toString(radix) + .slice(2, size + 2); +} + +export function makeRandomHexString(length: number): string { + return randomString(length, 16); +}