fix: handle cancellation for request ID 0 (#2654)
Co-authored-by: Konstantin Konstantinov <konstantin@mach5technology.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
This commit is contained in:
parent
27a94b5d1c
commit
03842cd9ca
2 changed files with 85 additions and 41 deletions
|
|
@ -724,7 +724,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
|
|||
}
|
||||
|
||||
private async _oncancel(notification: CancelledNotification): Promise<void> {
|
||||
if (!notification.params.requestId) {
|
||||
// `requestId` is optional on the 2025-era wire schema. Absent is the
|
||||
// only thing that means "no id": `0` and `''` are legal request ids.
|
||||
if (notification.params.requestId === undefined) {
|
||||
return;
|
||||
}
|
||||
// Handle request cancellation
|
||||
|
|
@ -1611,7 +1613,11 @@ export abstract class Protocol<ContextT extends BaseContext> {
|
|||
const debouncedMethods = this._options?.debouncedNotificationMethods ?? [];
|
||||
// A notification can only be debounced if it's in the list AND it's "simple"
|
||||
// (i.e., has no parameters and no related request ID that could be lost).
|
||||
const canDebounce = debouncedMethods.includes(notification.method) && !notification.params && !options?.relatedRequestId;
|
||||
// Absent is the only thing that means "no id" here too: `0` and `''` are
|
||||
// legal request ids, and the pending set is keyed by method alone, so
|
||||
// treating them as absent lets a related notification be coalesced away.
|
||||
const canDebounce =
|
||||
debouncedMethods.includes(notification.method) && !notification.params && options?.relatedRequestId === undefined;
|
||||
|
||||
if (canDebounce) {
|
||||
// If a notification of this type is already scheduled, do nothing.
|
||||
|
|
|
|||
|
|
@ -656,6 +656,27 @@ describe('protocol tests', () => {
|
|||
expect(sendSpy).toHaveBeenCalledWith(expect.any(Object), { relatedRequestId: 'req-2' });
|
||||
});
|
||||
|
||||
// Same-tick coverage for every legal request id: the pending set is keyed
|
||||
// by method alone, so a related notification that wrongly passes the
|
||||
// debounce gate is coalesced away entirely. Awaiting between the two
|
||||
// sends would flush the microtask and hide that, so they are fired in one
|
||||
// tick. `0` and `''` are the ids a truthiness guard swallows.
|
||||
test.each([0, 123, '', 'req-1'])('should NOT coalesce same-tick notifications related to requestId %j', async relatedRequestId => {
|
||||
// ARRANGE
|
||||
protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced_with_options'] });
|
||||
await protocol.connect(transport);
|
||||
|
||||
// ACT — two related notifications in the same tick, no await between
|
||||
void protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId });
|
||||
void protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId });
|
||||
await flushMicrotasks();
|
||||
|
||||
// ASSERT — both go out, each still carrying its related request id
|
||||
expect(sendSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendSpy).toHaveBeenNthCalledWith(1, expect.any(Object), { relatedRequestId });
|
||||
expect(sendSpy).toHaveBeenNthCalledWith(2, expect.any(Object), { relatedRequestId });
|
||||
});
|
||||
|
||||
it('should clear pending debounced notifications on connection close', async () => {
|
||||
// ARRANGE
|
||||
protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced'] });
|
||||
|
|
@ -775,50 +796,55 @@ describe('protocol tests', () => {
|
|||
});
|
||||
|
||||
describe('notifications/cancelled behavior', () => {
|
||||
test('should abort request handler when notifications/cancelled is received', async () => {
|
||||
await protocol.connect(transport);
|
||||
// Every legal JSON-RPC request id must cancel, including the ones a
|
||||
// truthiness guard swallows: `0` (the first id every peer assigns,
|
||||
// since the counter is zero-based) and the empty string.
|
||||
test.each([0, 123, '', 'req-1'])(
|
||||
'should abort request handler when notifications/cancelled carries requestId %j',
|
||||
async requestId => {
|
||||
await protocol.connect(transport);
|
||||
|
||||
// Set up a request handler that checks if it was aborted
|
||||
let wasAborted = false;
|
||||
protocol.setRequestHandler('ping', async (_request, ctx) => {
|
||||
// Simulate a long-running operation
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
wasAborted = ctx.mcpReq.signal.aborted;
|
||||
return {};
|
||||
});
|
||||
|
||||
// Simulate an incoming request
|
||||
const requestId = 123;
|
||||
if (transport.onmessage) {
|
||||
transport.onmessage({
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
method: 'ping',
|
||||
params: {}
|
||||
// Set up a request handler that checks if it was aborted
|
||||
let wasAborted = false;
|
||||
protocol.setRequestHandler('ping', async (_request, ctx) => {
|
||||
// Simulate a long-running operation
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
wasAborted = ctx.mcpReq.signal.aborted;
|
||||
return {};
|
||||
});
|
||||
|
||||
// Simulate an incoming request
|
||||
if (transport.onmessage) {
|
||||
transport.onmessage({
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
method: 'ping',
|
||||
params: {}
|
||||
});
|
||||
}
|
||||
|
||||
// Wait a bit for the handler to start
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Send cancellation notification
|
||||
if (transport.onmessage) {
|
||||
transport.onmessage({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/cancelled',
|
||||
params: {
|
||||
requestId: requestId,
|
||||
reason: 'User cancelled'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for the handler to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Verify the request was aborted
|
||||
expect(wasAborted).toBe(true);
|
||||
}
|
||||
|
||||
// Wait a bit for the handler to start
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Send cancellation notification
|
||||
if (transport.onmessage) {
|
||||
transport.onmessage({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/cancelled',
|
||||
params: {
|
||||
requestId: requestId,
|
||||
reason: 'User cancelled'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for the handler to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Verify the request was aborted
|
||||
expect(wasAborted).toBe(true);
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
// Spec basic/patterns/cancellation §Transport-Specific (2026-07-28): on a
|
||||
|
|
|
|||
Loading…
Reference in a new issue