Skip to content

Commit 8a076d0

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 6a6c544 commit 8a076d0

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
@@ -477,6 +477,65 @@ async def test_stdio_server_serves_in_place_when_the_standard_descriptors_are_in
477477
os.close(saved2)
478478

479479

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

0 commit comments

Comments
 (0)