Repository navigation
fix!(server): #575 Rethrow CancellationException in StdioServerTransport, fix ReadBuffer #571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
2875ca2
4ef9d9f
6318623
2bfefa4
5572cf6
9d47a71
9c0ad9f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,7 @@ import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer | |||||
| import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions | ||||||
| import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage | ||||||
| import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage | ||||||
| import kotlinx.coroutines.CancellationException | ||||||
| import kotlinx.coroutines.CoroutineScope | ||||||
| import kotlinx.coroutines.Job | ||||||
| import kotlinx.coroutines.NonCancellable | ||||||
|
|
@@ -26,37 +27,55 @@ import kotlin.concurrent.atomics.AtomicBoolean | |||||
| import kotlin.concurrent.atomics.ExperimentalAtomicApi | ||||||
| import kotlin.coroutines.CoroutineContext | ||||||
|
|
||||||
| private const val READ_BUFFER_SIZE = 8192L | ||||||
|
|
||||||
| /** | ||||||
| * A server transport that communicates with a client via standard I/O. | ||||||
| * | ||||||
| * Reads from System.in and writes to System.out. | ||||||
| * Reads from input [Source] and writes to output [Sink]. | ||||||
| * | ||||||
| * @constructor Creates a new instance of [StdioServerTransport]. | ||||||
| * @param inputStream The input [Source] used to receive data. | ||||||
| * @param outputStream The output [Sink] used to send data. | ||||||
| */ | ||||||
| @OptIn(ExperimentalAtomicApi::class) | ||||||
| public class StdioServerTransport(private val inputStream: Source, outputStream: Sink) : AbstractTransport() { | ||||||
| private val logger = KotlinLogging.logger {} | ||||||
|
|
||||||
| private val logger = KotlinLogging.logger {} | ||||||
| private val readBuffer = ReadBuffer() | ||||||
| private val initialized: AtomicBoolean = AtomicBoolean(false) | ||||||
| private var readingJob: Job? = null | ||||||
| private var sendingJob: Job? = null | ||||||
| private var processingJob: Job? = null | ||||||
|
|
||||||
| private val coroutineContext: CoroutineContext = IODispatcher + SupervisorJob() | ||||||
| private val scope = CoroutineScope(coroutineContext) | ||||||
| private val readChannel = Channel<ByteArray>(Channel.UNLIMITED) | ||||||
| private val writeChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED) | ||||||
| private val outputWriter = outputStream.buffered() | ||||||
| private val outputSink = outputStream.buffered() | ||||||
|
|
||||||
| override suspend fun start() { | ||||||
| if (!initialized.compareAndSet(expectedValue = false, newValue = true)) { | ||||||
| error("StdioServerTransport already started!") | ||||||
| } | ||||||
|
|
||||||
| // Launch a coroutine to read from stdin | ||||||
| readingJob = scope.launch { | ||||||
| readingJob = launchReadingJob() | ||||||
|
|
||||||
| // Launch a coroutine to process messages from readChannel | ||||||
| processingJob = launchProcessingJob() | ||||||
|
|
||||||
| // Launch a coroutine to handle message sending | ||||||
| sendingJob = launchSendingJob() | ||||||
| } | ||||||
|
|
||||||
| private fun launchReadingJob(): Job { | ||||||
| val job = scope.launch { | ||||||
| val buf = Buffer() | ||||||
| @Suppress("TooGenericExceptionCaught") | ||||||
| try { | ||||||
| while (isActive) { | ||||||
| val bytesRead = inputStream.readAtMostTo(buf, 8192) | ||||||
| val bytesRead = inputStream.readAtMostTo(buf, READ_BUFFER_SIZE) | ||||||
| if (bytesRead == -1L) { | ||||||
| // EOF reached | ||||||
| break | ||||||
|
|
@@ -66,47 +85,73 @@ public class StdioServerTransport(private val inputStream: Source, outputStream: | |||||
| readChannel.send(chunk) | ||||||
| } | ||||||
| } | ||||||
| } catch (e: Throwable) { | ||||||
| } catch (e: CancellationException) { | ||||||
| throw e | ||||||
| } catch (e: Exception) { | ||||||
| logger.error(e) { "Error reading from stdin" } | ||||||
| _onError.invoke(e) | ||||||
| } finally { | ||||||
| // Reached EOF or error, close connection | ||||||
| close() | ||||||
| } | ||||||
| } | ||||||
| job.invokeOnCompletion { cause -> | ||||||
| logJobCompletion("Message reading", cause) | ||||||
| } | ||||||
| return job | ||||||
| } | ||||||
|
|
||||||
| // Launch a coroutine to process messages from readChannel | ||||||
| scope.launch { | ||||||
| private fun launchProcessingJob(): Job { | ||||||
| val job = scope.launch { | ||||||
| @Suppress("TooGenericExceptionCaught") | ||||||
| try { | ||||||
| for (chunk in readChannel) { | ||||||
| readBuffer.append(chunk) | ||||||
| processReadBuffer() | ||||||
| } | ||||||
| } catch (e: Throwable) { | ||||||
| } catch (e: CancellationException) { | ||||||
| throw e | ||||||
| } catch (e: Exception) { | ||||||
| _onError.invoke(e) | ||||||
| } | ||||||
| } | ||||||
| job.invokeOnCompletion { cause -> | ||||||
| logJobCompletion("Processing", cause) | ||||||
| } | ||||||
| return job | ||||||
| } | ||||||
|
|
||||||
| // Launch a coroutine to handle message sending | ||||||
| sendingJob = scope.launch { | ||||||
| private fun launchSendingJob(): Job { | ||||||
| val job = scope.launch { | ||||||
| @Suppress("TooGenericExceptionCaught") | ||||||
| try { | ||||||
| for (message in writeChannel) { | ||||||
| val json = serializeMessage(message) | ||||||
| outputWriter.writeString(json) | ||||||
| outputWriter.flush() | ||||||
| outputSink.writeString(json) | ||||||
| outputSink.flush() | ||||||
| } | ||||||
| } catch (e: Throwable) { | ||||||
| } catch (e: CancellationException) { | ||||||
| throw e | ||||||
| } catch (e: Exception) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| logger.error(e) { "Error writing to stdout" } | ||||||
| _onError.invoke(e) | ||||||
| } | ||||||
| } | ||||||
| job.invokeOnCompletion { cause -> | ||||||
| logJobCompletion("Message sending", cause) | ||||||
| if (cause is CancellationException) { | ||||||
| readingJob?.cancel(cause) | ||||||
| } | ||||||
| } | ||||||
| return job | ||||||
| } | ||||||
|
|
||||||
| private suspend fun processReadBuffer() { | ||||||
| @Suppress("TooGenericExceptionCaught") | ||||||
| while (true) { | ||||||
| val message = try { | ||||||
| readBuffer.readMessage() | ||||||
| } catch (e: Throwable) { | ||||||
| } catch (e: Exception) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you tell me why we want to skip logging
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Things like OutOfMemoryError are not actionable and usually not handled by the application and propagated.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. they still needs to be logged by the configured logger
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logging is never guaranteed in the situation when JVM is crashing. Logging to System.err might make sense, but is still questionable. |
||||||
| _onError.invoke(e) | ||||||
| null | ||||||
| } | ||||||
|
|
@@ -115,12 +160,29 @@ public class StdioServerTransport(private val inputStream: Source, outputStream: | |||||
| // Async invocation broke delivery order | ||||||
| try { | ||||||
| _onMessage.invoke(message) | ||||||
| } catch (e: Throwable) { | ||||||
| } catch (e: CancellationException) { | ||||||
| throw e | ||||||
| } catch (e: Exception) { | ||||||
| _onError.invoke(e) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| private fun logJobCompletion(jobName: String, cause: Throwable?) { | ||||||
| when (cause) { | ||||||
| is CancellationException -> { | ||||||
| } | ||||||
|
|
||||||
| null -> { | ||||||
| logger.debug { "$jobName job completed" } | ||||||
| } | ||||||
|
|
||||||
| else -> { | ||||||
| logger.debug(cause) { "$jobName job completed exceptionally" } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| override suspend fun close() { | ||||||
| if (!initialized.compareAndSet(expectedValue = true, newValue = false)) return | ||||||
|
|
||||||
|
|
@@ -133,13 +195,15 @@ public class StdioServerTransport(private val inputStream: Source, outputStream: | |||||
| }.onFailure { logger.warn(it) { "Failed to close stdin" } } | ||||||
|
|
||||||
| readingJob?.cancel() | ||||||
|
|
||||||
| readChannel.close() | ||||||
|
|
||||||
| processingJob?.cancelAndJoin() | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
|
|
||||||
| readBuffer.clear() | ||||||
|
|
||||||
| runCatching { | ||||||
| outputWriter.flush() | ||||||
| outputWriter.close() | ||||||
| outputSink.flush() | ||||||
| outputSink.close() | ||||||
| }.onFailure { logger.warn(it) { "Failed to close stdout" } } | ||||||
|
|
||||||
| invokeOnCloseCallback() | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.