Skip to content

Commit 34bf0c2

Browse files
travisbreaksclaude
andcommitted
fix(core): address review feedback on periodic ping
- Fix shutdown race condition: add _closing flag so in-flight pings do not fire spurious onerror when close() is called concurrently. The flag is set in close() before stopping the timer and closing the transport, and reset in connect() for clean reconnection. - Prevent concurrent ping overlap: replace setInterval with self-rescheduling setTimeout so the next ping is only scheduled after the current one completes (success or failure). This strictly enforces one-at-a-time behavior instead of relying on timeout alignment. - Fix inaccurate JSDoc: startPeriodicPing() is not called by the base Protocol.connect(). Updated docs to reflect that Client and Server call it after their respective initialization steps, and that custom subclasses must call it explicitly. - Add test coverage for the shutdown race condition and strict sequential ping enforcement. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5116b16 commit 34bf0c2

File tree

2 files changed

+141
-113
lines changed

2 files changed

+141
-113
lines changed

packages/core/src/shared/protocol.ts

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,17 @@ export type ProtocolOptions = {
9696

9797
/**
9898
* Interval (in milliseconds) between periodic ping requests sent to the remote side
99-
* to verify connection health. If set, pings will begin after {@linkcode Protocol.connect | connect()}
100-
* completes and stop automatically when the connection closes.
99+
* to verify connection health. When set, pings begin after initialization completes
100+
* ({@linkcode Client} starts them after the MCP handshake; {@linkcode Server} starts
101+
* them on `notifications/initialized`) and stop automatically when the connection closes.
101102
*
102103
* Per the MCP specification, implementations SHOULD periodically issue pings to
103104
* detect connection health, with configurable frequency.
104105
*
105106
* Disabled by default (no periodic pings). Typical values: 15000-60000 (15s-60s).
106107
*
107108
* Ping failures are reported via the {@linkcode Protocol.onerror | onerror} callback
108-
* and do not stop the periodic timer.
109+
* and do not stop the periodic loop.
109110
*/
110111
pingIntervalMs?: number;
111112
};
@@ -324,8 +325,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
324325

325326
private _taskManager: TaskManager;
326327

327-
private _pingTimer?: ReturnType<typeof setInterval>;
328+
private _pingTimer?: ReturnType<typeof setTimeout>;
328329
private _pingIntervalMs?: number;
330+
private _closing = false;
329331

330332
protected _supportedProtocolVersions: string[];
331333

@@ -474,6 +476,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
474476
*/
475477
async connect(transport: Transport): Promise<void> {
476478
this._transport = transport;
479+
this._closing = false;
477480
const _onclose = this.transport?.onclose;
478481
this._transport.onclose = () => {
479482
try {
@@ -754,12 +757,13 @@ export abstract class Protocol<ContextT extends BaseContext> {
754757
* Starts sending periodic ping requests at the configured interval.
755758
* Pings are used to verify that the remote side is still responsive.
756759
* Failures are reported via the {@linkcode onerror} callback but do not
757-
* stop the timer; pings continue until the connection is closed.
760+
* stop the loop; pings continue until the connection is closed.
758761
*
759-
* This is called automatically at the end of {@linkcode connect} when
760-
* `pingIntervalMs` is set. Subclasses that override `connect()` and
761-
* perform additional initialization (e.g., the MCP handshake) may call
762-
* this method after their initialization is complete instead.
762+
* This is not called automatically by the base {@linkcode Protocol.connect | connect()}
763+
* method. {@linkcode Client} calls it after the MCP initialization handshake
764+
* (and on reconnection), and {@linkcode Server} calls it when the
765+
* `notifications/initialized` notification is received. Custom `Protocol`
766+
* subclasses must call this explicitly after their own initialization.
763767
*
764768
* Has no effect if periodic ping is already running or if no interval
765769
* is configured.
@@ -769,28 +773,40 @@ export abstract class Protocol<ContextT extends BaseContext> {
769773
return;
770774
}
771775

772-
this._pingTimer = setInterval(async () => {
773-
try {
774-
await this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, {
775-
timeout: this._pingIntervalMs
776-
});
777-
} catch (error) {
778-
this._onerror(error instanceof Error ? error : new Error(`Periodic ping failed: ${String(error)}`));
776+
const schedulePing = (): void => {
777+
this._pingTimer = setTimeout(async () => {
778+
try {
779+
await this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, {
780+
timeout: this._pingIntervalMs
781+
});
782+
} catch (error) {
783+
// Suppress errors caused by intentional shutdown
784+
if (!this._closing) {
785+
this._onerror(error instanceof Error ? error : new Error(`Periodic ping failed: ${String(error)}`));
786+
}
787+
} finally {
788+
// Schedule the next ping only if we have not been stopped
789+
if (this._pingTimer) {
790+
schedulePing();
791+
}
792+
}
793+
}, this._pingIntervalMs);
794+
795+
// Allow the process to exit even if the timer is still running
796+
if (typeof this._pingTimer === 'object' && 'unref' in this._pingTimer) {
797+
this._pingTimer.unref();
779798
}
780-
}, this._pingIntervalMs);
799+
};
781800

782-
// Allow the process to exit even if the timer is still running
783-
if (typeof this._pingTimer === 'object' && 'unref' in this._pingTimer) {
784-
this._pingTimer.unref();
785-
}
801+
schedulePing();
786802
}
787803

788804
/**
789805
* Stops periodic ping requests. Called automatically when the connection closes.
790806
*/
791807
protected stopPeriodicPing(): void {
792808
if (this._pingTimer) {
793-
clearInterval(this._pingTimer);
809+
clearTimeout(this._pingTimer);
794810
this._pingTimer = undefined;
795811
}
796812
}
@@ -799,6 +815,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
799815
* Closes the connection.
800816
*/
801817
async close(): Promise<void> {
818+
this._closing = true;
802819
this.stopPeriodicPing();
803820
await this._transport?.close();
804821
}

0 commit comments

Comments
 (0)