Skip to content

Commit 097a4fc

Browse files
Merge pull request #117 from codefly-dev/fix/connect-server-settings
fix(connect): the face knows the server's limit before the client's first call, and tells it its own
2 parents 940bc54 + dbf27d2 commit 097a4fc

2 files changed

Lines changed: 123 additions & 34 deletions

File tree

‎templates/connect/connect_gen.py‎

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,15 @@
9696
_PROTOCOL_ERROR, _CANCEL, _ENHANCE_YOUR_CALM = 0x1, 0x8, 0xB
9797
# Calls one relayed connection may hold open when the gRPC server advertises no
9898
# SETTINGS_MAX_CONCURRENT_STREAMS (grpcio advertises none unless the service sets
99-
# grpc.max_concurrent_streams, as the scaffold's server does). Past it the face resets
100-
# the call alone: ENHANCE_YOUR_CALM to the client (gRPC's RESOURCE_EXHAUSTED, which a
101-
# client does not retry on its own), and CANCEL to the server. The server still receives
102-
# the call's header block, which keeps the connection's header compression in step, so it
103-
# may have started the call before the cancel: the face never promises it did not. What
104-
# the face tracks per connection is bounded by it. When the server advertises a limit the
105-
# face enforces nothing: the server refuses what is past it (REFUSED_STREAM), truthfully.
99+
# grpc.max_concurrent_streams, as the scaffold's server does). The face advertises it to
100+
# the client in the server's first SETTINGS, so a client that keeps to HTTP/2 queues its
101+
# calls within it. Past it (a client that does not) the face resets the call alone:
102+
# ENHANCE_YOUR_CALM to the client (gRPC's RESOURCE_EXHAUSTED, which a client does not
103+
# retry on its own), and CANCEL to the server. The server still receives the call's
104+
# header block, which keeps the connection's header compression in step, so it may have
105+
# started the call before the cancel: the face never promises it did not. What the face
106+
# tracks per connection is bounded by it. When the server advertises a limit the face
107+
# enforces nothing: the server refuses what is past it (REFUSED_STREAM), truthfully.
106108
DEFAULT_MAX_OPEN_CALLS = 1000
107109
# Bytes of frames for the client (refusals, a GOAWAY) the relay holds while the server's
108110
# frame in flight completes; past it the relay reads nothing more from the client until
@@ -416,6 +418,24 @@ def _emit(self):
416418
self._after = self._on_frame(*self._frame, payload) or b""
417419

418420

