Skip to content

Commit 7eb2a93

Browse files
authored
fix(test): improve timing stability in tests (#506)
1 parent 9d4535b commit 7eb2a93

5 files changed

Lines changed: 66 additions & 35 deletions

File tree

integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/BaseTransportTest.kt

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
package io.modelcontextprotocol.kotlin.sdk.shared
22

3+
import io.kotest.assertions.nondeterministic.eventually
4+
import io.kotest.matchers.shouldBe
35
import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification
46
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
57
import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
68
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
79
import kotlinx.coroutines.CompletableDeferred
8-
import kotlinx.coroutines.delay
9-
import kotlin.test.assertEquals
10-
import kotlin.test.assertFalse
11-
import kotlin.test.assertTrue
1210
import kotlin.test.fail
1311
import kotlin.time.Duration.Companion.seconds
1412

@@ -23,12 +21,18 @@ abstract class BaseTransportTest {
2321
transport.onClose { didClose = true }
2422

2523
transport.start()
26-
delay(1.seconds)
2724

28-
assertFalse(didClose, "Transport should not be closed immediately after start")
25+
// Verify transport stays open after start (use eventually for cross-platform reliability)
26+
eventually(2.seconds) {
27+
didClose shouldBe false
28+
}
2929

3030
transport.close()
31-
assertTrue(didClose, "Transport should be closed after close() call")
31+
32+
// Verify transport is closed after close() call
33+
eventually(2.seconds) {
34+
didClose shouldBe true
35+
}
3236
}
3337

3438
protected suspend fun testTransportRead(transport: Transport) {
@@ -60,7 +64,7 @@ abstract class BaseTransportTest {
6064

6165
finished.await()
6266

63-
assertEquals(messages, readMessages, "Assert messages received")
67+
messages shouldBe readMessages
6468

6569
transport.close()
6670
}

integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransportTest.kt

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package io.modelcontextprotocol.kotlin.sdk.client
22

3+
import io.kotest.assertions.nondeterministic.eventually
34
import io.modelcontextprotocol.kotlin.sdk.shared.BaseTransportTest
45
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
56
import io.modelcontextprotocol.kotlin.sdk.types.McpException
67
import io.modelcontextprotocol.kotlin.test.utils.createSleepyProcessBuilder
78
import io.modelcontextprotocol.kotlin.test.utils.createTeeProcessBuilder
89
import kotlinx.coroutines.Dispatchers
9-
import kotlinx.coroutines.delay
1010
import kotlinx.coroutines.runBlocking
1111
import kotlinx.coroutines.test.runTest
1212
import kotlinx.io.asSink
@@ -23,7 +23,6 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
2323
import kotlin.test.assertFalse
2424
import kotlin.test.assertTrue
2525
import kotlin.test.fail
26-
import kotlin.time.Duration.Companion.milliseconds
2726
import kotlin.time.Duration.Companion.seconds
2827

2928
@Timeout(30, unit = TimeUnit.SECONDS)
@@ -89,17 +88,21 @@ class StdioClientTransportTest : BaseTransportTest() {
8988
transport.onClose { didClose.store(true) }
9089

9190
transport.start()
92-
delay(1.seconds)
9391

94-
assertFalse(didClose.load(), "Transport should not be closed immediately after start")
92+
// Verify transport stays open after start (use eventually for cross-platform reliability)
93+
eventually(2.seconds) {
94+
assertFalse(didClose.load(), "Transport should not be closed immediately after start")
95+
}
9596

9697
// Destroy process BEFORE close() to unblock stdin reader
9798
process.destroyForcibly()
98-
delay(100.milliseconds) // Give time for EOF to propagate
9999

100100
transport.close()
101101

102-
assertTrue(didClose.load(), "Transport should be closed after close() call")
102+
// Verify transport is closed after close() call
103+
eventually(2.seconds) {
104+
assertTrue(didClose.load(), "Transport should be closed after close() call")
105+
}
103106
}
104107

105108
@Test

integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerResourcesNotificationSubscribeTest.kt

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package io.modelcontextprotocol.kotlin.sdk.server
22

3+
import io.kotest.assertions.nondeterministic.eventually
4+
import io.kotest.assertions.withClue
5+
import io.kotest.matchers.collections.shouldBeEmpty
36
import io.kotest.matchers.collections.shouldHaveSize
7+
import io.kotest.matchers.shouldBe
48
import io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsResourcesUpdated
59
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
610
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification
@@ -9,13 +13,11 @@ import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest
913
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams
1014
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
1115
import kotlinx.coroutines.CompletableDeferred
12-
import kotlinx.coroutines.delay
1316
import kotlinx.coroutines.runBlocking
1417
import org.awaitility.kotlin.await
1518
import org.awaitility.kotlin.untilAsserted
1619
import org.junit.jupiter.api.Test
1720
import java.util.concurrent.ConcurrentLinkedQueue
18-
import kotlin.test.assertFalse
1921
import kotlin.test.assertTrue
2022
import kotlin.time.Duration.Companion.seconds
2123
import kotlin.uuid.ExperimentalUuidApi
@@ -115,7 +117,7 @@ class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() {
115117
}
116118

117119
@Test
118-
fun `notification should not be send when removed resource does not exists`() = runBlocking {
120+
fun `notification should not be send when removed resource does not exists`(): Unit = runBlocking {
119121
println("Thread ${Thread.currentThread().name} test 1")
120122
// Track notifications
121123
val notifications = ConcurrentLinkedQueue<ResourceUpdatedNotification>()
@@ -126,13 +128,15 @@ class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() {
126128

127129
// Try to remove a non-existent resource
128130
val result = server.removeResource("non-existent-resource")
129-
assertFalse(result, "Removing non-existent resource should return false")
130-
131-
// Verify the result
132-
delay(1.seconds)
133-
assertTrue(
134-
notifications.isEmpty(),
135-
"No notification should be sent when resource doesn't exist",
136-
)
131+
withClue("Removing non-existent resource should return false") {
132+
result shouldBe false
133+
}
134+
135+
// Verify no notification is sent (use eventually for cross-platform reliability)
136+
withClue("No notification should be sent when resource doesn't exist") {
137+
eventually(2.seconds) {
138+
notifications.shouldBeEmpty()
139+
}
140+
}
137141
}
138142
}

kotlin-sdk-client/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/AbstractClientTransportLifecycleTest.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.modelcontextprotocol.kotlin.sdk.client
22

3+
import io.kotest.assertions.nondeterministic.eventually
34
import io.kotest.assertions.throwables.shouldThrow
45
import io.kotest.matchers.shouldBe
56
import io.kotest.matchers.string.shouldContain
@@ -13,6 +14,7 @@ import kotlinx.coroutines.test.runTest
1314
import kotlin.test.BeforeTest
1415
import kotlin.test.Test
1516
import kotlin.time.Duration.Companion.milliseconds
17+
import kotlin.time.Duration.Companion.seconds
1618

1719
abstract class AbstractClientTransportLifecycleTest<T : AbstractTransport> {
1820

@@ -60,11 +62,15 @@ abstract class AbstractClientTransportLifecycleTest<T : AbstractTransport> {
6062
val transport = createTransport()
6163

6264
transport.start()
65+
// Give transport time to initialize
6366
delay(50.milliseconds)
6467
transport.close()
6568

66-
shouldThrow<Exception> {
67-
transport.send(PingRequest().toJSON())
69+
// Verify that send throws after close using eventually for reliability
70+
eventually(2.seconds) {
71+
shouldThrow<Exception> {
72+
transport.send(PingRequest().toJSON())
73+
}
6874
}
6975
}
7076

@@ -76,13 +82,17 @@ abstract class AbstractClientTransportLifecycleTest<T : AbstractTransport> {
7682
transport.onClose { closeCallCount++ }
7783

7884
transport.start()
85+
// Give transport time to initialize
7986
delay(50.milliseconds)
8087

8188
// Multiple close attempts
8289
transport.close()
8390
transport.close()
8491

85-
closeCallCount shouldBe 1
92+
// Verify onClose was called exactly once using eventually for cross-platform reliability
93+
eventually(2.seconds) {
94+
closeCallCount shouldBe 1
95+
}
8696
}
8797

8898
protected abstract fun createTransport(): T

kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/stdio/StdioClientTransportErrorHandlingTest.kt

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.modelcontextprotocol.kotlin.sdk.client.stdio
22

3+
import io.kotest.assertions.nondeterministic.eventually
34
import io.kotest.assertions.throwables.shouldThrow
45
import io.kotest.matchers.booleans.shouldBeFalse
56
import io.kotest.matchers.shouldBe
@@ -27,7 +28,7 @@ import java.util.stream.Stream
2728
import kotlin.concurrent.atomics.AtomicBoolean
2829
import kotlin.concurrent.atomics.ExperimentalAtomicApi
2930
import kotlin.test.Test
30-
import kotlin.time.Duration.Companion.milliseconds
31+
import kotlin.time.Duration.Companion.seconds
3132

3233
/**
3334
* Tests for StdioClientTransport error handling: EOF, IO errors, and edge cases.
@@ -57,10 +58,13 @@ class StdioClientTransportErrorHandlingTest {
5758
transport.onClose { closeCalled.store(true) }
5859

5960
transport.start()
60-
delay(200.milliseconds)
6161

6262
// Stderr EOF should not close transport
63-
closeCalled.load() shouldBe false
63+
// Use eventually to handle timing differences across platforms (especially Windows)
64+
eventually(2.seconds) {
65+
// Wait for stderr to be processed, then verify transport is still open
66+
closeCalled.load() shouldBe false
67+
}
6468

6569
transport.close()
6670
closeCalled.load() shouldBe true
@@ -87,9 +91,13 @@ class StdioClientTransportErrorHandlingTest {
8791
transport.onClose { closeCallCount++ }
8892

8993
transport.start()
90-
delay(100.milliseconds)
9194

92-
// Explicit close after error already closed it
95+
// FATAL stderr should trigger close, wait for it to complete
96+
eventually(2.seconds) {
97+
closeCallCount shouldBe 1
98+
}
99+
100+
// Explicit close after error already closed it (should be no-op)
93101
transport.close()
94102

95103
closeCallCount shouldBe 1
@@ -109,10 +117,12 @@ class StdioClientTransportErrorHandlingTest {
109117
transport.onError { errorCalled = true }
110118

111119
transport.start()
112-
delay(100.milliseconds)
113120

114121
// Empty input should close cleanly without error
115-
errorCalled.shouldBeFalse()
122+
// Wait for EOF processing to complete, verify no error was called
123+
eventually(2.seconds) {
124+
errorCalled.shouldBeFalse()
125+
}
116126
}
117127

118128
companion object {

0 commit comments

Comments
 (0)