Skip to content

Commit

Permalink
Added first draft for cancelable request handlers #16
Browse files Browse the repository at this point in the history
  • Loading branch information
dhuebner committed Dec 13, 2023
1 parent 3cc8f7a commit cd7681d
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 29 deletions.
67 changes: 64 additions & 3 deletions packages/vscode-messenger-common/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,13 @@ export type RequestType<P, R> = {
/**
* Used to ensure correct typing. Clients must not use this property
*/
readonly _?: [P,R]
readonly _?: [P, R]
};

/**
* Function for handling incoming requests.
*/
export type RequestHandler<P, R> = (params: P, sender: MessageParticipant) => HandlerResult<R>;
export type RequestHandler<P, R> = (params: P, sender: MessageParticipant, cancelIndicator: CancelIndicator) => HandlerResult<R>;
export type HandlerResult<R> = R | Promise<R>;

/**
Expand All @@ -176,7 +176,7 @@ export type NotificationHandler<P> = (params: P, sender: MessageParticipant) =>
* Base API for Messenger implementations.
*/
export interface MessengerAPI {
sendRequest<P, R>(type: RequestType<P, R>, receiver: MessageParticipant, params?: P): Promise<R>
sendRequest<P, R>(type: RequestType<P, R>, receiver: MessageParticipant, params?: P, cancelable?: Cancelable): Promise<R>
onRequest<P, R>(type: RequestType<P, R>, handler: RequestHandler<P, R>): void
sendNotification<P>(type: NotificationType<P>, receiver: MessageParticipant, params?: P): void
onNotification<P>(type: NotificationType<P>, handler: NotificationHandler<P>): void
Expand All @@ -194,4 +194,65 @@ export class PendingRequest<R = any> {
this.resolve = (arg) => resolve(arg);
this.reject = (err) => reject(err);
});
}

/**
* Interface that allows to check for cancellation and
* set a listener that is called when the request is canceled.
*/
export interface CancelIndicator {
isCanceled(): boolean;
onCancel: ((reason: string) => void) | undefined;
}

/**
* Implementation of the CancelIndicator interface.
* Allows to trigger cancelation.
*/
export class Cancelable implements CancelIndicator {
private canceled = false;

public cancel(reason: string): void {
if (this.canceled) {
throw new Error('Request already canceled');
}
this.canceled = true;
this.onCancel?.(reason);
}

public isCanceled(): boolean {
return this.canceled;
}

public onCancel: ((reason: string) => void) | undefined;
}

const cancelRequestMethod = '$cancelRequest';

/**
* Internal message type for canceling requests.
*/
export type CancelRequestMessage = NotificationMessage & { method: typeof cancelRequestMethod, params: string };

/**
* Checks if the given message is a cancel request.
* @param msg message to check
* @returns true if the message is a cancel request
*/
export function isCancelRequestNotification(msg: Message): msg is CancelRequestMessage {
return isNotificationMessage(msg) && msg.method === cancelRequestMethod;
}

