|
60 | 60 | _SHUTDOWN_WRITE_TIMEOUT: float = 1 |
61 | 61 | """Tighter bound for the shutdown-arm error write so a wedged transport can't hold session close.""" |
62 | 62 |
|
| 63 | +_DRAIN_INBOUND_ON_EOF_TIMEOUT: float = 5 |
| 64 | +"""Bound for letting already-accepted inbound requests write responses after read EOF.""" |
| 65 | + |
| 66 | +_DRAIN_INBOUND_ON_EOF_POLL_INTERVAL: float = 0.01 |
| 67 | +"""Polling interval while waiting for accepted inbound requests to finish.""" |
| 68 | + |
63 | 69 | TransportT = TypeVar("TransportT", bound=TransportContext, default=TransportContext) |
64 | 70 |
|
65 | 71 | PeerCancelMode = Literal["interrupt", "signal"] |
@@ -285,6 +291,7 @@ def __init__( |
285 | 291 | self._next_id = 0 |
286 | 292 | self._pending: dict[RequestId, _Pending] = {} |
287 | 293 | self._in_flight: dict[RequestId, _InFlight[TransportT]] = {} |
| 294 | + self._active_inbound_requests = 0 |
288 | 295 | self._tg: anyio.abc.TaskGroup | None = None |
289 | 296 | self._running = False |
290 | 297 | self._closed = False |
@@ -471,6 +478,7 @@ async def run( |
471 | 478 | self._running = False |
472 | 479 | self._closed = True |
473 | 480 | self._fan_out_closed() |
| 481 | + await self._drain_active_inbound_requests() |
474 | 482 | finally: |
475 | 483 | # Cancel in-flight handlers; otherwise the task-group join |
476 | 484 | # waits on handlers whose callers are already gone. |
@@ -545,6 +553,7 @@ async def _dispatch_request( |
545 | 553 | _progress_token=progress_token, |
546 | 554 | ) |
547 | 555 | scope = anyio.CancelScope() |
| 556 | + self._active_inbound_requests += 1 |
548 | 557 | # TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit |
549 | 558 | # rejecting with INVALID_REQUEST. Key coerced so a stringified |
550 | 559 | # `notifications/cancelled` id still correlates. |
@@ -659,6 +668,24 @@ def _fan_out_closed(self) -> None: |
659 | 668 | pass |
660 | 669 | self._pending.clear() |
661 | 670 |
|
| 671 | + async def _drain_active_inbound_requests(self) -> None: |
| 672 | + """Let accepted inbound requests finish response writes after read EOF. |
| 673 | +
|
| 674 | + A redirected-stdin stdio transport can reach EOF immediately after the last |
| 675 | + request is accepted. Treating EOF as immediate shutdown cancels handlers |
| 676 | + before their JSON-RPC responses reach stdout. Keep the write side open |
| 677 | + briefly so already-accepted requests can produce responses, then let the |
| 678 | + caller cancel any stragglers. |
| 679 | + """ |
| 680 | + with anyio.move_on_after(_DRAIN_INBOUND_ON_EOF_TIMEOUT) as scope: |
| 681 | + while self._active_inbound_requests: |
| 682 | + await anyio.sleep(_DRAIN_INBOUND_ON_EOF_POLL_INTERVAL) |
| 683 | + if scope.cancelled_caught: |
| 684 | + logger.warning( |
| 685 | + "timed out waiting for %d inbound request(s) to finish after read EOF", |
| 686 | + self._active_inbound_requests, |
| 687 | + ) |
| 688 | + |
662 | 689 | async def _handle_request( |
663 | 690 | self, |
664 | 691 | req: JSONRPCRequest, |
@@ -722,6 +749,8 @@ async def _handle_request( |
722 | 749 | await self._write_error(req.id, ErrorData(code=0, message=str(e))) |
723 | 750 | if self._raise_handler_exceptions: |
724 | 751 | raise |
| 752 | + finally: |
| 753 | + self._active_inbound_requests = max(0, self._active_inbound_requests - 1) |
725 | 754 | # No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id. |
726 | 755 |
|
727 | 756 | def _allocate_id(self) -> int: |
|
0 commit comments