17
17
from mcp .shared .exceptions import McpError
18
18
from mcp .shared .message import SessionMessage
19
19
from mcp .types import CONNECTION_CLOSED , JSONRPCMessage , JSONRPCRequest , JSONRPCResponse
20
+ from tests .shared .test_win32_utils import escape_path_for_python
20
21
21
22
# Timeout for cleanup of processes that ignore SIGTERM
22
23
# This timeout ensures the test fails quickly if the cleanup logic doesn't have
@@ -249,12 +250,6 @@ class TestChildProcessCleanup:
249
250
This is a fundamental difference between Windows and Unix process termination.
250
251
"""
251
252
252
- @staticmethod
253
- def _escape_path_for_python (path : str ) -> str :
254
- """Escape a file path for use in Python code strings."""
255
- # Use forward slashes which work on all platforms and don't need escaping
256
- return repr (path .replace ("\\ " , "/" ))
257
-
258
253
@pytest .mark .anyio
259
254
@pytest .mark .filterwarnings ("ignore::ResourceWarning" if sys .platform == "win32" else "default" )
260
255
async def test_basic_child_process_cleanup (self ):
@@ -280,13 +275,13 @@ async def test_basic_child_process_cleanup(self):
280
275
import os
281
276
282
277
# Mark that parent started
283
- with open({ self . _escape_path_for_python (parent_marker )} , 'w') as f:
278
+ with open({ escape_path_for_python (parent_marker )} , 'w') as f:
284
279
f.write('parent started\\ n')
285
280
286
281
# Child script that writes continuously
287
282
child_script = f'''
288
283
import time
289
- with open({ self . _escape_path_for_python (marker_file )} , 'a') as f:
284
+ with open({ escape_path_for_python (marker_file )} , 'a') as f:
290
285
while True:
291
286
f.write(f"{ time .time ()} ")
292
287
f.flush()
@@ -381,7 +376,7 @@ async def test_nested_process_tree(self):
381
376
382
377
# Grandchild just writes to file
383
378
grandchild_script = \" \" \" import time
384
- with open({ self . _escape_path_for_python (grandchild_file )} , 'a') as f:
379
+ with open({ escape_path_for_python (grandchild_file )} , 'a') as f:
385
380
while True:
386
381
f.write(f"gc {{time.time()}}")
387
382
f.flush()
@@ -391,7 +386,7 @@ async def test_nested_process_tree(self):
391
386
subprocess.Popen([sys.executable, '-c', grandchild_script])
392
387
393
388
# Child writes to its file
394
- with open({ self . _escape_path_for_python (child_file )} , 'a') as f:
389
+ with open({ escape_path_for_python (child_file )} , 'a') as f:
395
390
while True:
396
391
f.write(f"c { time .time ()} ")
397
392
f.flush()
@@ -401,7 +396,7 @@ async def test_nested_process_tree(self):
401
396
subprocess.Popen([sys.executable, '-c', child_script])
402
397
403
398
# Parent writes to its file
404
- with open({ self . _escape_path_for_python (parent_file )} , 'a') as f:
399
+ with open({ escape_path_for_python (parent_file )} , 'a') as f:
405
400
while True:
406
401
f.write(f"p { time .time ()} ")
407
402
f.flush()
@@ -470,7 +465,7 @@ async def test_early_parent_exit(self):
470
465
471
466
# Child that continues running
472
467
child_script = f'''import time
473
- with open({ self . _escape_path_for_python (marker_file )} , 'a') as f:
468
+ with open({ escape_path_for_python (marker_file )} , 'a') as f:
474
469
while True:
475
470
f.write(f"child { time .time ()} ")
476
471
f.flush()
@@ -525,3 +520,119 @@ def handle_term(sig, frame):
525
520
os .unlink (marker_file )
526
521
except OSError :
527
522
pass
523
+
524
+
525
+ @pytest .mark .anyio
526
+ async def test_stdio_client_graceful_stdin_exit ():
527
+ """
528
+ Test that a process exits gracefully when stdin is closed,
529
+ without needing SIGTERM or SIGKILL.
530
+ """
531
+ # Create a Python script that exits when stdin is closed
532
+ script_content = textwrap .dedent (
533
+ """
534
+ import sys
535
+
536
+ # Read from stdin until it's closed
537
+ try:
538
+ while True:
539
+ line = sys.stdin.readline()
540
+ if not line: # EOF/stdin closed
541
+ break
542
+ except:
543
+ pass
544
+
545
+ # Exit gracefully
546
+ sys.exit(0)
547
+ """
548
+ )
549
+
550
+ server_params = StdioServerParameters (
551
+ command = sys .executable ,
552
+ args = ["-c" , script_content ],
553
+ )
554
+
555
+ start_time = time .time ()
556
+
557
+ # Use anyio timeout to prevent test from hanging forever
558
+ with anyio .move_on_after (5.0 ) as cancel_scope :
559
+ async with stdio_client (server_params ) as (read_stream , write_stream ):
560
+ # Let the process start and begin reading stdin
561
+ await anyio .sleep (0.2 )
562
+ # Exit context triggers cleanup - process should exit from stdin closure
563
+ pass
564
+
565
+ if cancel_scope .cancelled_caught :
566
+ pytest .fail (
567
+ "stdio_client cleanup timed out after 5.0 seconds. "
568
+ "Process should have exited gracefully when stdin was closed."
569
+ )
570
+
571
+ end_time = time .time ()
572
+ elapsed = end_time - start_time
573
+
574
+ # Should complete quickly with just stdin closure (no signals needed)
575
+ assert elapsed < 3.0 , (
576
+ f"stdio_client cleanup took { elapsed :.1f} seconds for stdin-aware process. "
577
+ f"Expected < 3.0 seconds since process should exit on stdin closure."
578
+ )
579
+
580
+
581
+ @pytest .mark .anyio
582
+ async def test_stdio_client_stdin_close_ignored ():
583
+ """
584
+ Test that when a process ignores stdin closure, the shutdown sequence
585
+ properly escalates to SIGTERM.
586
+ """
587
+ # Create a Python script that ignores stdin closure but responds to SIGTERM
588
+ script_content = textwrap .dedent (
589
+ """
590
+ import signal
591
+ import sys
592
+ import time
593
+
594
+ # Set up SIGTERM handler to exit cleanly
595
+ def sigterm_handler(signum, frame):
596
+ sys.exit(0)
597
+
598
+ signal.signal(signal.SIGTERM, sigterm_handler)
599
+
600
+ # Close stdin immediately to simulate ignoring it
601
+ sys.stdin.close()
602
+
603
+ # Keep running until SIGTERM
604
+ while True:
605
+ time.sleep(0.1)
606
+ """
607
+ )
608
+
609
+ server_params = StdioServerParameters (
610
+ command = sys .executable ,
611
+ args = ["-c" , script_content ],
612
+ )
613
+
614
+ start_time = time .time ()
615
+
616
+ # Use anyio timeout to prevent test from hanging forever
617
+ with anyio .move_on_after (7.0 ) as cancel_scope :
618
+ async with stdio_client (server_params ) as (read_stream , write_stream ):
619
+ # Let the process start
620
+ await anyio .sleep (0.2 )
621
+ # Exit context triggers cleanup
622
+ pass
623
+
624
+ if cancel_scope .cancelled_caught :
625
+ pytest .fail (
626
+ "stdio_client cleanup timed out after 7.0 seconds. "
627
+ "Process should have been terminated via SIGTERM escalation."
628
+ )
629
+
630
+ end_time = time .time ()
631
+ elapsed = end_time - start_time
632
+
633
+ # Should take ~2 seconds (stdin close timeout) before SIGTERM is sent
634
+ # Total time should be between 2-4 seconds
635
+ assert 1.5 < elapsed < 4.5 , (
636
+ f"stdio_client cleanup took { elapsed :.1f} seconds for stdin-ignoring process. "
637
+ f"Expected between 2-4 seconds (2s stdin timeout + termination time)."
638
+ )
0 commit comments