Skip to content

Commit fd151e0

Browse files
fix(connect): never claim a call unprocessed, never write inside a header block, relay at main's speed
Review r50 of #110: - B1: past DEFAULT_MAX_OPEN_CALLS on a server advertising no limit, the face forwarded the call's header block (header compression needs it) and the server started bidi and client-streaming calls, while the client was told REFUSED_STREAM, "not processed", which gRPC clients retry on their own. The client now gets ENHANCE_YOUR_CALM (RESOURCE_EXHAUSTED, not retried on its own) and the server CANCEL. Past a limit the server advertised, the face relays the call untouched and the server refuses it itself (m2: one reset, not two). - M1: _Frames tracks an open header block (HEADERS or PUSH_PROMISE without END_HEADERS, up to the CONTINUATION that ends it); the face's frames are never written inside one (RFC 9113 6.10). - M2: relay writes only where it inserts a frame, and feed reads whole frame headers in place. 200 MB of 64 B frames: 2.26 s, as main; 16 KiB: 0.14 s (main 0.21 s). - m1: nothing is written to, or relayed from, a client that is gone. - m3: a GOAWAY that could not be delivered is logged. - On a protocol error the frames before the offending one still reach the server, so the GOAWAY's last stream is one it saw. Tests (each fails on 82a7ade): test_past_an_advertised_limit_only_the_server_refuses, test_past_the_faces_own_limit_a_call_is_never_said_unprocessed, test_no_frame_is_written_inside_a_servers_header_block, and the reset codes in test_a_call_past_the_limit_is_refused_alone and test_a_burst_past_the_limit_loses_no_call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 82a7ade commit fd151e0

3 files changed

Lines changed: 241 additions & 56 deletions

File tree

‎templates/connect/connect_gen.py‎

Lines changed: 85 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,17 @@
6666
_DATA, _HEADERS, _RST_STREAM, _SETTINGS, _GOAWAY, _CONTINUATION = 0x0, 0x1, 0x3, 0x4, 0x7, 0x9
6767
_END_STREAM_FLAG, _ACK_FLAG, _END_HEADERS_FLAG = 0x1, 0x1, 0x4
6868
_MAX_CONCURRENT_STREAMS = 0x3
69-
_PROTOCOL_ERROR, _REFUSED_STREAM = 0x1, 0x7
69+
_PUSH_PROMISE = 0x5
70+
_PROTOCOL_ERROR, _CANCEL, _ENHANCE_YOUR_CALM = 0x1, 0x8, 0xB
7071
# Calls one relayed connection may hold open when the gRPC server advertises no
7172
# SETTINGS_MAX_CONCURRENT_STREAMS (grpcio advertises none unless the service sets
72-
# grpc.max_concurrent_streams, as the scaffold's server does). A call past the limit is
73-
# refused alone, with REFUSED_STREAM, which a client retries safely: the server never
74-
# processed it. What the face tracks per connection is bounded by it.
73+
# grpc.max_concurrent_streams, as the scaffold's server does). Past it the face resets
74+
# the call alone: ENHANCE_YOUR_CALM to the client (gRPC's RESOURCE_EXHAUSTED, which a
75+
# client does not retry on its own), and CANCEL to the server. The server still receives
76+
# the call's header block, which keeps the connection's header compression in step, so it
77+
# may have started the call before the cancel: the face never promises it did not. What
78+
# the face tracks per connection is bounded by it. When the server advertises a limit the
79+
# face enforces nothing: the server refuses what is past it (REFUSED_STREAM), truthfully.
7580
DEFAULT_MAX_OPEN_CALLS = 1000
7681
# Bytes of frames for the client (refusals, a GOAWAY) the relay holds while the server's
7782
# frame in flight completes; past it the relay reads nothing more from the client until
@@ -265,49 +270,72 @@ def __init__(self, on_frame):
265270
self._payload = bytearray()
266271
self._keep = 0 # payload bytes still to read
267272
self._skip = 0 # payload bytes still to skip
273+
self._in_block = False # inside a header block: HEADERS ... CONTINUATION
268274
self.error: _ProtocolError | None = None
269275

