Skip to content

Commit 82a7ade

Browse files
fix(connect): refusals the client has not read pause the client, never close it
Review r41 of #110: - B1: a refusal waits for the end of the server's frame in flight, so how many wait depends on how the server's reads split, not on the client. The 1024-refusal cap closed clients that were reading (after ~55k-63k refusals: 1000 held calls lost, no GOAWAY). Now the refusals are queued as bytes, and past _MAX_QUEUED_FOR_CLIENT (16 KiB) the client pump waits for the server pump to write them before it reads the client again: a client that reads keeps its calls, and one that does not stalls itself while the face holds at most the cap plus one read's refusals. - m1: the even-stream GOAWAY is queued behind the server's frame like the refusals, and the relay waits (at most _GOAWAY_GRACE_S) for it to be written before closing. - m2: the scaffold's comment says most of a burst queues and the rest come back REFUSED_STREAM, safe to retry. - m3: test_a_reading_client_past_the_limit_keeps_its_calls (3000 refusals behind a server frame in flight, all delivered, connection kept), test_a_client_that_reads_nothing_is_not_read (the face stops reading; the client's writes back up) and test_the_even_stream_goaway_waits_for_the_servers_frame each fail on 0aca1b9; test_at_the_scaffolds_limit_a_burst_fails_only_retry_safely (limit 1000, 1200 calls, every failure REFUSED_STREAM). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0aca1b9 commit 82a7ade

3 files changed

Lines changed: 196 additions & 24 deletions

File tree

‎templates/connect/connect_gen.py‎

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,13 @@
7373
# refused alone, with REFUSED_STREAM, which a client retries safely: the server never
7474
# processed it. What the face tracks per connection is bounded by it.
7575
DEFAULT_MAX_OPEN_CALLS = 1000
76-
# Frames for the client (refusals) the relay holds while the server's frame in flight
77-
# completes; a client that makes it hold more is closed.
78-
_MAX_QUEUED_FOR_CLIENT = 1024
76+
# Bytes of frames for the client (refusals, a GOAWAY) the relay holds while the server's
77+
# frame in flight completes; past it the relay reads nothing more from the client until
78+
# they are written, so a client that opens calls faster than it reads them stalls itself.
79+
_MAX_QUEUED_FOR_CLIENT = 16 * 1024
80+
# How long a closing relay waits for the server's frame in flight to complete, so the
81+
# client's GOAWAY lands between two frames.
82+
_GOAWAY_GRACE_S = 5.0
7983
# The most of a SETTINGS or GOAWAY payload the relay reads; the rest is skipped.
8084
_CONTROL_PAYLOAD = 256
8185
_ENVELOPE = struct.Struct(">BI")
@@ -338,9 +342,11 @@ async def _relay(self, reader, writer, head):
338342
what the server allows at once (SETTINGS_MAX_CONCURRENT_STREAMS,
339343
``DEFAULT_MAX_OPEN_CALLS`` when it advertises none) is refused alone: the client
340344
gets RST_STREAM REFUSED_STREAM, and the server its header block, which keeps the
341-
connection's header compression in step, then the same reset. A client that opens
342-
an even stream, which no HTTP/2 client does, gets a GOAWAY naming why, and the
343-
connection is closed."""
345+
connection's header compression in step, then the same reset. A refusal is
346+
written between two of the server's frames; while more than
347+
``_MAX_QUEUED_FOR_CLIENT`` bytes of them wait, the client is not read. A client
348+
that opens an even stream, which no HTTP/2 client does, gets a GOAWAY naming why,
349+
and the connection is closed."""
344350
upstream_reader, upstream_writer = await asyncio.open_connection(*self.grpc_address)
345351
upstream_writer.write(head)
346352
loop = asyncio.get_running_loop()
@@ -349,7 +355,8 @@ async def _relay(self, reader, writer, head):
349355
highest = accepted = 0
350356
refusing = 0 # the refused stream whose header block is still arriving
351357
max_calls = DEFAULT_MAX_OPEN_CALLS
352-
to_client: list[bytes] = [] # frames for the client, written at a frame boundary
358+
to_client = bytearray() # frames for the client, written at a frame boundary
359+
flushed = asyncio.Event() # set when the server pump wrote them
353360
told = False # the face wrote to the client since the client pump last drained
354361

