@@ -474,11 +474,12 @@ async def parked() -> None:
474474
475475 tg .start_soon (parked )
476476 await entered .wait ()
477- # The counter mints 1 and 2, then skips the occupied 3 to 4.
477+ # The counter is advanced to 3 when "3" is supplied, so
478+ # subsequent mints produce 4, 5, 6 — never revisiting 3.
478479 for _ in range (3 ):
479480 await client .send_raw_request ("plain" , None )
480481 release .set ()
481- assert [request_id for request_id in seen_ids if request_id != "3" ] == [1 , 2 , 4 ]
482+ assert [request_id for request_id in seen_ids if request_id != "3" ] == [4 , 5 , 6 ]
482483
483484
484485@pytest .mark .anyio
@@ -512,6 +513,64 @@ async def first() -> None:
512513 assert await client .send_raw_request ("again" , None , {"request_id" : "7" }) == {}
513514
514515
516+ @pytest .mark .anyio
517+ async def test_minted_ids_never_reuse_a_completed_caller_supplied_id (pair_factory : PairFactory ):
518+ """Regression: after a caller-supplied integer id completes, the mint counter
519+ must have advanced past it so no future minted id collides. This is the bug
520+ from GH-3126: the counter could land on a previously-used supplied id because
521+ the guard only checked `_pending`/`_in_flight_ids` (cleared on completion)."""
522+ seen_ids : list [RequestId | None ] = []
523+
524+ async def track (
525+ ctx : DispatchContext [TransportContext ], method : str , params : Mapping [str , Any ] | None
526+ ) -> dict [str , Any ]:
527+ seen_ids .append (ctx .request_id )
528+ return {}
529+
530+ async with running_pair (pair_factory , server_on_request = track ) as (client , * _ ):
531+ with anyio .fail_after (5 ):
532+ # Send a request with a caller-supplied integer id.
533+ await client .send_raw_request ("supplied" , None , {"request_id" : 5 })
534+ # Now send several auto-minted requests. None should reuse id 5.
535+ for _ in range (6 ):
536+ await client .send_raw_request ("minted" , None )
537+
538+ # The first id is the supplied 5; the rest are minted sequentially starting
539+ # above 5 (i.e. 6, 7, 8, 9, 10, 11).
540+ assert seen_ids [0 ] == 5
541+ minted_ids = seen_ids [1 :]
542+ assert 5 not in minted_ids
543+ # Verify they are unique and monotonically increasing integers > 5.
544+ assert all (isinstance (i , int ) and i > 5 for i in minted_ids )
545+ assert len (minted_ids ) == len (set (minted_ids ))
546+
547+
548+ @pytest .mark .anyio
549+ async def test_minted_ids_never_reuse_a_completed_numeric_string_id (pair_factory : PairFactory ):
550+ """Same as above but with a numeric-string supplied id ("3"), which coerces to
551+ int 3 in the collision domain. Minted ids must skip past 3."""
552+ seen_ids : list [RequestId | None ] = []
553+
554+ async def track (
555+ ctx : DispatchContext [TransportContext ], method : str , params : Mapping [str , Any ] | None
556+ ) -> dict [str , Any ]:
557+ seen_ids .append (ctx .request_id )
558+ return {}
559+
560+ async with running_pair (pair_factory , server_on_request = track ) as (client , * _ ):
561+ with anyio .fail_after (5 ):
562+ await client .send_raw_request ("supplied" , None , {"request_id" : "3" })
563+ for _ in range (4 ):
564+ await client .send_raw_request ("minted" , None )
565+
566+ assert seen_ids [0 ] == "3"
567+ minted_ids = seen_ids [1 :]
568+ # 3 should never appear (even though "3" completed and left _pending/_in_flight).
569+ assert 3 not in minted_ids
570+ assert all (isinstance (i , int ) and i > 3 for i in minted_ids )
571+ assert len (minted_ids ) == len (set (minted_ids ))
572+
573+
515574@pytest .mark .anyio
516575async def test_notify_intercept_sees_every_notification_and_consumes_on_true (pair_factory : PairFactory ):
517576 """The intercept sees every inbound notification; a frame it consumes never reaches `on_notify`, the rest do."""
0 commit comments