270276
@property
271277
def at_boundary(self) -> bool:
272-
return not self._header and not self._keep and not self._skip
273-
274-
def feed(self, data: bytes) -> list[tuple[int, bytes]]:
278+
"""Between two frames, and outside a header block: where another frame may go
279+
(RFC 9113 section 6.10: nothing comes between a HEADERS and its CONTINUATION)."""
280+
return not self._header and not self._keep and not self._skip and not self._in_block
281+
282+
def feed(self, data: bytes, every_end: bool = False) -> list[tuple[int, bytes]]:
283+
"""Where frames end in ``data``: each end where ``on_frame`` returned bytes to
284+
write after the frame, and, with ``every_end``, each end where another frame may
285+
go. After a ``_ProtocolError``, the last end is the last place another frame
286+
may go before the offending frame."""
275287
ends: list[tuple[int, bytes]] = []
276-
at = 0
288+
at, size = 0, len(data)
289+
clear = 0 if self.at_boundary else -1 # the last such place in ``data``
277290
try:
278-
while at < len(data):
291+
while at < size:
279292
if self._keep:
280-
step = min(self._keep, len(data) - at)
293+
step = min(self._keep, size - at)
281294
self._payload += data[at : at + step]
282295
self._keep -= step
283296
at += step
284-
if not self._keep:
285-
self._emit()
297+
if self._keep:
298+
break
299+
self._emit()
286300
elif self._skip:
287-
step = min(self._skip, len(data) - at)
301+
step = min(self._skip, size - at)
288302
self._skip -= step
289303
at += step
290-
else:
291-
step = min(_FRAME.size - len(self._header), len(data) - at)
304+
if self._skip:
305+
break
306+
elif self._header or size - at < _FRAME.size:
307+
step = min(_FRAME.size - len(self._header), size - at)
292308
self._header += data[at : at + step]
293309
at += step
294310
if len(self._header) < _FRAME.size:
295-
continue
296-
high, low, kind, flags, stream = _FRAME.unpack(self._header)
311+
break
312+
self._start(*_FRAME.unpack(self._header))
297313
self._header.clear()
298-
length = high << 16 | low
299-
self._frame = (kind, flags, stream & 0x7FFFFFFF)
300-
self._keep = min(length, _CONTROL_PAYLOAD) if kind in (_SETTINGS, _GOAWAY) else 0
301-
self._skip = length - self._keep
302-
if not self._keep:
303-
self._emit()
304-
if self.at_boundary:
305-
ends.append((at, self._after))
306-
self._after = b""
314+
else: # a whole frame header in this chunk: read it in place
315+
self._start(*_FRAME.unpack_from(data, at))
316+
at += _FRAME.size
317+
if not (self._keep or self._skip or self._in_block):
318+
clear = at
319+
if every_end or self._after:
320+
ends.append((at, self._after))
321+
self._after = b""
307322
except _ProtocolError as refusal:
308323
self.error = refusal
324+
if clear > (ends[-1][0] if ends else 0):
325+
ends.append((clear, b""))
309326
return ends
310327

328+
def _start(self, high, low, kind, flags, stream):
329+
"""A frame header read: what of its payload to keep, and its frame if none."""
330+
length = high << 16 | low
331+
self._frame = (kind, flags, stream & 0x7FFFFFFF)
332+
if kind in (_HEADERS, _PUSH_PROMISE, _CONTINUATION):
333+
self._in_block = not flags & _END_HEADERS_FLAG
334+
self._keep = min(length, _CONTROL_PAYLOAD) if kind in (_SETTINGS, _GOAWAY) else 0
335+
self._skip = length - self._keep
336+
if not self._keep:
337+
self._emit()
338+
311339
def _emit(self):
312340
payload = bytes(self._payload)
313341
self._payload.clear()
@@ -339,14 +367,13 @@ async def _relay(self, reader, writer, head):
339367
The frame headers say which calls are open: a client HEADERS on a new stream (odd,
340368
above every stream before it) opens one, and the server's END_STREAM, either
341369
side's RST_STREAM, or the server's GOAWAY above the stream ends it. A call past
342-
what the server allows at once (SETTINGS_MAX_CONCURRENT_STREAMS,
343-
``DEFAULT_MAX_OPEN_CALLS`` when it advertises none) is refused alone: the client
344-
gets RST_STREAM REFUSED_STREAM, and the server its header block, which keeps the
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."""
370+
the server's own SETTINGS_MAX_CONCURRENT_STREAMS is relayed as it came, for the
371+
server to refuse. When the server advertises none, a call past
372+
``DEFAULT_MAX_OPEN_CALLS`` is reset alone (see there). The face's frames for the
373+
client are written only between two of the server's frames and outside a header
374+
block; while more than ``_MAX_QUEUED_FOR_CLIENT`` bytes of them wait, the client
375+
is not read. A client that opens an even stream, which no HTTP/2 client does, gets
376+
a GOAWAY naming why, and the connection is closed."""
350377
upstream_reader, upstream_writer = await asyncio.open_connection(*self.grpc_address)
351378
upstream_writer.write(head)
352379
loop = asyncio.get_running_loop()
@@ -355,6 +382,7 @@ async def _relay(self, reader, writer, head):
355382
highest = accepted = 0
356383
refusing = 0 # the refused stream whose header block is still arriving
357384
max_calls = DEFAULT_MAX_OPEN_CALLS
385+
advertised = False # the server sent SETTINGS_MAX_CONCURRENT_STREAMS
358386
to_client = bytearray() # frames for the client, written at a frame boundary
359387
flushed = asyncio.Event() # set when the server pump wrote them
360388
told = False # the face wrote to the client since the client pump last drained
@@ -368,9 +396,10 @@ def from_client(kind, flags, stream, payload):
368396
if len(calls) < max_calls:
369397
calls.add(stream)
370398
accepted = stream
371-
else:
399+
elif not advertised:
372400
refusing = stream
373-
tell_client(_rst_stream(stream, _REFUSED_STREAM))
401+
tell_client(_rst_stream(stream, _ENHANCE_YOUR_CALM))
402+
# Past an advertised limit the server refuses the call itself.
374403
elif kind == _HEADERS and stream % 2 == 0:
375404
raise _ProtocolError(f"HEADERS on stream {stream}, which a server opens")
376405
elif kind == _RST_STREAM:
@@ -380,18 +409,18 @@ def from_client(kind, flags, stream, payload):
380409
if refusing and stream == refusing and kind in (_HEADERS, _CONTINUATION):
381410
if flags & _END_HEADERS_FLAG:
382411
refusing = 0
383-
return _rst_stream(stream, _REFUSED_STREAM) # after its header block
412+
return _rst_stream(stream, _CANCEL) # after its header block
384413
return None
385414