355362
def from_client(kind, flags, stream, payload):
@@ -395,24 +402,29 @@ def from_server(kind, flags, stream, payload):
395402

396403
def tell_client(frame):
397404
nonlocal told
398-
# Only between two of the server's frames: queued while one is half relayed.
399-
# A client that keeps opening calls while it reads nothing is not served.
405+
# Only between two of the server's frames: queued while one is half relayed,
406+
# and written by the server pump at that frame's end.
400407
if server.at_boundary and not to_client:
401408
writer.write(frame)
402409
told = True
403-
elif len(to_client) >= _MAX_QUEUED_FOR_CLIENT:
404-
raise _ProtocolError("the client opens calls past the limit and reads nothing")
405410
else:
406-
to_client.append(frame)
411+
to_client.extend(frame)
412+
413+
async def written():
414+
"""Wait until the server pump has written what is queued for the client."""
415+
while to_client:
416+
flushed.clear()
417+
await flushed.wait()
407418

408419
def relay(chunk, sink, frames, ends, queued):
409420
at = 0
410421
for end, after in ends:
411422
sink.write(chunk[at:end] + after)
412423
at = end
413424
if queued:
414-
sink.write(b"".join(queued))
425+
sink.write(bytes(queued))
415426
queued.clear()
427+
flushed.set()
416428
if frames.error is None:
417429
sink.write(chunk[at:])
418430

@@ -424,14 +436,20 @@ async def pump(source, sink, frames, queued):
424436
relay(chunk, sink, frames, frames.feed(chunk), queued)
425437
if frames.error is not None:
426438
_log.info("connect face: closing a gRPC connection: %s", frames.error)
427-
if server.at_boundary:
428-
reason = str(frames.error)
429-
writer.write(_goaway(accepted, _PROTOCOL_ERROR, reason))
439+
tell_client(_goaway(accepted, _PROTOCOL_ERROR, str(frames.error)))
440+
try:
441+
await asyncio.wait_for(written(), _GOAWAY_GRACE_S)
442+
except TimeoutError:
443+
pass
430444
return
431445
await sink.drain()
432-
if sink is not writer and told:
433-
told = False
434-
await writer.drain() # the refusals: a client must read them
446+
if sink is not writer:
447+
# The refusals: a client must read them before it is read again.
448+
if len(to_client) >= _MAX_QUEUED_FOR_CLIENT:
449+
await written()
450+
if told:
451+
told = False
452+
await writer.drain()
435453
except (ConnectionError, OSError):
436454
pass
437455

@@ -445,7 +463,7 @@ async def idle():
445463
await asyncio.sleep(remaining if remaining > 0 else self.idle_timeout)
446464

447465
tasks = (
448-
asyncio.ensure_future(pump(reader, upstream_writer, client, [])),
466+
asyncio.ensure_future(pump(reader, upstream_writer, client, bytearray())),
449467
asyncio.ensure_future(pump(upstream_reader, writer, server, to_client)),
450468
asyncio.ensure_future(idle()),
451469
)

‎templates/grpc/code/src/rpc/server.py.tmpl‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ def register(server: grpc.aio.Server) -> None:
3131

3232

3333
# How many calls one client connection may have open at once, advertised to clients
34-
# (SETTINGS_MAX_CONCURRENT_STREAMS) so a burst queues in the client instead of being
35-
# refused. The Connect face reads the same setting and enforces it.
34+
# (SETTINGS_MAX_CONCURRENT_STREAMS) so most of a burst queues in the client. The rest
35+
# come back REFUSED_STREAM (grpcio counts a stream a little longer than the client
36+
# does), which the server never processed and a client may safely retry. The Connect
37+
# face reads the same setting and enforces it.
3638
MAX_CONCURRENT_STREAMS = 1000
3739

