fix(core): never send notifications/cancelled for the initialize hand… (#2668)
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
This commit is contained in:
parent
75dc7ea6e2
commit
3e90449fd5
2 changed files with 109 additions and 33 deletions
|
|
@ -1453,19 +1453,29 @@ export abstract class Protocol<ContextT extends BaseContext> {
|
|||
this._progressHandlers.delete(messageId);
|
||||
|
||||
if (requestAbort === undefined) {
|
||||
this._transport
|
||||
?.send(
|
||||
this._envelopeOutbound({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/cancelled',
|
||||
params: {
|
||||
requestId: messageId,
|
||||
reason: String(reason)
|
||||
}
|
||||
}),
|
||||
{ relatedRequestId, resumptionToken, onresumptiontoken }
|
||||
)
|
||||
.catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`)));
|
||||
// "A client MUST NOT attempt to cancel its `initialize`
|
||||
// request" (spec basic/lifecycle, mirrored on
|
||||
// `CancelledNotification`). The handshake is the one request
|
||||
// whose cancellation is forbidden outright, so an abort or
|
||||
// timeout on it settles purely locally: the promise still
|
||||
// rejects below, but nothing goes on the wire. Only the
|
||||
// legacy era can reach this — `initialize` is absent from the
|
||||
// modern registry, which negotiates via `server/discover`.
|
||||
if (request.method !== 'initialize') {
|
||||
this._transport
|
||||
?.send(
|
||||
this._envelopeOutbound({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/cancelled',
|
||||
params: {
|
||||
requestId: messageId,
|
||||
reason: String(reason)
|
||||
}
|
||||
}),
|
||||
{ relatedRequestId, resumptionToken, onresumptiontoken }
|
||||
)
|
||||
.catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`)));
|
||||
}
|
||||
} else {
|
||||
// Modern-era per-request-stream transport: aborting the
|
||||
// request's underlying stream IS the spec cancel signal.
|
||||
|
|
|
|||
|
|
@ -866,6 +866,23 @@ describe('protocol tests', () => {
|
|||
const cancelledSent = (sent: JSONRPCMessage[]): JSONRPCMessage[] =>
|
||||
sent.filter(m => 'method' in m && m.method === 'notifications/cancelled');
|
||||
|
||||
/**
|
||||
* Connects a fresh protocol over a single-channel transport (stdio /
|
||||
* in-memory shape: no `hasPerRequestStream`) at `version`, recording
|
||||
* every outbound message.
|
||||
*/
|
||||
const connectSingleChannel = async (version: string) => {
|
||||
const sent: JSONRPCMessage[] = [];
|
||||
const tx = new MockTransport();
|
||||
tx.send = async (m: JSONRPCMessage) => {
|
||||
sent.push(m);
|
||||
};
|
||||
const proto = createTestProtocol();
|
||||
await proto.connect(tx);
|
||||
setNegotiatedProtocolVersion(proto, version);
|
||||
return { proto, sent };
|
||||
};
|
||||
|
||||
test('modern era + per-request-stream transport: abort closes the stream, NO notifications/cancelled', async () => {
|
||||
const tx = new PerRequestStreamTransport();
|
||||
const proto = createTestProtocol();
|
||||
|
|
@ -888,15 +905,7 @@ describe('protocol tests', () => {
|
|||
});
|
||||
|
||||
test('modern era + single-channel transport (no hasPerRequestStream): POSTs notifications/cancelled', async () => {
|
||||
// stdio / in-memory shape: hasPerRequestStream is undefined.
|
||||
const sent: JSONRPCMessage[] = [];
|
||||
const tx = new MockTransport();
|
||||
tx.send = async (m: JSONRPCMessage, _opts?: TransportSendOptions) => {
|
||||
sent.push(m);
|
||||
};
|
||||
const proto = createTestProtocol();
|
||||
await proto.connect(tx);
|
||||
setNegotiatedProtocolVersion(proto, '2026-07-28');
|
||||
const { proto, sent } = await connectSingleChannel('2026-07-28');
|
||||
|
||||
const ac = new AbortController();
|
||||
const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal });
|
||||
|
|
@ -937,6 +946,53 @@ describe('protocol tests', () => {
|
|||
expect(tx.lastRequestSignal?.aborted).toBe(true);
|
||||
expect(cancelledSent(tx.sent)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// "A client MUST NOT attempt to cancel its `initialize` request." The
|
||||
// handshake is exempt from the POST path above on every transport: an
|
||||
// abort or timeout rejects the caller locally and sends nothing. Both
|
||||
// triggers are covered because they reach cancel() by different routes
|
||||
// (the caller's signal vs the timeout handler).
|
||||
describe('the initialize handshake is never cancelled on the wire', () => {
|
||||
test('aborting an in-flight initialize sends NO notifications/cancelled', async () => {
|
||||
// ARRANGE
|
||||
const { proto, sent } = await connectSingleChannel('2025-11-25');
|
||||
|
||||
// ACT
|
||||
const ac = new AbortController();
|
||||
const pending = testRequest(proto, { method: 'initialize', params: {} }, z.object({}), { signal: ac.signal });
|
||||
ac.abort('user cancel');
|
||||
|
||||
// ASSERT — rejects locally, wire stays clean
|
||||
await expect(pending).rejects.toThrow();
|
||||
expect(cancelledSent(sent)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('timing out an in-flight initialize sends NO notifications/cancelled', async () => {
|
||||
// ARRANGE
|
||||
const { proto, sent } = await connectSingleChannel('2025-11-25');
|
||||
|
||||
// ACT
|
||||
const pending = testRequest(proto, { method: 'initialize', params: {} }, z.object({}), { timeout: 0 });
|
||||
|
||||
// ASSERT
|
||||
await expect(pending).rejects.toThrow();
|
||||
expect(cancelledSent(sent)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('every other method still POSTs notifications/cancelled (regression guard)', async () => {
|
||||
// ARRANGE
|
||||
const { proto, sent } = await connectSingleChannel('2025-11-25');
|
||||
|
||||
// ACT
|
||||
const ac = new AbortController();
|
||||
const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal });
|
||||
ac.abort('user cancel');
|
||||
|
||||
// ASSERT
|
||||
await expect(pending).rejects.toThrow();
|
||||
expect(cancelledSent(sent)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue