Skip to content

Commit f5ae856

Browse files
committed
Prove concurrent listen streams demux their own acks
1 parent 738576c commit f5ae856

2 files changed

Lines changed: 60 additions & 0 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,15 @@ def __post_init__(self) -> None:
12311231
),
12321232
added_in="2026-07-28",
12331233
),
1234+
"subscriptions:listen:client:concurrent-demux": Requirement(
1235+
source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#multiple-concurrent-subscriptions",
1236+
behavior=(
1237+
"Concurrently open subscriptions each surface their own acknowledgment: with both listen "
1238+
"requests in flight before either ack arrives, each handle's honored filter is the subset "
1239+
"for its own request, routed by subscription id rather than broadcast to every open route."
1240+
),
1241+
added_in="2026-07-28",
1242+
),
12341243
"subscriptions:listen:client:iteration": Requirement(
12351244
source="sdk",
12361245
behavior=(

tests/interaction/lowlevel/test_subscriptions.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,54 @@ async def recording_listen(
8989
stock.close()
9090
async for _event in sub:
9191
raise NotImplementedError # unreachable: nothing was published
92+
93+
94+
@requirement("subscriptions:listen:client:concurrent-demux")
95+
@requirement("protocol:request-id:caller-supplied")
96+
async def test_concurrent_listen_streams_each_receive_their_own_ack(connect: Connect) -> None:
97+
"""Two subscriptions opened concurrently each surface the honored filter of their own request:
98+
ack frames route by subscription id, not broadcast to every open route.
99+
100+
The server gates both acks until both listen requests have arrived, so both client routes are
101+
live and unacknowledged when the first ack lands - a client that broadcast subscription frames
102+
would cross-pollute that ack into both handles.
103+
"""
104+
bus = InMemorySubscriptionBus()
105+
stock = ListenHandler(bus)
106+
arrived: list[types.RequestId] = []
107+
both_arrived = anyio.Event()
108+
109+
async def gated_listen(
110+
ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams
111+
) -> types.SubscriptionsListenResult:
112+
assert ctx.request_id is not None
113+
arrived.append(ctx.request_id)
114+
if len(arrived) == 2:
115+
both_arrived.set()
116+
with anyio.fail_after(10):
117+
await both_arrived.wait()
118+
return await stock(ctx, params)
119+
120+
server = Server("subs", on_subscriptions_listen=gated_listen)
121+
honored: dict[str, types.SubscriptionFilter] = {}
122+
123+
async with connect(server) as client:
124+
125+
async def open_tools() -> None:
126+
async with client.listen(tools_list_changed=True) as sub:
127+
honored["tools"] = sub.honored
128+
129+
async def open_prompts() -> None:
130+
async with client.listen(prompts_list_changed=True) as sub:
131+
honored["prompts"] = sub.honored
132+
133+
with anyio.fail_after(10):
134+
async with anyio.create_task_group() as tg: # pragma: no branch
135+
tg.start_soon(open_tools)
136+
tg.start_soon(open_prompts)
137+
138+
assert honored == {
139+
"tools": types.SubscriptionFilter(tools_list_changed=True),
140+
"prompts": types.SubscriptionFilter(prompts_list_changed=True),
141+
}
142+
assert len(set(arrived)) == 2

0 commit comments

Comments
 (0)