421+
async def _server_preface(reader) -> bytes:
422+
"""The server's first frame, whole (RFC 9113 section 3.4: a SETTINGS frame)."""
423+
header = await reader.readexactly(_FRAME.size)
424+
high, low, *_ = _FRAME.unpack(header)
425+
return header + await reader.readexactly(high << 16 | low)
426+
427+
428+
def _with_max_streams(frame: bytes, limit: int) -> bytes:
429+
"""A server SETTINGS frame that advertises no SETTINGS_MAX_CONCURRENT_STREAMS, with
430+
``limit`` added: the client then holds its calls within what the face enforces. The
431+
client acknowledges it as the server's one frame, so the server sees what it expects."""
432+
_, _, kind, flags, stream = _FRAME.unpack_from(frame)
433+
if kind != _SETTINGS or flags & _ACK_FLAG:
434+
return frame
435+
payload = frame[_FRAME.size :] + struct.pack(">HI", _MAX_CONCURRENT_STREAMS, limit)
436+
return _h2_frame(_SETTINGS, flags, stream, payload)
437+
438+
419439
class _Connection:
420440
def __init__(self, channel, grpc_address, limit, answer_bounds, read_timeout, idle_timeout):
421441
self.channel, self.grpc_address, self.limit = channel, grpc_address, limit
@@ -439,16 +459,18 @@ async def _relay(self, reader, writer, head):
439459
"""gRPC over HTTP/2: the bytes go to the gRPC server as they came, until either
440460
side closes, or the connection has had no open call for ``idle_timeout``.
441461
442-
The frame headers say which calls are open: a client HEADERS on a new stream (odd,
443-
above every stream before it) opens one, and the server's END_STREAM, either
444-
side's RST_STREAM, or the server's GOAWAY above the stream ends it. A call past
445-
the server's own SETTINGS_MAX_CONCURRENT_STREAMS is relayed as it came, for the
446-
server to refuse. When the server advertises none, a call past
447-
``DEFAULT_MAX_OPEN_CALLS`` is reset alone (see there). The face's frames for the
448-
client are written only between two of the server's frames and outside a header
449-
block; while more than ``_MAX_QUEUED_FOR_CLIENT`` bytes of them wait, the client
450-
is not read. A client that opens an even stream, which no HTTP/2 client does, gets
451-
a GOAWAY naming why, and the connection is closed."""
462+
The frame headers say which calls are open: a client HEADERS on a new stream
463+
(odd, above every stream before it) opens one, and the server's END_STREAM,
464+
either side's RST_STREAM, or the server's GOAWAY above the stream ends it. A
465+
call past the server's own SETTINGS_MAX_CONCURRENT_STREAMS is relayed as it
466+
came, for the server to refuse. When the server advertises none, the face
467+
advertises ``DEFAULT_MAX_OPEN_CALLS`` in the server's first SETTINGS, and a call
468+
past it is reset alone (see there). The client is read only once that first
469+
SETTINGS is. The face's frames for the client are written only between two of
470+
the server's frames and outside a header block; while more than
471+
``_MAX_QUEUED_FOR_CLIENT`` bytes of them wait, the client is not read. A client
472+
that opens an even stream, which no HTTP/2 client does, gets a GOAWAY naming
473+
why, and the connection is closed."""
452474
upstream_reader, upstream_writer = await asyncio.open_connection(*self.grpc_address)
453475
upstream_writer.write(head)
454476
loop = asyncio.get_running_loop()
@@ -578,6 +600,19 @@ async def idle():
578600
return
579601
await asyncio.sleep(remaining if remaining > 0 else self.idle_timeout)
580602

603+
# The server's connection preface, a SETTINGS frame, before the client is read: so
604+
# the face knows the server's limit before the client's first call, and a client
605+
# of a server that advertises none learns the face's.
606+
try:
607+
async with asyncio.timeout(self.read_timeout):
608+
preface = await _server_preface(upstream_reader)
609+
except (TimeoutError, asyncio.IncompleteReadError, ConnectionError) as exc:
610+
_log.info("connect face: the gRPC server sent no SETTINGS: %r", exc)
611+
upstream_writer.close()
612+
return
613+
server.feed(preface)
614+
writer.write(preface if advertised else _with_max_streams(preface, max_calls))
615+
581616
tasks = (
582617
asyncio.ensure_future(pump(reader, upstream_writer, client, bytearray())),
583618
asyncio.ensure_future(pump(upstream_reader, writer, server, to_client)),

‎testdata/connect/test_connect_gen.py‎

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -596,8 +596,9 @@ async def test_a_grpc_connection_carrying_traffic_outlives_the_idle_bound():
596596
# Bytes in either direction keep the relay open: a server stream whose client sends
597597
# nothing is not idle.
598598
async def drip(reader, writer):
599-
for _ in range(6):
600-
writer.write(b".")
599+
writer.write(SETTINGS)
600+
for n in range(6):
601+
writer.write(h2_frame(0x6, 0, 0, bytes([n]) * 8))
601602
await writer.drain()
602603
await asyncio.sleep(0.2)
603604
writer.close()
@@ -610,7 +611,8 @@ async def drip(reader, writer):
610611
reader, writer = await asyncio.open_connection("127.0.0.1", listener.port)
611612
writer.write(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
612613
await writer.drain()
613-
assert await asyncio.wait_for(reader.read(), 3) == b"......"
614+
got = read_frames(await asyncio.wait_for(reader.read(), 3))
615+
assert [f[3] for f in got if f[0] == 0x6] == [bytes([n]) * 8 for n in range(6)]
614616
writer.close()
615617
finally:
616618
await listener.stop()
@@ -717,6 +719,7 @@ async def silent_upstream():
717719

718720
async def accept(reader, writer):
719721
held.append(writer)
722+
writer.write(SETTINGS) # a server's connection preface
720723
if state["mid_frame"] is not None:
721724
# Half a PING, the rest once the test sets the event: the server is mid-frame.
722725
ping = h2_frame(0x6, 0, 0, b"12345678")
@@ -820,7 +823,7 @@ async def test_a_call_past_the_limit_is_refused_alone(silent_upstream):
820823
payload = PREFACE + SETTINGS + b"".join(opens(2 * n + 1) for n in range(calls + 1))
821824
closed, got = await exchange(await face(), payload, 1)
822825
assert not closed
823-
assert read_frames(got) == [(0x3, 0, past, struct.pack(">I", 0xB))]
826+
assert [f for f in read_frames(got) if f[0] == 0x3] == [(0x3, 0, past, struct.pack(">I", 0xB))]
824827
upstream = read_frames(bytes(state["received"])[len(PREFACE) :])
825828
assert upstream[-2:] == [(0x1, 0x4, past, b"\x82"), (0x3, 0, past, struct.pack(">I", 0x8))]
826829
assert sum(1 for f in upstream if f[0] == 0x1) == calls + 1
@@ -840,10 +843,9 @@ async def test_a_refused_call_waits_for_the_end_of_its_header_block(silent_upstr
840843
assert [f[0] for f in upstream[-3:]] == [0x1, 0x9, 0x3]
841844

842845

843-
async def test_a_burst_past_the_limit_loses_no_call(short_idle):
846+
async def test_a_burst_past_the_limit_queues_and_loses_no_call(short_idle):
844847
# 1100 concurrent calls on one channel to a server advertising no limit: the face
845-
# resets only the calls past 1000, each with ENHANCE_YOUR_CALM (never REFUSED_STREAM,
846-
# which a client would retry on its own); every call it let through is answered.
848+
# advertises its own 1000, so the client queues the rest, and every call is answered.
847849
async with grpc.aio.insecure_channel(f"127.0.0.1:{short_idle.port}") as channel:
848850
say_call = channel.unary_unary(
849851
"/echo.v1.Echo/Say",
@@ -857,14 +859,65 @@ async def test_a_burst_past_the_limit_loses_no_call(short_idle):
857859
],
858860
return_exceptions=True,
859861
)
860-
answered = [a for a in answers if not isinstance(a, Exception)]
861-
refused = [a for a in answers if isinstance(a, Exception)]
862-
assert all(a.text == "slow|maya" for a in answered)
863-
assert len(answered) == connect_gen.DEFAULT_MAX_OPEN_CALLS
864-
assert all(
865-
a.code() == grpc.StatusCode.RESOURCE_EXHAUSTED and "error code 11" in a.details()
866-
for a in refused
867-
), {a.details() for a in refused}
862+
failed = {str(a)[:200] for a in answers if isinstance(a, Exception)}
863+
assert not failed, failed
864+
assert all(a.text == "slow|maya" for a in answers)
865+
866+
867+
def _settings(frame):
868+
"""The (id, value) entries of a SETTINGS frame."""
869+
payload = frame[3]
870+
return [struct.unpack_from(">HI", payload, at) for at in range(0, len(payload), 6)]
871+
872+
873+
async def _first_frames(upstream_preface, client_payload, monkeypatch=None):
874+
"""What the client receives, and what the server receives, when a fake gRPC server
875+
sends ``upstream_preface`` 0.3 s after the connection and reads everything after."""
876+
received = bytearray()
877+
878+
async def accept(reader, writer):
879+
await asyncio.sleep(0.3)
880+
writer.write(upstream_preface)
881+
await writer.drain()
882+
while chunk := await reader.read(65536):
883+
received.extend(chunk)
884+
885+
upstream = await asyncio.start_server(accept, "127.0.0.1", 0)
886+
listener = await connect_gen.serve(0, upstream.sockets[0].getsockname()[1], host="127.0.0.1")
887+
try:
888+
closed, got = await exchange(listener, client_payload, 1)
889+
return read_frames(got), read_frames(bytes(received)[len(PREFACE) :])
890+
finally:
891+
await listener.stop()
892+
upstream.close()
893+
894+
895+
async def test_a_server_advertising_no_limit_is_given_the_faces():
896+
# grpcio advertises no SETTINGS_MAX_CONCURRENT_STREAMS: the client is told the face's
897+
# DEFAULT_MAX_OPEN_CALLS in the server's own SETTINGS, so it queues within it.
898+
server_settings = h2_frame(0x4, 0, 0, struct.pack(">HI", 0x4, 65535))
899+
to_client, _ = await _first_frames(server_settings, PREFACE + SETTINGS)
900+
assert to_client[0][0] == 0x4
901+
assert _settings(to_client[0]) == [(0x4, 65535), (0x3, connect_gen.DEFAULT_MAX_OPEN_CALLS)]
902+
903+
904+
async def test_a_server_limit_reaches_the_client_untouched():
905+
server_settings = h2_frame(0x4, 0, 0, struct.pack(">HI", 0x3, 7))
906+
to_client, _ = await _first_frames(server_settings, PREFACE + SETTINGS)
907+
assert _settings(to_client[0]) == [(0x3, 7)]
908+
909+
910+
async def test_the_client_is_read_once_the_servers_limit_is_known(monkeypatch):
911+
# The client opens three calls at once; the server's SETTINGS (limit 2) arrives 0.3 s
912+
# later. The face waits for it, so the third call goes to the server, which refuses it
913+
# itself (retry-safe), rather than being reset by the face as past its own default.
914+
monkeypatch.setattr(connect_gen, "DEFAULT_MAX_OPEN_CALLS", 2)
915+
server_settings = h2_frame(0x4, 0, 0, struct.pack(">HI", 0x3, 2))
916+
to_client, to_server = await _first_frames(
917+
server_settings, PREFACE + SETTINGS + opens(1) + opens(3) + opens(5)
918+
)
919+
assert [f for f in to_client if f[0] == 0x3] == []
920+
assert [f[0] for f in to_server] == [0x4, 0x1, 0x1, 0x1]
868921

869922

870923
def _literal_headers(headers):
@@ -1053,7 +1106,8 @@ async def test_the_relay_ends_when_the_grpc_server_hangs_up(silent_upstream):
10531106
break
10541107
await asyncio.sleep(0.1)
10551108
assert not listener._connections, "the face still holds the client's connection"
1056-
assert await asyncio.wait_for(reader.read(), 1) == b""
1109+
# The server's SETTINGS, then the end.
1110+
assert [f[0] for f in read_frames(await asyncio.wait_for(reader.read(), 1))] == [0x4]
10571111
finally:
10581112
writer.close()
10591113

@@ -1167,7 +1221,7 @@ async def test_the_even_stream_goaway_waits_for_the_servers_frame(silent_upstrea
11671221
state["mid_frame"].set()
11681222
await frames.until(lambda: frames.eof, 3)
11691223
kinds = [f[0] for f in frames.frames]
1170-
assert kinds == [0x6, 0x7] # the server's frame whole, then the GOAWAY
1224+
assert kinds == [0x4, 0x6, 0x7] # the server's frame whole, then the GOAWAY
11711225
last, code = struct.unpack_from(">II", frames.frames[-1][3])
11721226
assert (last, code) == (1, 0x1)
11731227
finally:

0 commit comments

Comments
 (0)