You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Add outgoing and incoming message hooks to MessageStrategy - #1925
vscode-jsonrpc users can now change or observe the messages a connection sends, not only the ones it receives. The issue needs this to propagate W3C trace context (traceparent) to a StreamJsonRpc process: one side adds a top-level property to each outgoing message, and the other side reads it before the handler runs. Today MessageStrategy only sees received messages, and the writer used by createMessageConnection can't be wrapped without reimplementing the framing.
Design
This follows the second idea in Dirk Bäumer (@dbaeumer)'s comment: keep MessageStrategy and add two methods to it.
handleOutgoingMessage(message, next) gets every message the connection writes (requests, notifications and responses) before it reaches the MessageWriter. It calls next with the message to write, so it can add properties, replace the message or run code around the write.
handleIncomingMessage(message, next) gets every received message before it is dispatched. If provided, it is used instead of handleMessage.
handleMessage keeps its current meaning (received messages only), so existing strategies behave exactly as before. It is now optional, so a strategy can implement only the new methods, and MessageStrategy.is accepts a strategy with any of the three.
All writes in the connection go through one writeMessage helper, which is a plain messageWriter.write when no outgoing hook is set. I did not add a direction parameter to handleMessage, since calling existing strategies for outgoing messages would change what they see.
Eight tests in jsonrpc/src/node/test/connection.test.ts: a client adds traceparent to its requests and the server reads it in handleIncomingMessage and exposes it to the request handler through AsyncLocalStorage; a server sees its outgoing notification and response in order; handleIncomingMessage is used instead of handleMessage; an outgoing hook that awaits keeps the send order; with maxParallelism set, a response and the cancellations of a running and a queued request reach handleIncomingMessage; a hook that never calls next on a queued cancellation leaves the request to run and be answered; a throwing hook on a message that skips the queue is logged instead of escaping.
A short section with an example in jsonrpc/README.md.
Checks run: npm run compile:jsonrpc, the JSON-RPC Node.js tests and npm run lint in jsonrpc. The browser tests were not run locally.
A message strategy can now implement handleOutgoingMessage to see or
change every message before the connection writes it, and
handleIncomingMessage to handle received messages instead of
handleMessage. This lets callers add properties like a W3C trace context
to the messages they send and read them on the other side.
handleMessage keeps handling received messages only, so existing
strategies behave as before, and is now optional.
Closesmicrosoft#1757
handleOutgoingMessage now gets one message at a time in send order. A
hook that awaited before calling next let a later message reach the
writer first, so for example didChange could be written before didOpen.
Connections without the hook still write directly.
handleIncomingMessage now also gets the messages that skip the queue:
responses when maxParallelism is set and $/cancelRequest notifications
handled as soon as they are read. They still skip the queue, and
handleMessage still does not get them.
A synchronous exception from handleIncomingMessage on a message that
skips the queue escaped to the reader. The IPC reader calls the
connection from the process message event, so this could crash the
process. Such errors are now logged like asynchronous rejections.
When cancelUndispatched answered a queued request, the request was
removed from the queue before the hook ran, so a hook that never called
next left it unanswered. The request is now removed when next is called,
and only if it is still queued then. If it already started, its token is
cancelled instead, so it is still answered once.
Honor forwarded cancellation messages after hook interception
jsonrpc/src/common/connection.ts:851
This continuation ignores the message passed to next, so a hook that forwards a replacement cancellation targeting another request still cancels and answers the original request. The running-request path at lines 870–873 likewise cancels the originally captured token. Dispatch the forwarded message and resolve its cancellation target and response after interception, retaining the queued-versus-running recheck for delayed continuations.
…next
With the default connection strategy, a $/cancelRequest for a queued
request was added to knownCanceledRequests before the hook saw it, so
the request got a canceled token even when the hook dropped the
cancellation. The hook now gets every $/cancelRequest as soon as it is
read, and the existing cancellation code only runs when it calls next.
The copy that stays queued to clean up is not passed to the hook again.
The reason will be displayed to describe this comment to others. Learn more.
Thanks, this matches the direction we discussed in #1757, and the cancellation and ordering edge cases are well handled. My inline comments are mostly about what happens when a hook does not call next, plus some API and documentation follow-ups.
Could you also run the browser tests, or confirm that CI covers them? connection.ts is shared code.
The reason will be displayed to describe this comment to others. Learn more.
Two things about handleMessage:
Making it optional breaks typing for external code that calls strategy.handleMessage(...) directly when strictNullChecks is on. If that is acceptable for this release, please add a note to the changelog. Otherwise we could leave MessageStrategy as it is and add the new methods in a new interface that extends it.
handleMessage and handleIncomingMessage now mean almost the same thing; the only difference is whether a message that skips the queue is passed in. Could we mark handleMessage as @deprecated in favor of handleIncomingMessage? Then the API would have one incoming hook and one outgoing hook.
The reason will be displayed to describe this comment to others. Learn more.
Done in 8575cc1. handleMessage is now marked @deprecated in favor of handleIncomingMessage. I kept it optional and added a note under Compatibility in the history of the root README, in a new Next section. If you would rather avoid the typing change in this release, I can move the new methods to a separate interface.
The reason will be displayed to describe this comment to others. Learn more.
Since cancellations now depend on the hook, a hook that only forwards some messages and forgets $/cancelRequest quietly breaks cancellation. Please state this more directly here (something like "you must call next for $/cancelRequest, or the request is not canceled") and in the README section too.
The reason will be displayed to describe this comment to others. Learn more.
Adding a property means casting to Message (see the README example: { ...message, traceparent } as Message). Could we make this generic, or at least document that extra top-level properties reach the writer unchanged?
The reason will be displayed to describe this comment to others. Learn more.
Documented in 8575cc1, in the JSDoc and the README: top level properties reach the writer unchanged, and Message does not declare them, so the object passed to next needs the cast. I did not make it generic, since that would have to go through MessageStrategy and the connection options. Happy to look at it if you prefer that.
The reason will be displayed to describe this comment to others. Learn more.
Nit: the check "does the strategy have handleIncomingMessage?" is repeated here, in triggerMessageQueue and in callback. A small incomingHook() helper would remove the duplication.
The reason will be displayed to describe this comment to others. Learn more.
If handleOutgoingMessage resolves without calling next, the message is never written but writeMessage still resolves. For a request, sendRequest registers the response promise and waits forever. Calling next twice writes the message twice.
Could we wrap next so it only works once and check whether it was called after the hook settles? If it wasn't, we should reject the pending request (or at least log a warning). If dropping messages is meant to be allowed, the JSDoc should say what happens to requests.
Separately: messages still in this chain can reach messageWriter.write after the connection is disposed. Checking isDisposed() inside the chained callback would prevent that cheaply.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 8575cc1. next now only works once: any other call, including one after the hook settled, logs a warning and writes nothing. If the hook settles without calling next, writeMessage rejects, so sendRequest rejects the pending request with MessageWriteError through its existing catch, and the promise of a notification rejects too. The JSDoc and README describe this. The chained callback also checks isDisposed() first, so a queued message does not reach the hook or the writer after dispose. New tests cover all of these cases.
The reason will be displayed to describe this comment to others. Learn more.
This should keep the chain working after a hook throws or rejects, but no test checks it. Please add one where the hook fails on one message and the next message is still written.
The reason will be displayed to describe this comment to others. Learn more.
Added in 8575cc1. The test runs twice, with a hook that throws and with one that rejects, and checks that the failed send rejects and the next message is still written. It also passes without the other changes in that commit, so it guards the existing behavior.
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):
The reason will be displayed to describe this comment to others. Learn more.
Please add that a handleIncomingMessage hook must call next for $/cancelRequest, or cancellation stops working. Also mention what happens if handleOutgoingMessage doesn't call next.
next in handleOutgoingMessage now only works once. If the hook settles
without calling it, writeMessage rejects, so a request fails with
MessageWriteError instead of waiting forever and a notification rejects.
Queued messages no longer reach the hook or the writer after dispose.
handleMessage is deprecated in favor of handleIncomingMessage, the
requirement to call next for $/cancelRequest is documented, and the check
for handleIncomingMessage moved to an incomingHook helper.
All the inline comments are addressed in 8575cc1. I ran npm run test:node and npm run test:browser in jsonrpc on Windows and both pass, and .github/workflows/build.yml covers the browser tests through npm run test. jsonrpc, server and client compile, and eslint reports nothing on the two changed source files. I did not run the client integration tests.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because subtle interactions between asynchronous hooks and writer promises can mask transport failures and leave requests pending.
writeMessage only waited for the hook, so with a hook that called next
without returning its promise a failed write was lost and the request
waited forever.
One more in fbf0378: writeMessage now also waits for the promise of next. A hook that called next without returning it lost a failed write, so the request waited forever. The new test fails without this change. npm run test:node and npm run test:browser in jsonrpc pass.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because asynchronous hook handling can leave writer rejections unhandled, potentially terminating Node.js even when callers catch the send failure.
A hook that calls next without awaiting it and is still running when the write fails left the rejection without a handler, which can end the Node.js process even though the caller handles the send failure. Mark the promise as handled when it is created. The failure is still reported when the hook settles.
One more in 8a7946e: a failed write no longer becomes an unhandled rejection while a hook that did not await next is still running. The failure is still reported once the hook settles. The new test fails without this change. npm run test:node and npm run test:browser in jsonrpc pass.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because unpublished findings identify message-hook lifecycle and ordering risks, including writes after end or disposal and skipped hooks for canceled requests.
No open issues 🎉
Review effort: Balanced
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1757
vscode-jsonrpcusers can now change or observe the messages a connection sends, not only the ones it receives. The issue needs this to propagate W3C trace context (traceparent) to a StreamJsonRpc process: one side adds a top-level property to each outgoing message, and the other side reads it before the handler runs. TodayMessageStrategyonly sees received messages, and the writer used bycreateMessageConnectioncan't be wrapped without reimplementing the framing.Design
This follows the second idea in Dirk Bäumer (@dbaeumer)'s comment: keep
MessageStrategyand add two methods to it.handleOutgoingMessage(message, next)gets every message the connection writes (requests, notifications and responses) before it reaches theMessageWriter. It callsnextwith the message to write, so it can add properties, replace the message or run code around the write.handleIncomingMessage(message, next)gets every received message before it is dispatched. If provided, it is used instead ofhandleMessage.handleMessagekeeps its current meaning (received messages only), so existing strategies behave exactly as before. It is now optional, so a strategy can implement only the new methods, andMessageStrategy.isaccepts a strategy with any of the three.All writes in the connection go through one
writeMessagehelper, which is a plainmessageWriter.writewhen no outgoing hook is set. I did not add a direction parameter tohandleMessage, since calling existing strategies for outgoing messages would change what they see.Example
Tests and docs
jsonrpc/src/node/test/connection.test.ts: a client addstraceparentto its requests and the server reads it inhandleIncomingMessageand exposes it to the request handler throughAsyncLocalStorage; a server sees its outgoing notification and response in order;handleIncomingMessageis used instead ofhandleMessage; an outgoing hook that awaits keeps the send order; withmaxParallelismset, a response and the cancellations of a running and a queued request reachhandleIncomingMessage; a hook that never callsnexton a queued cancellation leaves the request to run and be answered; a throwing hook on a message that skips the queue is logged instead of escaping.jsonrpc/README.md.npm run compile:jsonrpc, the JSON-RPC Node.js tests andnpm run lintinjsonrpc. The browser tests were not run locally.