/**
* Creates a cancel request message.
* @param receiver receiver of the cancel request
* @param msgId id of the request to cancel
* @returns new cancel request message
*/
export function createCancelRequestMessage(receiver: MessageParticipant, msgId: string): CancelRequestMessage {
return {
method: cancelRequestMethod,
receiver,
params: msgId
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ class DevtoolsComponent extends React.Component<Record<string, any>, DevtoolsCom
title={
'Number of added method handlers: \n' + (selectedExt?.info?.handlers ?? []).map(entry => ' ' + entry.method + ': ' + entry.count).join('\n')
}>{Array.from(selectedExt?.info?.handlers?.values() ?? []).length}</VSCodeBadge>
<span className='info-param-name'>Pending Req.:</span>
<VSCodeBadge className='ext-info-badge'
title='Number of pending (incoming + outgoing) requests.'>{selectedExt?.info?.pendingRequest ?? 0}</VSCodeBadge>

<span className='info-param-name'>Events:</span>
<VSCodeBadge className='ext-info-badge'>{selectedExt?.events.length ?? 0}</VSCodeBadge>
Expand Down
54 changes: 46 additions & 8 deletions packages/vscode-messenger-webview/src/messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
******************************************************************************/

import {
Cancelable,
createCancelRequestMessage,
isCancelRequestNotification,
isMessage,
isNotificationMessage, isRequestMessage, isResponseMessage, isWebviewIdMessageParticipant, JsonAny, Message, MessageParticipant, MessengerAPI,
NotificationHandler, NotificationMessage, NotificationType, PendingRequest, RequestHandler, RequestMessage, RequestType, ResponseError, ResponseMessage
Expand All @@ -16,6 +19,7 @@ export class Messenger implements MessengerAPI {
protected readonly handlerRegistry: Map<string, RequestHandler<unknown, unknown> | NotificationHandler<unknown>> = new Map();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected readonly requests: Map<string, PendingRequest<any>> = new Map();
protected readonly pendingHandlers: Map<string, Cancelable> = new Map();

protected readonly vscode: VsCodeApi;

Expand Down Expand Up @@ -63,21 +67,29 @@ export class Messenger implements MessengerAPI {
this.log(`View received Request message: ${msg.method} (id ${msg.id})`);
const handler = this.handlerRegistry.get(msg.method);
if (handler) {
const cancelable = new Cancelable();
try {
const result = await handler(msg.params, msg.sender!);
this.pendingHandlers.set(msg.id, cancelable);
const result = await handler(msg.params, msg.sender!, cancelable);
const response: ResponseMessage = {
id: msg.id,
receiver: msg.sender!,
result: result as JsonAny
};
this.vscode.postMessage(response);
} catch (error) {
if (cancelable.isCanceled()) {
// Don't report the error if request was canceled.
return;
}
const response: ResponseMessage = {
id: msg.id,
receiver: msg.sender!,
error: this.createResponseError(error)
};
this.vscode.postMessage(response);
} finally {
this.pendingHandlers.delete(msg.id);
}
} else {
this.log(`Received request with unknown method: ${msg.method}`, 'warn');
Expand All @@ -92,11 +104,20 @@ export class Messenger implements MessengerAPI {
}
} else if (isNotificationMessage(msg)) {
this.log(`View received Notification message: ${msg.method}`);
const handler = this.handlerRegistry.get(msg.method);
if (handler) {
handler(msg.params, msg.sender!);
} else if (msg.receiver.type !== 'broadcast') {
this.log(`Received notification with unknown method: ${msg.method}`, 'warn');
if (isCancelRequestNotification(msg)) {
const cancelable = this.pendingHandlers.get(msg.params);
if (cancelable) {
cancelable.cancel(`Request ${msg.params} was canceled by the sender.`);
} else {
this.log(`Received cancel notification for missing cancelable. ${msg.params}`, 'warn');
}
} else {
const handler = this.handlerRegistry.get(msg.method);
if (handler) {
handler(msg.params, msg.sender!, new Cancelable());
} else if (msg.receiver.type !== 'broadcast') {
this.log(`Received notification with unknown method: ${msg.method}`, 'warn');
}
}
} else if (isResponseMessage(msg)) {
this.log(`View received Response message: ${msg.id}`);
Expand Down Expand Up @@ -126,14 +147,26 @@ export class Messenger implements MessengerAPI {
}
}

sendRequest<P, R>(type: RequestType<P, R>, receiver: MessageParticipant, params?: P): Promise<R> {
sendRequest<P, R>(type: RequestType<P, R>, receiver: MessageParticipant, params?: P, cancelable?: Cancelable): Promise<R> {
if (receiver.type === 'broadcast') {
throw new Error('Only notification messages are allowed for broadcast.');
}

const msgId = this.createMsgId();
const pending = new PendingRequest<R>();
this.requests.set(msgId, pending);
if (cancelable) {
cancelable.onCancel = (reason) => {
// Send cancel message for pending request
this.vscode.postMessage(createCancelRequestMessage(receiver, msgId));
pending.reject(reason);
this.requests.delete(msgId);
};
pending.result.finally(() => {
// Request finished, nothing to do on cancel.
cancelable.onCancel = undefined;
});
}
const message: RequestMessage = {
id: msgId,
method: type.method,
Expand Down Expand Up @@ -164,6 +197,11 @@ export class Messenger implements MessengerAPI {
return 'req_' + this.nextMsgId++ + '_' + rand;
}

/**
* Log a message to the console.
* @param text The message to log.
* @param level The log level. Defaults to 'debug'.
*/
protected log(text: string, level: 'debug' | 'warn' | 'error' = 'debug'): void {
switch (level) {
case 'debug': {
Expand Down Expand Up @@ -193,7 +231,7 @@ function participantToString(participant: MessageParticipant): string {
switch (participant.type) {
case 'extension':
return 'host extension';
case 'webview':{
case 'webview': {
if (isWebviewIdMessageParticipant(participant)) {
return participant.webviewId;
} else if (participant.webviewType) {
Expand Down
76 changes: 58 additions & 18 deletions packages/vscode-messenger/src/messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

import * as vscode from 'vscode';
import {
equalParticipants, HOST_EXTENSION, isMessage, isNotificationMessage, isRequestMessage, isResponseMessage,
Cancelable,
createCancelRequestMessage,
equalParticipants, HOST_EXTENSION, isCancelRequestNotification, isMessage, isNotificationMessage, isRequestMessage, isResponseMessage,
isWebviewIdMessageParticipant, JsonAny, Message, MessageParticipant, MessengerAPI, NotificationHandler,
NotificationMessage, NotificationType, PendingRequest, RequestHandler, RequestMessage, RequestType, ResponseError,
ResponseMessage, WebviewIdMessageParticipant
Expand All @@ -25,6 +27,7 @@ export class Messenger implements MessengerAPI {

// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected readonly requests: Map<string, PendingRequest<any>> = new Map();
protected readonly pendingHandlers: Map<string, Cancelable> = new Map();

protected readonly eventListeners: Map<(event: MessengerEvent) => void, DiagnosticOptions | undefined> = new Map();

Expand Down Expand Up @@ -178,8 +181,10 @@ export class Messenger implements MessengerAPI {
return this.sendErrorResponse('Multiple matching request handlers', msg, responseCallback);
}

const cancelable = new Cancelable();
try {
const result = await filtered[0].handler(msg.params, msg.sender!);
this.pendingHandlers.set(msg.id, cancelable);
const result = await filtered[0].handler(msg.params, msg.sender!, cancelable);
const response: ResponseMessage = {
id: msg.id,
sender: HOST_EXTENSION,
Expand All @@ -191,7 +196,13 @@ export class Messenger implements MessengerAPI {
this.log(`Failed to send result message: ${participantToString(response.receiver)}`, 'error');
}
} catch (error) {
if (cancelable?.isCanceled()) {
// Don't report the error if request was canceled.
return;
}
this.sendErrorResponse(this.createResponseError(error), msg, responseCallback);
} finally {
this.pendingHandlers.delete(msg.id);
}
}

Expand Down Expand Up @@ -223,15 +234,26 @@ export class Messenger implements MessengerAPI {
*/
protected async processNotificationMessage(msg: NotificationMessage): Promise<void> {
this.log(`Host received Notification message: ${msg.method}`);
const regs = this.handlerRegistry.get(msg.method);
if (regs) {
const filtered = regs.filter(reg => !reg.sender || equalParticipants(reg.sender, msg.sender!));
if (filtered.length > 0) {
await Promise.all(filtered.map(reg => reg.handler(msg.params, msg.sender!)));
if (isCancelRequestNotification(msg)) {
const cancelable = this.pendingHandlers.get(msg.params);
if (cancelable) {
cancelable.cancel(`Request ${msg.params} was canceled by the sender.`);
} else {
this.log(`Received cancel notification for missing cancelable. ${msg.params}`);
}
} else {
const regs = this.handlerRegistry.get(msg.method);
if (regs) {
const filtered = regs.filter(reg => !reg.sender || equalParticipants(reg.sender, msg.sender!));
if (filtered.length > 0) {
// TODO No need to cancel a notification
await Promise.all(filtered.map(reg => reg.handler(msg.params, msg.sender!, new Cancelable())));
}
} else if (msg.receiver.type !== 'broadcast') {
this.log(`Received notification with unknown method: ${msg.method}`, 'warn');
}
} else if (msg.receiver.type !== 'broadcast') {
this.log(`Received notification with unknown method: ${msg.method}`, 'warn');
}

}

/**
Expand Down Expand Up @@ -295,36 +317,36 @@ export class Messenger implements MessengerAPI {
};
}

async sendRequest<P, R>(type: RequestType<P, R>, receiver: MessageParticipant, params?: P): Promise<R> {
async sendRequest<P, R>(type: RequestType<P, R>, receiver: MessageParticipant, params?: P, cancelable?: Cancelable): Promise<R> {
if (receiver.type === 'extension') {
throw new Error('Requests to other extensions are not supported yet.');
} else if (receiver.type === 'broadcast') {
throw new Error('Only notification messages are allowed for broadcast.');
} else if (receiver.type === 'webview') {
if (isWebviewIdMessageParticipant(receiver)) {
const receiverView = this.viewRegistry.get(receiver.webviewId);
if (receiverView) {
return this.sendRequestToWebview(type, receiver, params, receiverView.container);
return this.sendRequestToWebview(type, receiver, params, receiverView.container, cancelable);
} else {
return Promise.reject(new Error(`No webview with id ${receiver.webviewId} is registered.`));
}
} else if (receiver.webviewType) {
const receiverViews = this.viewTypeRegistry.get(receiver.webviewType);
if (receiverViews) {
// If there are multiple views, we make a race: the first view to return a result wins
const results = Array.from(receiverViews).map(view => this.sendRequestToWebview(type, receiver, params, view));
const results = Array.from(receiverViews).map(view => this.sendRequestToWebview(type, receiver, params, view, cancelable));
return Promise.race(results);
} else {
return Promise.reject(new Error(`No webview with type ${receiver.webviewType} is registered.`));
}
} else {
throw new Error('Unspecified webview receiver: neither webviewId nor webviewType was set.');
}
} else if (receiver.type === 'broadcast') {
throw new Error('Only notification messages are allowed for broadcast.');
}
throw new Error(`Invalid receiver: ${JSON.stringify(receiver)}`);
}

protected async sendRequestToWebview<P, R>(type: RequestType<P, R>, receiver: MessageParticipant, params: P, view: ViewContainer): Promise<R> {
protected async sendRequestToWebview<P, R>(type: RequestType<P, R>, receiver: MessageParticipant, params: P, view: ViewContainer, cancelable?: Cancelable): Promise<R> {
// Messages are only delivered if the webview is live (either visible or in the background with `retainContextWhenHidden`).
if (!view.visible && this.options.ignoreHiddenViews) {
return Promise.reject(new Error(`Skipped request for hidden view: ${participantToString(receiver)}`));
Expand All @@ -333,6 +355,24 @@ export class Messenger implements MessengerAPI {
const msgId = this.createMsgId();
const pendingRequest = new PendingRequest<R>();
this.requests.set(msgId, pendingRequest);
console.warn('Added request: ' + msgId);
if (cancelable) {
cancelable.onCancel = (reason) => {
// Send cancel message for pending request
view.webview.postMessage(createCancelRequestMessage(receiver, msgId))
.then((posted) => {
if (!posted) {
this.log(`Failed to send cancel message to view: ${participantToString(receiver)}`, 'error');
}
});
pendingRequest.reject(reason);
this.requests.delete(msgId);
};
pendingRequest.result.finally(() => {
// Request finished, nothing to do on cancel.
cancelable.onCancel = undefined;
});
}
const message: RequestMessage = {
id: msgId,
method: type.method,
Expand Down Expand Up @@ -441,8 +481,8 @@ export class Messenger implements MessengerAPI {
event.error = `Unknown message to ${msg.receiver}`;
}
this.eventListeners.forEach((options, listener) => {
if(isResponseMessage(msg)) {
if(!options?.withResponseData) {
if (isResponseMessage(msg)) {
if (!options?.withResponseData) {
// Clear response value if user don't want to expose it
event.parameter = undefined;
}
Expand All @@ -466,7 +506,7 @@ export class Messenger implements MessengerAPI {
extensionInfo: () => {
return {
diagnosticListeners: this.eventListeners.size,
pendingRequest: this.requests.size,
pendingRequest: this.requests.size + this.pendingHandlers.size,
handlers:
Array.from(this.handlerRegistry.entries()).map(
entry => { return { method: entry[0], count: entry[1].length }; }),
Expand Down

0 comments on commit cd7681d

Please sign in to comment.