386415
def from_server(kind, flags, stream, payload):
387-
nonlocal max_calls
416+
nonlocal max_calls, advertised
388417
if kind == _RST_STREAM or (kind in (_DATA, _HEADERS) and flags & _END_STREAM_FLAG):
389418
calls.discard(stream)
390419
elif kind == _SETTINGS and not flags & _ACK_FLAG:
391420
for at in range(0, len(payload) - 5, 6):
392421
key, value = struct.unpack_from(">HI", payload, at)
393422
if key == _MAX_CONCURRENT_STREAMS:
394-
max_calls = value
423+
max_calls, advertised = value, True
395424
elif kind == _GOAWAY and len(payload) >= 4:
396425
# Streams above the last one the server processes are never answered.
397426
last_stream = struct.unpack_from(">I", payload)[0] & 0x7FFFFFFF
@@ -403,7 +432,9 @@ def from_server(kind, flags, stream, payload):
403432
def tell_client(frame):
404433
nonlocal told
405434
# 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.
435+
# and written by the server pump at that frame's end. Nothing to a client gone.
436+
if writer.is_closing():
437+
return
407438
if server.at_boundary and not to_client:
408439
writer.write(frame)
409440
told = True
@@ -417,30 +448,40 @@ async def written():
417448
await flushed.wait()
418449

419450
def relay(chunk, sink, frames, ends, queued):
451+
# As few writes as the frames to insert allow: one per insertion point.
420452
at = 0
421453
for end, after in ends:
454+
if not after and not queued:
455+
continue
422456
sink.write(chunk[at:end] + after)
423457
at = end
424458
if queued:
425459
sink.write(bytes(queued))
426460
queued.clear()
427461
flushed.set()
428-
if frames.error is None:
429-
sink.write(chunk[at:])
462+
stop = len(chunk) if frames.error is None else (ends[-1][0] if ends else at)
463+
if stop > at:
464+
sink.write(chunk[at:stop])
430465

431466
async def pump(source, sink, frames, queued):
432467
nonlocal last, told
433468
try:
434469
while chunk := await source.read(65536):
470+
if writer.is_closing():
471+
return # the client is gone: nothing left to relay
435472
last = loop.time()
436-
relay(chunk, sink, frames, frames.feed(chunk), queued)
473+
relay(chunk, sink, frames, frames.feed(chunk, bool(queued)), queued)
437474
if frames.error is not None:
438475
_log.info("connect face: closing a gRPC connection: %s", frames.error)
439476
tell_client(_goaway(accepted, _PROTOCOL_ERROR, str(frames.error)))
440477
try:
441478
await asyncio.wait_for(written(), _GOAWAY_GRACE_S)
442479
except TimeoutError:
443-
pass
480+
_log.warning(
481+
"connect face: the GOAWAY was not delivered: the server's "
482+
"frame in flight did not complete within %s s",
483+
_GOAWAY_GRACE_S,
484+
)
444485
return
445486
await sink.drain()
446487
if sink is not writer:

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def register(server: grpc.aio.Server) -> None:
3434
# (SETTINGS_MAX_CONCURRENT_STREAMS) so most of a burst queues in the client. The rest
3535
# come back REFUSED_STREAM (grpcio counts a stream a little longer than the client
3636
# does), which the server never processed and a client may safely retry. The Connect
37-
# face reads the same setting and enforces it.
37+
# face relays a call past it as it came, for this server to refuse.
3838
MAX_CONCURRENT_STREAMS = 1000
3939

4040

0 commit comments

Comments
 (0)