diff --git a/README.md b/README.md index 5ea87fcb3..5729b65c7 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the repository map, focused build a ## History +## Next + +### Compatibility + +- JSON-RPC: `handleMessage` is now optional on `MessageStrategy` and deprecated in favor of `handleIncomingMessage`. Code that calls `strategy.handleMessage(...)` directly needs a check for `undefined` when `strictNullChecks` is on. + ## 3.18.5 Protocol, 10.0.0 JSON-RPC, 11.0.0 Client and 11.0.0 Server. [Fixed issues](https://github.com/microsoft/vscode-languageserver-node/issues?q=is%3Aissue%20is%3Aclosed%20reason%3Acompleted%20milestone%3A%223.18.5%22%20sort%3Aupdated-desc) | [Merged pull requests](https://github.com/microsoft/vscode-languageserver-node/pulls?q=is%3Apr%20is%3Amerged%20milestone%3A%223.18.5%22%20sort%3Aupdated-desc) diff --git a/jsonrpc/README.md b/jsonrpc/README.md index 4ffbb87d1..77e05cd4b 100644 --- a/jsonrpc/README.md +++ b/jsonrpc/README.md @@ -50,6 +50,28 @@ connection.onNotification(notification, (param: string) => { connection.listen(); ``` +Both sides can pass a `messageStrategy` in the connection options to work with the messages of the connection. `handleOutgoingMessage` gets every message before it is written, and `handleIncomingMessage` gets every received message before it is dispatched. Outgoing messages are passed one at a time in send order, so a `handleOutgoingMessage` that awaits before calling `next` delays the messages after it instead of reordering them. For example, to send a trace context along with each message and restore it while the other side handles the message (`getTraceparent` and `runWithTraceparent` stand for your own tracing code): + +```ts +let connection = rpc.createMessageConnection(reader, writer, undefined, { + messageStrategy: { + handleOutgoingMessage(message, next) { + return next({ ...message, traceparent: getTraceparent() } as rpc.Message); + }, + handleIncomingMessage(message, next) { + const traceparent = (message as { traceparent?: string }).traceparent; + return runWithTraceparent(traceparent, () => next(message)); + } + } +}); +``` + +A `handleIncomingMessage` hook must call `next` for `$/cancelRequest`, or the request is not canceled. + +A `handleOutgoingMessage` hook must call `next` exactly once before its promise settles. If it does not call `next`, the message is not written and sending it fails: a request is rejected with `ErrorCodes.MessageWriteError` and the promise of a notification is rejected. Any other call to `next` logs a warning and writes nothing. + +Properties added at the top level of a message reach the writer unchanged. `Message` does not declare them, which is why the example casts the object passed to `next`. + # History For the history please see the [main repository](https://github.com/Microsoft/vscode-languageserver-node/blob/master/README.md) diff --git a/jsonrpc/src/common/connection.ts b/jsonrpc/src/common/connection.ts index 06bdd5d6c..e7d0b495f 100644 --- a/jsonrpc/src/common/connection.ts +++ b/jsonrpc/src/common/connection.ts @@ -474,13 +474,57 @@ export namespace CancellationStrategy { } export interface MessageStrategy { - handleMessage(message: Message, next: (message: Message) => NotificationResult): NotificationResult; + /** + * Handles a received message before it is dispatched. Not called if + * `handleIncomingMessage` is provided. + * + * @deprecated Use `handleIncomingMessage` instead. + * + * @param message The received message. + * @param next Dispatches the message. + */ + handleMessage?(message: Message, next: (message: Message) => NotificationResult): NotificationResult; + + /** + * Handles a received message before it is dispatched. If provided, it is + * used instead of `handleMessage`. Unlike `handleMessage`, it also gets the + * messages that skip the message queue: responses when `maxParallelism` is + * set, and `$/cancelRequest` notifications as soon as they are read. + * + * You must call `next` for `$/cancelRequest`, or the request is not + * canceled. + * + * @param message The received message. + * @param next Dispatches the message. + */ + handleIncomingMessage?(message: Message, next: (message: Message) => NotificationResult): NotificationResult; + + /** + * Handles a message before it is written, for example to add a property + * like a trace context to it. Messages are passed one at a time in send + * order: the next one is passed when the promise returned for the previous + * one settles. + * + * Properties added at the top level of a message reach the writer + * unchanged. `Message` does not declare them, so the object passed to + * `next` has to be cast to `Message`. + * + * You must call `next` exactly once before the returned promise settles. + * If it is not called, the message is not written and sending it fails: a + * request is rejected with `ErrorCodes.MessageWriteError` and the promise + * of a notification is rejected. Any other call to `next` logs a warning + * and writes nothing. + * + * @param message The message to send. + * @param next Writes the message. Pass it the message to write. + */ + handleOutgoingMessage?(message: Message, next: (message: Message) => Promise): Promise; } export namespace MessageStrategy { export function is(value: any): value is MessageStrategy { const candidate: MessageStrategy = value; - return candidate && Is.func(candidate.handleMessage); + return candidate && (Is.func(candidate.handleMessage) || Is.func(candidate.handleIncomingMessage) || Is.func(candidate.handleOutgoingMessage)); } } @@ -708,7 +752,7 @@ export function createMessageConnection(messageReader: MessageReader, messageWri } else { // If we have limited parallelism we resolve responses to avoid // dead locks. - handleResponse(message); + handleUnqueuedMessage(message, handleMessage); } } else { queue.set(createNotificationQueueKey(), message); @@ -735,7 +779,11 @@ export function createMessageConnection(messageReader: MessageReader, messageWri try { inFlight++; const messageStrategy = options?.messageStrategy; - if (MessageStrategy.is(messageStrategy)) { + const handleIncoming = incomingHook(); + if (handleIncoming !== undefined) { + // A queued cancellation already went through the hook when it was read. + result = isCancelNotification(message) ? handleMessage(message) : handleIncoming(message, handleMessage); + } else if (messageStrategy !== undefined && Is.func(messageStrategy.handleMessage)) { result = messageStrategy.handleMessage(message, handleMessage); } else { result = handleMessage(message); @@ -775,11 +823,90 @@ export function createMessageConnection(messageReader: MessageReader, messageWri } } + function incomingHook(): MessageStrategy['handleIncomingMessage'] { + const messageStrategy = options?.messageStrategy; + if (messageStrategy === undefined || !Is.func(messageStrategy.handleIncomingMessage)) { + return undefined; + } + return (message, next) => messageStrategy.handleIncomingMessage!(message, next); + } + + // Messages that skip the queue go through `handleIncomingMessage`. + // `handleMessage` keeps getting queued messages only. Errors are logged, + // since the reader can call us from an event handler. + function handleUnqueuedMessage(message: Message, handle: (message: Message) => NotificationResult): void { + const handleIncoming = incomingHook(); + const logError = (error: any) => logger.error(`Processing message failed: ${error}`); + try { + const result = handleIncoming !== undefined ? handleIncoming(message, handle) : handle(message); + if (result instanceof Promise) { + result.catch(logError); + } + } catch (error) { + logError(error); + } + } + + let lastOutgoingMessage: Promise = Promise.resolve(); + function writeMessage(message: Message): Promise { + const messageStrategy = options?.messageStrategy; + if (messageStrategy === undefined || !Is.func(messageStrategy.handleOutgoingMessage)) { + return messageWriter.write(message); + } + // Pass one message at a time to the hook. Otherwise a hook that awaits + // before calling next lets a later message reach the writer first. + const result = lastOutgoingMessage.then(async () => { + if (isDisposed()) { + throw new ConnectionError(ConnectionErrors.Disposed, 'Connection is disposed.'); + } + let written: Promise | undefined; + let settled = false; + const next = (outgoing: Message): Promise => { + if (written !== undefined || settled) { + logger.warn(`handleOutgoingMessage must call next exactly once before it settles.`); + return written ?? Promise.resolve(); + } + written = messageWriter.write(outgoing); + // The rejection is reported once the hook settles. Until then the + // hook may not be listening, which would be an unhandled rejection. + written.catch(() => undefined); + return written; + }; + try { + await messageStrategy.handleOutgoingMessage!(message, next); + } finally { + settled = true; + } + if (written === undefined) { + throw new Error(`handleOutgoingMessage did not call next, so the message was not written.`); + } + // A hook may not return the promise of next, so a failed write has to + // be reported here or the request would wait forever. + await written; + }); + lastOutgoingMessage = result.catch(() => undefined); + return result; + } + const callback: DataCallback = (message) => { + // The hook gets cancellations as soon as they are read and can drop them, + // so nothing is canceled or remembered before it calls next. + if (isCancelNotification(message) && incomingHook() !== undefined) { + handleUnqueuedMessage(message, receiveMessage); + } else { + receiveMessage(message); + } + }; + + function isCancelNotification(message: Message): message is NotificationMessage { + return Message.isNotification(message) && message.method === CancelNotification.type.method; + } + + function receiveMessage(message: Message): void { try { // We have received a cancellation message. Check if the message is still in the queue // and cancel it if allowed to do so. - if (Message.isNotification(message) && message.method === CancelNotification.type.method) { + if (isCancelNotification(message)) { const cancelId = (message.params as CancelParams).id; const key = createRequestQueueKey(cancelId); const toCancel = messageQueue.get(key); @@ -791,7 +918,7 @@ export function createMessageConnection(messageReader: MessageReader, messageWri requestTokens.delete(cancelId); response.id = toCancel.id; traceSendingResponse(response, message.method, Date.now()); - messageWriter.write(response).catch(() => logger.error(`Sending response for canceled message failed.`)); + writeMessage(response).catch(() => logger.error(`Sending response for canceled message failed.`)); return; } } @@ -811,7 +938,7 @@ export function createMessageConnection(messageReader: MessageReader, messageWri } finally { triggerMessageQueue(); } - }; + } async function handleRequest(requestMessage: RequestMessage): Promise { if (isDisposed()) { @@ -831,7 +958,7 @@ export function createMessageConnection(messageReader: MessageReader, messageWri message.result = resultOrError === undefined ? null : resultOrError; } traceSendingResponse(message, method, startTime); - return messageWriter.write(message); + return writeMessage(message); } function replyError(error: ResponseError, method: string, startTime: number): Promise { const message: ResponseMessage = { @@ -840,7 +967,7 @@ export function createMessageConnection(messageReader: MessageReader, messageWri error: error.toJson() }; traceSendingResponse(message, method, startTime); - return messageWriter.write(message); + return writeMessage(message); } traceReceivedRequest(requestMessage); @@ -1313,7 +1440,7 @@ export function createMessageConnection(messageReader: MessageReader, messageWri params: messageParams }; traceSendingNotification(notificationMessage); - return messageWriter.write(notificationMessage).catch((error) => { + return writeMessage(notificationMessage).catch((error) => { logger.error(`Sending notification failed.`); throw error; }); @@ -1460,7 +1587,7 @@ export function createMessageConnection(messageReader: MessageReader, messageWri const responsePromise: ResponsePromise | null = { method: method, timerStart: Date.now(), resolve: resolveWithCleanup, reject: rejectWithCleanup }; try { responsePromises.set(id, responsePromise); - await messageWriter.write(requestMessage); + await writeMessage(requestMessage); if (tokenWasCancelled) { sendCancellation(connection, id); } diff --git a/jsonrpc/src/node/test/connection.test.ts b/jsonrpc/src/node/test/connection.test.ts index 3de33d49a..d017a4414 100644 --- a/jsonrpc/src/node/test/connection.test.ts +++ b/jsonrpc/src/node/test/connection.test.ts @@ -12,7 +12,7 @@ import { inherits } from 'util'; import { AsyncLocalStorage } from 'async_hooks'; import { EventEmitter } from 'events'; -import { CancellationTokenSource, RequestType, RequestType3, ResponseError, NotificationType, NotificationType2, ErrorCodes, Trace, Tracer } from '../main'; +import { CancellationTokenSource, RequestType, RequestType0, RequestType3, ResponseError, NotificationType, NotificationType2, ErrorCodes, Trace, Tracer } from '../main'; import * as hostConnection from '../main'; import { getCustomCancellationStrategy } from './customCancellationStrategy'; @@ -885,6 +885,509 @@ suite('Connection', () => { }); }); + test('Message strategy handles outgoing and incoming messages', async () => { + const type = new RequestType('test/handleSingleRequest'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const asyncLocalStorage = new AsyncLocalStorage(); + + const server = hostConnection.createMessageConnection(duplexStream2, duplexStream1, hostConnection.NullLogger, { + messageStrategy: { + handleIncomingMessage(message, next) { + const traceparent = (message as { traceparent?: string }).traceparent; + return asyncLocalStorage.run(traceparent, () => next(message)); + } + } + }); + server.onRequest(type, () => asyncLocalStorage.getStore()); + server.listen(); + + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger, { + messageStrategy: { + handleOutgoingMessage(message, next) { + return next({ ...message, traceparent: '00-trace-span-01' } as hostConnection.Message); + } + } + }); + client.listen(); + + assert.strictEqual(await client.sendRequest(type, 0), '00-trace-span-01'); + }); + + test('Message strategy handles outgoing responses and notifications', async () => { + const type = new RequestType('test/handleSingleRequest'); + const notificationType = new NotificationType('test/notification'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const sent: string[] = []; + const server = hostConnection.createMessageConnection(duplexStream2, duplexStream1, hostConnection.NullLogger, { + messageStrategy: { + handleOutgoingMessage(message, next) { + sent.push(hostConnection.Message.isNotification(message) ? message.method : 'response'); + return next(message); + } + } + }); + server.onRequest(type, async (param) => { + await server.sendNotification(notificationType, param); + return param + 1; + }); + server.listen(); + + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger); + client.listen(); + + assert.strictEqual(await client.sendRequest(type, 1), 2); + assert.deepStrictEqual(sent, ['test/notification', 'response']); + }); + + test('Message strategy prefers handleIncomingMessage over handleMessage', async () => { + const type = new RequestType('test/handleSingleRequest'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const calls: string[] = []; + const server = hostConnection.createMessageConnection(duplexStream2, duplexStream1, hostConnection.NullLogger, { + messageStrategy: { + handleMessage(message, next) { + calls.push('handleMessage'); + return next(message); + }, + handleIncomingMessage(message, next) { + calls.push('handleIncomingMessage'); + return next(message); + } + } + }); + server.onRequest(type, (param) => param + 1); + server.listen(); + + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger); + client.listen(); + + assert.strictEqual(await client.sendRequest(type, 1), 2); + assert.deepStrictEqual(calls, ['handleIncomingMessage']); + }); + + test('Message strategy keeps the send order when handleOutgoingMessage awaits', async () => { + const didOpen = new NotificationType('test/didOpen'); + const didChange = new NotificationType('test/didChange'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const received: string[] = []; + const server = hostConnection.createMessageConnection(duplexStream2, duplexStream1, hostConnection.NullLogger); + const bothReceived = new Promise((resolve) => { + server.onNotification((method) => { + received.push(method); + if (received.length === 2) { + resolve(); + } + }); + }); + server.listen(); + + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger, { + messageStrategy: { + async handleOutgoingMessage(message, next) { + if (hostConnection.Message.isNotification(message) && message.method === didOpen.method) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + return next(message); + } + } + }); + client.listen(); + + void client.sendNotification(didOpen, 1); + void client.sendNotification(didChange, 2); + await bothReceived; + assert.deepStrictEqual(received, [didOpen.method, didChange.method]); + }); + + test('Message strategy fails a request when handleOutgoingMessage does not call next', async () => { + const type = new RequestType0('test/request'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger, { + messageStrategy: { + async handleOutgoingMessage() { + } + } + }); + client.listen(); + + await assert.rejects(client.sendRequest(type), (error: ResponseError) => error.code === ErrorCodes.MessageWriteError); + }); + + test('Message strategy fails a notification when handleOutgoingMessage does not call next', async () => { + const type = new NotificationType('test/notification'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger, { + messageStrategy: { + async handleOutgoingMessage() { + } + } + }); + client.listen(); + + await assert.rejects(client.sendNotification(type, 1)); + }); + + test('Message strategy fails a request when a write fails and the hook does not return next', async () => { + class FailingWriter extends hostConnection.AbstractMessageWriter implements hostConnection.MessageWriter { + public write(): Promise { + return Promise.reject(new Error('write failed')); + } + public end(): void { + } + } + const type = new RequestType0('test/request'); + const duplexStream = new TestDuplex('ds1'); + + const client = hostConnection.createMessageConnection(new hostConnection.StreamMessageReader(duplexStream), new FailingWriter(), hostConnection.NullLogger, { + messageStrategy: { + async handleOutgoingMessage(message, next) { + void next(message); + } + } + }); + client.listen(); + + await assert.rejects(client.sendRequest(type), (error: ResponseError) => error.code === ErrorCodes.MessageWriteError); + }); + + test('Message strategy does not leave a failed write unhandled while the hook runs', async () => { + class FailingWriter extends hostConnection.AbstractMessageWriter implements hostConnection.MessageWriter { + public write(): Promise { + return Promise.reject(new Error('write failed')); + } + public end(): void { + } + } + const type = new RequestType0('test/request'); + const duplexStream = new TestDuplex('ds1'); + + const unhandled: unknown[] = []; + const listeners = process.listeners('unhandledRejection'); + process.removeAllListeners('unhandledRejection'); + process.on('unhandledRejection', (reason) => unhandled.push(reason)); + try { + const client = hostConnection.createMessageConnection(new hostConnection.StreamMessageReader(duplexStream), new FailingWriter(), hostConnection.NullLogger, { + messageStrategy: { + async handleOutgoingMessage(message, next) { + void next(message); + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + }); + client.listen(); + + await assert.rejects(client.sendRequest(type), (error: ResponseError) => error.code === ErrorCodes.MessageWriteError); + assert.deepStrictEqual(unhandled, []); + } finally { + process.removeAllListeners('unhandledRejection'); + listeners.forEach(listener => process.on('unhandledRejection', listener)); + } + }); + + test('Message strategy writes a message once when handleOutgoingMessage calls next twice', async () => { + const first = new NotificationType('test/first'); + const second = new NotificationType('test/second'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const received: string[] = []; + const server = hostConnection.createMessageConnection(duplexStream2, duplexStream1, hostConnection.NullLogger); + const secondReceived = new Promise((resolve) => { + server.onNotification((method) => { + received.push(method); + if (method === second.method) { + resolve(); + } + }); + }); + server.listen(); + + const warnings: string[] = []; + const logger: hostConnection.Logger = { ...hostConnection.NullLogger, warn: (message) => warnings.push(message) }; + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, logger, { + messageStrategy: { + async handleOutgoingMessage(message, next) { + await next(message); + if (hostConnection.Message.isNotification(message) && message.method === first.method) { + await next(message); + } + } + } + }); + client.listen(); + + void client.sendNotification(first, 1); + void client.sendNotification(second, 2); + await secondReceived; + assert.deepStrictEqual(received, [first.method, second.method]); + assert.strictEqual(warnings.length, 1); + }); + + for (const failure of ['throws', 'rejects']) { + test(`Message strategy keeps writing after handleOutgoingMessage ${failure}`, async () => { + const failing = new NotificationType('test/failing'); + const after = new NotificationType('test/after'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const server = hostConnection.createMessageConnection(duplexStream2, duplexStream1, hostConnection.NullLogger); + const received = new Promise((resolve) => { + server.onNotification((method) => resolve(method)); + }); + server.listen(); + + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger, { + messageStrategy: { + handleOutgoingMessage(message, next) { + if (hostConnection.Message.isNotification(message) && message.method === failing.method) { + if (failure === 'throws') { + throw new Error('hook failed'); + } + return Promise.reject(new Error('hook failed')); + } + return next(message); + } + } + }); + client.listen(); + + await assert.rejects(client.sendNotification(failing, 1), /hook failed/); + await client.sendNotification(after, 2); + assert.strictEqual(await received, after.method); + }); + } + + test('Message strategy does not handle queued outgoing messages after dispose', async () => { + const first = new NotificationType('test/first'); + const second = new NotificationType('test/second'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + let release!: () => void; + const released = new Promise((resolve) => release = resolve); + const handled: string[] = []; + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger, { + messageStrategy: { + async handleOutgoingMessage(message, next) { + handled.push((message as hostConnection.NotificationMessage).method); + await released; + return next(message); + } + } + }); + client.listen(); + + const firstSent = client.sendNotification(first, 1); + const secondSent = client.sendNotification(second, 2); + await new Promise(resolve => setTimeout(resolve, 0)); + client.dispose(); + release(); + + await firstSent.catch(() => undefined); + await assert.rejects(secondSent, (error: hostConnection.ConnectionError) => error.code === hostConnection.ConnectionErrors.Disposed); + assert.deepStrictEqual(handled, [first.method]); + }); + + test('Message strategy handles responses that skip the queue', async () => { + const type = new RequestType('test/handleSingleRequest'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const server = hostConnection.createMessageConnection(duplexStream2, duplexStream1, hostConnection.NullLogger); + server.onRequest(type, (param) => param + 1); + server.listen(); + + // With maxParallelism set, responses are handled as soon as they are read. + const received: hostConnection.Message[] = []; + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger, { + maxParallelism: 1, + messageStrategy: { + handleIncomingMessage(message, next) { + received.push(message); + return next(message); + } + } + }); + client.listen(); + + assert.strictEqual(await client.sendRequest(type, 1), 2); + assert.deepStrictEqual(received, [{ jsonrpc: '2.0', id: 0, result: 2 }]); + }); + + test('Message strategy handles cancellations that skip the queue', async () => { + const type = new RequestType('test/handleSingleRequest'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const received: string[] = []; + const server = hostConnection.createMessageConnection(duplexStream2, duplexStream1, hostConnection.NullLogger, { + maxParallelism: 1, + connectionStrategy: { + cancelUndispatched: (message) => ({ jsonrpc: '2.0', id: (message as hostConnection.RequestMessage).id, result: 0 }) + }, + messageStrategy: { + handleIncomingMessage(message, next) { + received.push((message as hostConnection.RequestMessage).method); + return next(message); + } + } + }); + let started!: () => void; + const firstStarted = new Promise(resolve => started = resolve); + server.onRequest(type, (param, token) => { + started(); + return new Promise(resolve => token.onCancellationRequested(() => resolve(param))); + }); + server.listen(); + + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger); + client.listen(); + + // The first request runs while the second one waits in the queue. Both + // cancellations are handled as soon as they are read. + const firstSource = new CancellationTokenSource(); + const first = client.sendRequest(type, 1, firstSource.token); + await firstStarted; + const secondSource = new CancellationTokenSource(); + const second = client.sendRequest(type, 2, secondSource.token); + secondSource.cancel(); + assert.strictEqual(await second, 0); + firstSource.cancel(); + assert.strictEqual(await first, 1); + assert.deepStrictEqual(received, [type.method, '$/cancelRequest', '$/cancelRequest']); + }); + + test('Message strategy can ignore the cancellation of a queued request', async () => { + const type = new RequestType('test/handleSingleRequest'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + let dropped!: () => void; + const cancelDropped = new Promise(resolve => dropped = resolve); + const server = hostConnection.createMessageConnection(duplexStream2, duplexStream1, hostConnection.NullLogger, { + maxParallelism: 1, + connectionStrategy: { + cancelUndispatched: (message) => ({ jsonrpc: '2.0', id: (message as hostConnection.RequestMessage).id, result: 0 }) + }, + messageStrategy: { + handleIncomingMessage(message, next) { + // Cancellations are dropped without calling next. + if (hostConnection.Message.isNotification(message) && message.method === '$/cancelRequest') { + dropped(); + return; + } + return next(message); + } + } + }); + let started!: () => void; + const firstStarted = new Promise(resolve => started = resolve); + let release!: () => void; + const released = new Promise(resolve => release = resolve); + server.onRequest(type, async (param) => { + if (param === 1) { + started(); + await released; + } + return param; + }); + server.listen(); + + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger); + client.listen(); + + // The second request waits in the queue while the first one runs. + const first = client.sendRequest(type, 1); + await firstStarted; + const source = new CancellationTokenSource(); + const second = client.sendRequest(type, 2, source.token); + source.cancel(); + await cancelDropped; + release(); + assert.strictEqual(await first, 1); + assert.strictEqual(await second, 2); + }); + + test('Message strategy can ignore the cancellation of a queued request without cancelUndispatched', async () => { + const type = new RequestType('test/handleSingleRequest'); + const duplexStream1 = new TestDuplex('ds1'); + const duplexStream2 = new TestDuplex('ds2'); + + const server = hostConnection.createMessageConnection(duplexStream2, duplexStream1, hostConnection.NullLogger, { + maxParallelism: 1, + messageStrategy: { + handleIncomingMessage(message, next) { + // The cancellation of the queued request (id 1) is dropped. + if (hostConnection.Message.isNotification(message) && message.method === '$/cancelRequest' && (message.params as { id: number }).id === 1) { + return; + } + return next(message); + } + } + }); + let started!: () => void; + const firstStarted = new Promise(resolve => started = resolve); + server.onRequest(type, (param, token) => { + if (param === 1) { + started(); + return new Promise(resolve => token.onCancellationRequested(() => resolve(true))); + } + return token.isCancellationRequested; + }); + server.listen(); + + const client = hostConnection.createMessageConnection(duplexStream1, duplexStream2, hostConnection.NullLogger); + client.listen(); + + // The second request waits in the queue while the first one runs. The + // first one ends on its own cancellation, which is read after the + // dropped one. + const firstSource = new CancellationTokenSource(); + const first = client.sendRequest(type, 1, firstSource.token); + await firstStarted; + const secondSource = new CancellationTokenSource(); + const second = client.sendRequest(type, 2, secondSource.token); + secondSource.cancel(); + firstSource.cancel(); + assert.strictEqual(await first, true); + assert.strictEqual(await second, false); + }); + + test('Message strategy errors on messages that skip the queue are logged', () => { + const proc = new EventEmitter(); + const errors: string[] = []; + const logger: hostConnection.Logger = { + ...hostConnection.NullLogger, + error: (message: string) => errors.push(message) + }; + const connection = hostConnection.createMessageConnection(new hostConnection.IPCMessageReader(proc as NodeJS.Process), new hostConnection.IPCMessageWriter(proc as NodeJS.Process), logger, { + maxParallelism: 1, + messageStrategy: { + handleIncomingMessage() { + throw new Error('hook failed'); + } + } + }); + connection.listen(); + + // The IPC reader calls the connection from the process message event, + // so an error escaping there would be an uncaught exception. + assert.doesNotThrow(() => proc.emit('message', { jsonrpc: '2.0', id: 0, result: 1 })); + assert.deepStrictEqual(errors, ['Processing message failed: Error: hook failed']); + }); + test('Message ports', async () => { const type = new RequestType('test/handleSingleRequest'); const messageChannel = new MessageChannel();