Repository navigation
Conversation
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The focused implementation correctly preserves structured cancellation semantics and is covered by targeted regression tests.
Review effort: Balanced
Findings: None
What changed in this PR
Keeps StdioServerTransport active when only a message handler is cancelled, while preserving transport-level cancellation.
Changes:
- Distinguishes handler cancellation from transport cancellation using
ensureActive(). - Reports handler cancellations through
onErrorand continues processing. - Adds regression coverage for both cancellation paths.
| File | Description |
|---|---|
StdioServerTransport.kt |
Handles escaped handler cancellation without stopping the transport. |
StdioServerTransportTest.kt |
Updates and expands cancellation behavior tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Approving — I've left one non-blocking note inline for you.
Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=9ec05c83-ec7c-4a82-afff-a07cf8545630
| // escapes a handler is a failure of that handler, like any other exception. | ||
| currentCoroutineContext().ensureActive() | ||
| logger.error(e) { "Message handler was cancelled" } | ||
| _onError(e) |
There was a problem hiding this comment.
Problem: keeping the loop alive is the right call, but it now leaves the request the cancelled handler was serving with no reply at all. In the concurrent dispatch phase Protocol.onRequest already turns a cancellation that escapes a live handler job into an INTERNAL_ERROR response (Protocol.kt:626-632, "so the peer does not hang until its own timeout"). The serial, pre-notifications/initialized phase takes the handlerJob == null branch and rethrows instead — and until this change that rethrow landed here and tore the transport down, so the peer at least observed a closed connection. With the transport surviving, nothing goes out.
Failure scenario: a client sends initialize, then tools/call before the server has processed notifications/initialized (so dispatch is still inline), and the tool handler leaks a cancellation — e.g. an inner withTimeout expiring. I ran this against the branch: the server answers initialize, answers a later unrelated ping, and never answers the tools/call. The client blocks on that call until its own request timeout. On main the same scenario closed the stream, so the client failed fast.
Suggested fix: make the serial branch in Protocol.onRequest answer instead of rethrowing, using the same liveness check as here so a real close() during an inline handler still propagates:
if (handlerJob == null) {
currentCoroutineContext().ensureActive()
respondWithError(capturedTransport, request, e)
return@withContext
}Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=9ec05c83-ec7c-4a82-afff-a07cf8545630
There was a problem hiding this comment.
Fixed in 14c4d86, as suggested: the serial branch in Protocol.onRequest now calls ensureActive() and answers INTERNAL_ERROR instead of rethrowing. Added a core test for the leak (fails on the previous commit) and one that cancels the delivering coroutine and expects no response. This moves part of the fix into core, so I noted that in the PR description.
There was a problem hiding this comment.
🟢 Approval recommended
The cancellation distinction is correctly implemented and covered across transport and protocol layers.
0 open findings
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
There was a problem hiding this comment.
Nice work — this reads correct end to end, so happy to approve.
Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=5821c7a3-2243-4838-bc0d-67896dadc9d3
chrikrah
left a comment
There was a problem hiding this comment.
@gibkim approving at 14c4d86: both production files at the merge base fail one new test each, and on your tree both suites pass.
$ ./gradlew :kotlin-sdk-server:jvmTest --tests "*StdioServerTransportTest" :kotlin-sdk-core:jvmTest --tests "*ProtocolConcurrencyTest*" # 14c4d86, Temurin 21, Gradle 9.8.0
StdioServerTransportTest[jvm] tests=24 failures=0
ProtocolConcurrencyTest[jvm] tests=19 failures=0
$ # same command, Protocol.kt and StdioServerTransport.kt at 32e6483 (merge base), tests kept
StdioServerTransportTest[jvm] tests=24 failures=1 [3] handler throws CancellationException: Timed out waiting for 5000 ms
ProtocolConcurrencyTest[jvm] tests=19 failures=1 handler leaking CancellationException in the serial phase produces INTERNAL_ERROR
non-blocking: the body already notes the core change. The title becomes the squash commit, so fix(core,server): ... would carry it into git log as well.
@devcrocod you filed #1086. This is ready for your review, and the merge-base runs above show both new tests failing without the fix.
|
Thanks @chrikrah for running the merge-base comparison, that's the check that matters here. Retitled to |
There was a problem hiding this comment.
Approving — everything checks out here.
Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=6583000c-3f12-4881-b698-59324e5d4737
|
@devcrocod gentle ping on this one when you have a moment. I've updated the branch with the latest |
…elled A CancellationException thrown by a message handler was rethrown by the processor pump, which ended the read loop and closed the connection without onError. Treat it like any other handler failure and keep going. The transport's own cancellation is still detected with ensureActive() and stops the loop. Fixes modelcontextprotocol#1086
Before notifications/initialized, requests are dispatched inline and a CancellationException leaking from a handler was rethrown. With the Stdio transport no longer closing on handler cancellation, that request got no reply and the peer waited for its own timeout. Answer INTERNAL_ERROR, as the concurrent phase already does, unless the delivering coroutine itself is cancelled.
3c524e8 to
806578c
Compare
There was a problem hiding this comment.
Approving — this holds up end to end, nothing blocking from me.
Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=220b52a8-6473-44f0-af53-43aaca507413
Fixes #1086
Problem
When a message handler threw
CancellationException,processorPumprethrew it. The read loop ended, thefinallyblock closed the write channel, and the transport firedonClosewithoutonError. A single handler's cancellation, for example a cancelled request scope leaking out of a handler, closed the whole connection. Other handler exceptions are logged and the loop continues.Change
catchforCancellationException, callcurrentCoroutineContext().ensureActive()first. If the transport's own coroutine is cancelled (close()or a cancelled external scope), this rethrows and the loop stops as before.onError, like any other handler failure, and the loop continues.Protocol.onRequest(core, second commit): in the serial phase beforenotifications/initialized, aCancellationExceptionleaking from a handler was rethrown. Once the Stdio transport stops closing on it, that request got no reply and the peer waited for its own timeout. It now answersINTERNAL_ERROR, like the concurrent phase, afterensureActive()so a cancelled delivering coroutine still propagates. This touches core and therefore every transport.Tests
should continue processing messages after handler throwsnow also runs withCancellationException("handler cancelled"). Onmainthis case times out because the transport closes after the first message.should not invoke onError for CancellationException in handler, which pinned the old behavior.should stop without onError when the scope is cancelled during a handler: an external scope is cancelled while a handler is suspended. The transport must close without reporting an error. Without theensureActive()call this test fails with theJobCancellationExceptionreported toonError. The test waits for the scope's job after closing the input, becauseonClosecan fire before the processor has handled the cancellation.should not hang close when handler suspends indefinitelystill passes, soclose()during a suspended handler is unaffected.:kotlin-sdk-servertests pass onjvmTest,jsNodeTestandmacosArm64Test;:integration-test:jvmTestpasses.ktlintCheck,detektandcheckKotlinAbipass. No public API change.handler leaking CancellationException in the serial phase produces INTERNAL_ERROR(fails on the first commit with the rethrown exception) andcancelling the delivering coroutine in the serial phase sends no response.:kotlin-sdk-core,:kotlin-sdk-serverand:kotlin-sdk-clientjvmTestpass, plus the new tests onjsNodeTestandmacosArm64Test; corektlintCheck,detektandcheckKotlinAbipass.🤖 Generated with Claude Code