|
17 | 17 | from mcp.shared.message import SessionMessage |
18 | 18 | from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse |
19 | 19 |
|
20 | | -python: str = shutil.which("python") # type: ignore |
| 20 | +python = shutil.which("python") or "python" |
21 | 21 |
|
22 | 22 |
|
23 | 23 | @pytest.mark.anyio |
@@ -267,3 +267,203 @@ async def test_socket_client_invalid_json(): |
267 | 267 | async for message in read_stream: |
268 | 268 | assert isinstance(message, Exception) |
269 | 269 | break |
| 270 | + |
| 271 | + |
| 272 | +@pytest.mark.anyio |
| 273 | +async def test_socket_client_cancellation_handling(): |
| 274 | + """Test that socket_client handles cancellation gracefully.""" |
| 275 | + server_params = SocketServerParameters( |
| 276 | + command=python, |
| 277 | + args=[ |
| 278 | + "-c", |
| 279 | + """ |
| 280 | +import socket, sys, time |
| 281 | +s = socket.socket() |
| 282 | +s.connect(('127.0.0.1', int(sys.argv[2]))) |
| 283 | +# Keep connection alive for a bit |
| 284 | +time.sleep(2) |
| 285 | +s.close() |
| 286 | + """, |
| 287 | + ], |
| 288 | + ) |
| 289 | + |
| 290 | + # Test that cancellation works properly |
| 291 | + with anyio.move_on_after(0.5) as cancel_scope: |
| 292 | + async with socket_client(server_params) as (read_stream, write_stream): |
| 293 | + # Wait a bit, then the move_on_after should cancel |
| 294 | + await anyio.sleep(1) |
| 295 | + |
| 296 | + # The cancellation should have occurred |
| 297 | + assert cancel_scope.cancelled_caught |
| 298 | + |
| 299 | + |
| 300 | +@pytest.mark.anyio |
| 301 | +async def test_socket_client_cleanup_timeout(): |
| 302 | + """Test that socket_client cleanup has timeout protection.""" |
| 303 | + server_params = SocketServerParameters( |
| 304 | + command=python, |
| 305 | + args=[ |
| 306 | + "-c", |
| 307 | + """ |
| 308 | +import socket, sys, time |
| 309 | +s = socket.socket() |
| 310 | +s.connect(('127.0.0.1', int(sys.argv[2]))) |
| 311 | +
|
| 312 | +# Send a message and keep connection alive briefly |
| 313 | +s.send(b'{"jsonrpc": "2.0", "id": 1, "method": "test"}\\n') |
| 314 | +time.sleep(1) # Brief delay, then exit normally |
| 315 | +s.close() |
| 316 | + """, |
| 317 | + ], |
| 318 | + ) |
| 319 | + |
| 320 | + # Test that cleanup completes within reasonable time |
| 321 | + start_time = anyio.current_time() |
| 322 | + |
| 323 | + async with socket_client(server_params) as (read_stream, write_stream): |
| 324 | + # Do some work |
| 325 | + await anyio.sleep(0.1) |
| 326 | + |
| 327 | + end_time = anyio.current_time() |
| 328 | + |
| 329 | + # Normal cleanup should complete quickly (within 3 seconds) |
| 330 | + # This tests that the cleanup mechanism works without hanging |
| 331 | + assert end_time - start_time < 3.0 |
| 332 | + |
| 333 | + |
| 334 | +@pytest.mark.anyio |
| 335 | +async def test_socket_client_cleanup_mechanism(): |
| 336 | + """Test that socket_client cleanup mechanism is robust.""" |
| 337 | + server_params = SocketServerParameters( |
| 338 | + command=python, |
| 339 | + args=[ |
| 340 | + "-c", |
| 341 | + """ |
| 342 | +import socket, sys, time |
| 343 | +s = socket.socket() |
| 344 | +s.connect(('127.0.0.1', int(sys.argv[2]))) |
| 345 | +
|
| 346 | +# Send a test message |
| 347 | +s.send(b'{"jsonrpc": "2.0", "id": 1, "method": "test"}\\n') |
| 348 | +
|
| 349 | +# Close after brief delay |
| 350 | +time.sleep(0.2) |
| 351 | +s.close() |
| 352 | + """, |
| 353 | + ], |
| 354 | + ) |
| 355 | + |
| 356 | + # Test that cleanup works correctly |
| 357 | + async with socket_client(server_params) as (read_stream, write_stream): |
| 358 | + # Process at least one message |
| 359 | + async for message in read_stream: |
| 360 | + if isinstance(message, Exception): |
| 361 | + continue |
| 362 | + # Exit after first valid message |
| 363 | + break |
| 364 | + |
| 365 | + # If we reach here, cleanup worked properly |
| 366 | + assert True |
| 367 | + |
| 368 | + |
| 369 | +@pytest.mark.anyio |
| 370 | +async def test_socket_client_reader_writer_exception_handling(): |
| 371 | + """Test that socket reader/writer handle exceptions properly.""" |
| 372 | + server_params = SocketServerParameters( |
| 373 | + command=python, |
| 374 | + args=[ |
| 375 | + "-c", |
| 376 | + """ |
| 377 | +import socket, sys, time |
| 378 | +s = socket.socket() |
| 379 | +s.connect(('127.0.0.1', int(sys.argv[2]))) |
| 380 | +
|
| 381 | +# Send some data then close abruptly |
| 382 | +s.send(b'{"jsonrpc": "2.0", "id": 1, "method": "test"}\\n') |
| 383 | +time.sleep(0.1) |
| 384 | +s.close() # Close connection abruptly |
| 385 | + """, |
| 386 | + ], |
| 387 | + ) |
| 388 | + |
| 389 | + async with socket_client(server_params) as (read_stream, write_stream): |
| 390 | + # Should handle the abrupt connection close gracefully |
| 391 | + messages_received = 0 |
| 392 | + async for message in read_stream: |
| 393 | + if isinstance(message, Exception): |
| 394 | + # Exceptions in the stream are expected |
| 395 | + continue |
| 396 | + messages_received += 1 |
| 397 | + if messages_received >= 1: |
| 398 | + break |
| 399 | + |
| 400 | + assert messages_received >= 1 |
| 401 | + |
| 402 | + |
| 403 | +@pytest.mark.anyio |
| 404 | +async def test_socket_client_process_cleanup(): |
| 405 | + """Test that socket_client cleans up processes properly.""" |
| 406 | + server_params = SocketServerParameters( |
| 407 | + command=python, |
| 408 | + args=[ |
| 409 | + "-c", |
| 410 | + """ |
| 411 | +import socket, sys, time, os |
| 412 | +pid = os.getpid() |
| 413 | +print(f"Process PID: {pid}", file=sys.stderr) |
| 414 | +
|
| 415 | +s = socket.socket() |
| 416 | +s.connect(('127.0.0.1', int(sys.argv[2]))) |
| 417 | +time.sleep(0.5) |
| 418 | +s.close() |
| 419 | + """, |
| 420 | + ], |
| 421 | + ) |
| 422 | + |
| 423 | + async with socket_client(server_params) as (read_stream, write_stream): |
| 424 | + # Brief interaction |
| 425 | + await anyio.sleep(0.1) |
| 426 | + |
| 427 | + # Process should be cleaned up after context exit |
| 428 | + # This is mainly to ensure no zombie processes remain |
| 429 | + await anyio.sleep(0.1) # Give cleanup time to complete |
| 430 | + |
| 431 | + |
| 432 | +@pytest.mark.anyio |
| 433 | +async def test_socket_client_multiple_messages_with_cancellation(): |
| 434 | + """Test handling multiple messages with cancellation.""" |
| 435 | + server_params = SocketServerParameters( |
| 436 | + command=python, |
| 437 | + args=[ |
| 438 | + "-c", |
| 439 | + """ |
| 440 | +import socket, sys, time, json |
| 441 | +s = socket.socket() |
| 442 | +s.connect(('127.0.0.1', int(sys.argv[2]))) |
| 443 | +
|
| 444 | +# Send multiple messages |
| 445 | +for i in range(10): |
| 446 | + msg = {"jsonrpc": "2.0", "id": i, "method": "test", "params": {"counter": i}} |
| 447 | + s.send((json.dumps(msg) + '\\n').encode()) |
| 448 | + time.sleep(0.01) # Small delay between messages |
| 449 | +
|
| 450 | +s.close() |
| 451 | + """, |
| 452 | + ], |
| 453 | + ) |
| 454 | + |
| 455 | + messages_received = 0 |
| 456 | + |
| 457 | + with anyio.move_on_after(1.0) as cancel_scope: |
| 458 | + async with socket_client(server_params) as (read_stream, write_stream): |
| 459 | + async for message in read_stream: |
| 460 | + if isinstance(message, Exception): |
| 461 | + continue |
| 462 | + messages_received += 1 |
| 463 | + if messages_received >= 5: |
| 464 | + # Cancel after receiving some messages |
| 465 | + cancel_scope.cancel() |
| 466 | + |
| 467 | + # Should have received some messages before cancellation |
| 468 | + assert messages_received >= 5 |
| 469 | + assert cancel_scope.cancelled_caught |
0 commit comments