vscode-messenger icon indicating copy to clipboard operation
vscode-messenger copied to clipboard

[feature]: Support cancellation

Open dankeboy36 opened this issue 1 year ago • 5 comments

Thanks for the lib ❤️

Support cancellation. Maybe with AbortSignal. It's available in the browser and from Node.js 15+: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal#browser_compatibility

import { isAbortError } from 'abort-controller-x';
import { HOST_EXTENSION, RequestType } from 'vscode-messenger-common';
import { Messenger } from 'vscode-messenger-webview';

(async () => {
    try {
        const messenger: Messenger = undefined;
        type Params = Record<string, string>;
        const requestType: RequestType<Params, readonly unknown[]> = { method: 'long-running' };
        const myParams: Params = { foo: 'bar' };
        const abortController = new AbortController();
        const { signal } = abortController;
        const result = await messenger.sendRequest(requestType, HOST_EXTENSION, myParams, { signal });
        return result;
    } catch (err) {
        if (isAbortError(err)) {
            return [];
        }
        throw err;
    }
})();

dankeboy36 avatar Dec 07 '23 16:12 dankeboy36

@dankeboy36 Thanks for nice feature request! To make it clear: you just want to cancel/reject the pending request and not the handler that is running on extension side?

dhuebner avatar Dec 08 '23 13:12 dhuebner

not the handler that is running on extension side?

Preferably the handler at the extension side. Of course, it should be the responsibility of the extension developer to abort the actual work, but the cancellation signal (CancellationToken?) should arrive on the extension side. Otherwise, it's OK for the webviews but not for the entire VSIX. I do not know if it's possible with this lib. (Maybe, yes: https://github.com/microsoft/vscode-languageserver-node/issues/486.)

Here is my pseudo-example of a useful feature. I wrote it by hand. Maybe it does not even compile.

protocol.ts:

import type { RequestType } from 'vscode-messenger-common';

export interface SearchParams {
  readonly query: string;
}
export interface SearchResult = readonly string[]

export const searchType: RequestType<SearchParams, SearchResult> = {  method: 'search' };

extension.ts:

import { CancellationToken, ExtensionContext } from 'vscode';
import { Messenger } from 'vscode-messenger';
import { searchType } from '@my-app/protocol';

export function activate(context: ExtensionContext) {
  const messenger = new Messenger({ ignoreHiddenViews: false, debugLog: true });
  // create the webview panel
  // register the webview panel to the messenger
  // register other commands, views, and disposables

  context.subscriptions.push(messenger.onRequest(searchType, async (params, sender, cancellationToken: CancellationToken) => {
    const abortController = new AbortController();
    const { signal } = abortController;
    const toDispose = cancellationToken.onCancellationRequested(abortController => abortController.abort());
    const { execa } = await import('execa');
    try {
      // this is just a made-up example. imagine any service call that can be interrupted.
      // https://www.npmjs.com/package/execa#signal
      const { stdout } = await execa('grep', ['-rl', params.query], { signal }); 
      return stdout.split('\n');
    } catch (err) {
      if (isExecaError(err) && err.isCanceled) {
        return [];
      }
      throw err;
    } finally {
      toDispose.dispose();
    }
  }));
  return messenger.diagnosticApi();
}

App.tsx:

import { TextField } from '@vscode/webview-ui-toolkit';
import { VSCodeTextField } from '@vscode/webview-ui-toolkit/react';
import { searchType } from '@my-app/protocol';
import { useEffect, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { HOST_EXTENSION } from 'vscode-messenger-common';
import './App.css';
import { vscode } from './utilities/vscode';

function App() {
  const [query, setQuery] = useState<string>('')
  const [data, setData] = useState<string[]>([])

  useEffect(() => {
    const abortController = new window.AbortController();
    const signal = abortController.signal;
    async function runQuery() {
      const result = await vscode.messenger.sendRequest(searchType, HOST_EXTENSION, query, { signal });
      setData(result)
    }
    runQuery()
    return () => {
      if (signal && abortController.abort) {
        abortController.abort();
      }
    };
  }, [query])

  return (
    <main>
      <VSCodeTextField value={query} onInput={event => {
        if (event.target instanceof TextField) {
          setQuery(event.target.value);
        }
      }}/>
      <Virtuoso
        data={data}
        itemContent={(index, line) => (
          <div>
            <div>{line}</div>
          </div>
        )}
      />
    </main>
  );
}

export default App;

dankeboy36 avatar Dec 08 '23 15:12 dankeboy36

@dankeboy36 Just sending {signal} with a request will not work, we need a separate communication channel with a built-in NotificationType to cancel the running handler on the "other" side. I will draft an API for that next days, stay tuned.

dhuebner avatar Dec 12 '23 09:12 dhuebner

@dankeboy36

Request handler now has an additional parameter cancelIndicator: CancelIndicator

export type RequestHandler<P, R> =
    (params: P, sender: MessageParticipant, cancelIndicator: CancelIndicator) => HandlerResult<R>;

CancelIndicator can be asked for the cancelation state or one can set the callback function onCancel

export interface CancelIndicator {
    isCanceled(): boolean;
    onCancel: ((reason: string) => void) | undefined;
}

On the sender side sendRequest method now allows to pass a Cancelable instance which allows to call cancel(reason: string) . Calling cancel will then activate the CancelIndicator. The internal pending request will be rejected.

Simple timeout example:

const cancelable = new vscode_messenger_common.Cancelable();
setTimeout(() => cancelable.cancel('Timeout: 1 sec'), 1000);
const colors = await messenger.sendRequest(
    { method: 'availableColor' },
    { type: 'extension' },
    '', 
    cancelable
);

The code is currently on a branch dhuebner/canelPendingRequest-16, do you have a possibility to try it out locally?

dhuebner avatar Dec 13 '23 13:12 dhuebner

Absolutely, I have been monitoring your changes. Very nice, thank you!

Is there a reason this lib invents its own Cancelable type instead of AbortSignal?

dankeboy36 avatar Dec 13 '23 14:12 dankeboy36