3840

‎testdata/connect/test_connect_gen.py‎

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import asyncio
55
import base64
6+
import collections
67
import json
78
import struct
89

@@ -437,12 +438,25 @@ def opens(stream):
437438
async def silent_upstream():
438439
"""A gRPC address that accepts, reads everything and never answers, so only the face's
439440
own rules end a connection relayed to it, and ``face(**options)`` serving over it.
440-
``state["close_after"]`` makes it hang up after that many seconds instead."""
441+
``state["close_after"]`` makes it hang up after that many seconds instead, and
442+
``state["mid_frame"]`` (an event) makes it send half a frame first and the rest when
443+
the event is set."""
441444
held, faces = [], []
442-
state = {"close_after": None, "received": bytearray()}
445+
state = {"close_after": None, "received": bytearray(), "mid_frame": None}
443446

444447
async def accept(reader, writer):
445448
held.append(writer)
449+
if state["mid_frame"] is not None:
450+
# Half a PING, the rest once the test sets the event: the server is mid-frame.
451+
ping = h2_frame(0x6, 0, 0, b"12345678")
452+
writer.write(ping[:13])
453+
await writer.drain()
454+
455+
async def finish():
456+
await state["mid_frame"].wait()
457+
writer.write(ping[13:])
458+
459+
asyncio.ensure_future(finish())
446460
if state["close_after"] is not None:
447461
await asyncio.sleep(state["close_after"])
448462
writer.close()
@@ -635,3 +649,141 @@ async def test_the_relay_ends_when_the_grpc_server_hangs_up(silent_upstream):
635649
assert await asyncio.wait_for(reader.read(), 1) == b""
636650
finally:
637651
writer.close()
652+
653+
654+
class FrameReader:
655+
"""Reads a connection's frames as they come, counting each type."""
656+
657+
def __init__(self, reader):
658+
self.kinds = collections.Counter()
659+
self.frames = []
660+
self.eof = False
661+
self._task = asyncio.ensure_future(self._read(reader))
662+
663+
async def _read(self, reader):
664+
buffer = bytearray()
665+
while chunk := await reader.read(65536):
666+
buffer += chunk
667+
while len(buffer) >= 9:
668+
high, low, kind, flags, stream = struct.unpack_from(">BHBBI", buffer)
669+
size = high << 16 | low
670+
if len(buffer) < 9 + size:
671+
break
672+
self.kinds[kind] += 1
673+
if kind != 0x3:
674+
self.frames.append((kind, flags, stream, bytes(buffer[9 : 9 + size])))
675+
del buffer[: 9 + size]
676+
self.eof = True
677+
678+
async def until(self, condition, seconds):
679+
async with asyncio.timeout(seconds):
680+
while not condition():
681+
await asyncio.sleep(0.02)
682+
683+
def close(self):
684+
self._task.cancel()
685+
686+
687+
async def test_a_reading_client_past_the_limit_keeps_its_calls(silent_upstream):
688+
# The server is mid-frame, so every refusal waits for that frame's end: far more of
689+
# them than the face holds. A client that reads keeps its connection and its calls,
690+
# and gets every refusal once the frame completes.
691+
face, state = silent_upstream
692+
state["mid_frame"] = asyncio.Event()
693+
listener = await face()
694+
calls, past = connect_gen.DEFAULT_MAX_OPEN_CALLS, 3000
695+
reader, writer = await asyncio.open_connection("127.0.0.1", listener.port)
696+
frames = FrameReader(reader)
697+
try:
698+
writer.write(PREFACE + SETTINGS + b"".join(opens(2 * n + 1) for n in range(calls)))
699+
await writer.drain()
700+
await asyncio.sleep(0.2)
701+
writer.write(b"".join(opens(2 * n + 1) for n in range(calls, calls + past)))
702+
await writer.drain()
703+
await asyncio.sleep(0.3)
704+
assert not frames.eof and listener._connections
705+
state["mid_frame"].set()
706+
await frames.until(lambda: frames.kinds[0x3] == past, 5)
707+
assert not frames.eof and listener._connections
708+
upstream = read_frames(bytes(state["received"])[len(PREFACE) :])
709+
assert sum(1 for f in upstream if f[0] == 0x1) == calls + past
710+
finally:
711+
frames.close()
712+
writer.close()
713+
714+
715+
async def test_a_client_that_reads_nothing_is_not_read(silent_upstream):
716+
# The refusals cannot be written (the server stays mid-frame): past what the face
717+
# holds it stops reading the client, so the client's own writes back up and the
718+
# face's memory does not grow with them.
719+
face, state = silent_upstream
720+
state["mid_frame"] = asyncio.Event()
721+
listener = await face()
722+
calls = connect_gen.DEFAULT_MAX_OPEN_CALLS
723+
reader, writer = await asyncio.open_connection("127.0.0.1", listener.port)
724+
try:
725+
writer.write(PREFACE + SETTINGS + b"".join(opens(2 * n + 1) for n in range(calls)))
726+
await writer.drain()
727+
await asyncio.sleep(0.2)
728+
flood = b"".join(opens(2 * n + 1) for n in range(calls, calls + 1_000_000))
729+
writer.write(flood)
730+
with pytest.raises(TimeoutError):
731+
await asyncio.wait_for(writer.drain(), 2)
732+
forwarded = read_frames(bytes(state["received"])[len(PREFACE) :])
733+
assert sum(1 for f in forwarded if f[0] == 0x1) < calls + 20_000
734+
assert listener._connections
735+
finally:
736+
writer.transport.abort()
737+
738+
739+
async def test_the_even_stream_goaway_waits_for_the_servers_frame(silent_upstream):
740+
face, state = silent_upstream
741+
state["mid_frame"] = asyncio.Event()
742+
listener = await face()
743+
reader, writer = await asyncio.open_connection("127.0.0.1", listener.port)
744+
frames = FrameReader(reader)
745+
try:
746+
writer.write(PREFACE + SETTINGS + opens(1))
747+
await writer.drain()
748+
await asyncio.sleep(0.2)
749+
writer.write(opens(2))
750+
await writer.drain()
751+
await asyncio.sleep(0.2)
752+
state["mid_frame"].set()
753+
await frames.until(lambda: frames.eof, 3)
754+
kinds = [f[0] for f in frames.frames]
755+
assert kinds == [0x6, 0x7] # the server's frame whole, then the GOAWAY
756+
last, code = struct.unpack_from(">II", frames.frames[-1][3])
757+
assert (last, code) == (1, 0x1)
758+
finally:
759+
frames.close()
760+
writer.close()
761+
762+
763+
async def test_at_the_scaffolds_limit_a_burst_fails_only_retry_safely():
764+
# The scaffold advertises 1000: most of a 1200-call burst queues in the client, and
765+
# any call grpcio or the face refuses comes back REFUSED_STREAM, never processed.
766+
server = grpc.aio.server(options=[("grpc.max_concurrent_streams", 1000)])
767+
server.add_generic_rpc_handlers((_handler(),))
768+
grpc_port = server.add_insecure_port("127.0.0.1:0")
769+
await server.start()
770+
listener = await connect_gen.serve(0, grpc_port, host="127.0.0.1")
771+
try:
772+
async with grpc.aio.insecure_channel(f"127.0.0.1:{listener.port}") as channel:
773+
say_call = channel.unary_unary(
774+
"/echo.v1.Echo/Say",
775+
request_serializer=Msg.SerializeToString,
776+
response_deserializer=Msg.FromString,
777+
)
778+
answers = await asyncio.gather(
779+
*[say_call(Msg(text=str(n)), timeout=30) for n in range(1200)],
780+
return_exceptions=True,
781+
)
782+
refused = [a for a in answers if isinstance(a, Exception)]
783+
assert all("error code 7" in a.details() for a in refused), {
784+
a.details() for a in refused
785+
}
786+
assert len(refused) < 600
787+
finally:
788+
await listener.stop()
789+
await server.stop(None)

0 commit comments

Comments
 (0)