Skip to content

Commit 8ed5083

Browse files
committed
Roll forward when a failed claim cannot be rolled back
If a mid-claim OSError is followed by a rollback failure, fd is stuck on the diversion but the private duplicate still holds the wire, so the transport now keeps the claim and serves from the duplicate like a completed claim; the restore at exit gets another chance. Previously the claim was released while fd stayed diverted, letting a later stdio_server() claim the diversion as its wire.
1 parent 8c46858 commit 8ed5083

2 files changed

Lines changed: 67 additions & 4 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,14 @@ def unclaim() -> None:
9292
if sys.platform == "win32": # pragma: no cover
9393
rebind_std_handle_to_fd(fd)
9494
except OSError:
95-
if private_fd is not None:
96-
_restore_fd(fd, private_fd)
97-
os.close(private_fd)
98-
return stream.buffer, unclaim
95+
# Roll back and serve in place; if the rollback itself fails, fall
96+
# through and roll forward instead: fd is stuck on the diversion, but
97+
# the private duplicate still holds the wire, so serve from it with
98+
# the claim held (the restore at exit gets another chance).
99+
if private_fd is None or _restore_fd(fd, private_fd):
100+
if private_fd is not None:
101+
os.close(private_fd)
102+
return stream.buffer, unclaim
99103

100104
def restore() -> None:
101105
# Drain text buffered during the claim (a stray print) to the diversion.

tests/server/test_stdio.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,65 @@ async def test_stdio_server_serves_in_place_when_the_standard_descriptors_are_in
476476
os.close(saved2)
477477

478478

479+
@pytest.mark.anyio
480+
async def test_a_claim_that_cannot_roll_back_serves_the_wire_from_the_private_duplicate(
481+
monkeypatch: pytest.MonkeyPatch,
482+
) -> None:
483+
"""A mid-claim failure whose rollback also fails rolls forward instead of unclaiming.
484+
485+
SDK-defined behavior: with fd 0 stuck on the diversion, the transport keeps the
486+
claim and serves the protocol from the private duplicate; the restore at exit
487+
still runs and puts fd 0 back on the pipe.
488+
"""
489+
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
490+
with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w):
491+
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
492+
493+
# The first close (the diversion fd) and the second dup2 (the rollback)
494+
# fail: the claim can neither complete nor be undone. One-shot and
495+
# call-counted injectors, as elsewhere in this file.
496+
real_close = os.close
497+
close_armed = [True]
498+
499+
def failing_first_close(fd: int) -> None:
500+
if close_armed[0]:
501+
close_armed[0] = False
502+
raise OSError("injected close failure")
503+
real_close(fd)
504+
505+
real_dup2 = os.dup2
506+
dup2_calls: list[int] = []
507+
508+
def flaky_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
509+
dup2_calls.append(fd)
510+
if len(dup2_calls) == 2:
511+
raise OSError("injected rollback failure")
512+
return real_dup2(fd, fd2, inheritable)
513+
514+
monkeypatch.setattr(os, "close", failing_first_close)
515+
monkeypatch.setattr(os, "dup2", flaky_dup2)
516+
517+
with anyio.fail_after(5):
518+
async with stdio_server() as (read_stream, write_stream):
519+
async with read_stream: # pragma: no branch
520+
# fd 0 is stuck on the null device, yet the wire still flows.
521+
devnull_probe = os.open(os.devnull, os.O_RDONLY)
522+
try:
523+
assert os.path.sameopenfile(0, devnull_probe)
524+
finally:
525+
os.close(devnull_probe)
526+
527+
os.write(in_w, _frame(request))
528+
received = await read_stream.receive()
529+
assert isinstance(received, SessionMessage)
530+
assert received.message == request
531+
os.close(in_w)
532+
await write_stream.aclose()
533+
534+
# The exit restore (third dup2) succeeded: fd 0 is back on the pipe.
535+
assert os.path.sameopenfile(0, in_r)
536+
537+
479538
class _GatedStdin(io.RawIOBase):
480539
"""Raw stdin double: serves its frames, then blocks until released before EOF.
481540

0 commit comments

Comments
 (0)