Skip to content

Commit 69267b0

Browse files
Merge pull request #110 from codefly-dev/fix/109-concurrency
fix(connect): a call past the stream limit is refused alone; the scaffold advertises the limit
2 parents 6a4433e + fd151e0 commit 69267b0

4 files changed

Lines changed: 662 additions & 80 deletions

File tree

‎connect_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/binary"
56
"fmt"
67
"io"
78
"net"
@@ -202,6 +203,8 @@ func TestGeneratedMainServesConnect(t *testing.T) {
202203
require.NoError(t, err)
203204
require.Equal(t, http.StatusOK, response.StatusCode, "%s", body)
204205
require.JSONEq(t, `{"message":"over connect"}`, string(body))
206+
require.Equal(t, uint32(1000), advertisedMaxStreams(t, app.grpc),
207+
"the scaffold's gRPC server advertises the limit the face enforces, so a burst queues in the client")
205208
app.stop(t)
206209
require.False(t, listens(app.connect), "the face outlived the app")
207210

@@ -316,6 +319,35 @@ func (o *lockedOutput) String() string {
316319
return o.buf.String()
317320
}
318321

322+
// advertisedMaxStreams reads SETTINGS_MAX_CONCURRENT_STREAMS from the first
323+
// SETTINGS frame the gRPC server on port sends; 0 when it advertises none.
324+
func advertisedMaxStreams(t *testing.T, port uint16) uint32 {
325+
t.Helper()
326+
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 2*time.Second)
327+
require.NoError(t, err)
328+
defer conn.Close()
329+
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
330+
_, err = conn.Write(append([]byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"), 0, 0, 0, 4, 0, 0, 0, 0, 0))
331+
require.NoError(t, err)
332+
for {
333+
header := make([]byte, 9)
334+
_, err = io.ReadFull(conn, header)
335+
require.NoError(t, err)
336+
payload := make([]byte, int(header[0])<<16|int(header[1])<<8|int(header[2]))
337+
_, err = io.ReadFull(conn, payload)
338+
require.NoError(t, err)
339+
if header[3] != 4 || header[4]&1 != 0 { // not a SETTINGS, or an ACK
340+
continue
341+
}
342+
for at := 0; at+6 <= len(payload); at += 6 {
343+
if binary.BigEndian.Uint16(payload[at:]) == 3 {
344+
return binary.BigEndian.Uint32(payload[at+2:])
345+
}
346+
}
347+
return 0
348+
}
349+
}
350+
319351
func listens(port uint16) bool {
320352
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
321353
if err != nil {

‎templates/connect/connect_gen.py‎

Lines changed: 203 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,28 @@
6363
_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
6464
# An HTTP/2 frame header: 24-bit length, type, flags, stream id (RFC 9113 section 4.1).
6565
_FRAME = struct.Struct(">BHBBI")
66-
_DATA, _HEADERS, _RST_STREAM, _SETTINGS, _GOAWAY = 0x0, 0x1, 0x3, 0x4, 0x7
67-
_END_STREAM_FLAG, _ACK_FLAG = 0x1, 0x1
66+
_DATA, _HEADERS, _RST_STREAM, _SETTINGS, _GOAWAY, _CONTINUATION = 0x0, 0x1, 0x3, 0x4, 0x7, 0x9
67+
_END_STREAM_FLAG, _ACK_FLAG, _END_HEADERS_FLAG = 0x1, 0x1, 0x4
6868
_MAX_CONCURRENT_STREAMS = 0x3
69+
_PUSH_PROMISE = 0x5
70+
_PROTOCOL_ERROR, _CANCEL, _ENHANCE_YOUR_CALM = 0x1, 0x8, 0xB
6971
# Calls one relayed connection may hold open when the gRPC server advertises no
70-
# SETTINGS_MAX_CONCURRENT_STREAMS (grpcio advertises none); past it the face closes the
71-
# connection, so what it tracks per connection is bounded.
72+
# SETTINGS_MAX_CONCURRENT_STREAMS (grpcio advertises none unless the service sets
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.
7280
DEFAULT_MAX_OPEN_CALLS = 1000
81+
# Bytes of frames for the client (refusals, a GOAWAY) the relay holds while the server's
82+
# frame in flight completes; past it the relay reads nothing more from the client until
83+
# they are written, so a client that opens calls faster than it reads them stalls itself.
84+
_MAX_QUEUED_FOR_CLIENT = 16 * 1024
85+
# How long a closing relay waits for the server's frame in flight to complete, so the
86+
# client's GOAWAY lands between two frames.
87+
_GOAWAY_GRACE_S = 5.0
7388
# The most of a SETTINGS or GOAWAY payload the relay reads; the rest is skipped.
7489
_CONTROL_PAYLOAD = 256
7590
_ENVELOPE = struct.Struct(">BI")
@@ -224,55 +239,107 @@ class _ProtocolError(Exception):
224239
"""A client frame no HTTP/2 client may send: the relay closes the connection."""
225240

226241

242+
def _h2_frame(kind: int, flags: int, stream: int, payload: bytes = b"") -> bytes:
243+
size = len(payload)
244+
return _FRAME.pack(size >> 16, size & 0xFFFF, kind, flags, stream) + payload
245+
246+
247+
def _rst_stream(stream: int, code: int) -> bytes:
248+
return _h2_frame(_RST_STREAM, 0, stream, struct.pack(">I", code))
249+
250+
251+
def _goaway(last_stream: int, code: int, reason: str) -> bytes:
252+
return _h2_frame(_GOAWAY, 0, 0, struct.pack(">II", last_stream, code) + reason.encode())
253+
254+
227255
class _Frames:
228-
"""The HTTP/2 frames in one direction of a relayed connection, after the preface:
229-
``on_frame(type, flags, stream, payload)`` for each. The payload is read for SETTINGS
230-
and GOAWAY only (at most ``_CONTROL_PAYLOAD`` bytes of it) and skipped otherwise."""
256+
"""The HTTP/2 frames in one direction of a relayed connection, after the preface.
257+
258+
``feed`` calls ``on_frame(type, flags, stream, payload)`` for each frame and returns
259+
where frames end in the data, each with the bytes ``on_frame`` returned, to be written
260+
right after that frame in this direction. The payload is read for SETTINGS and GOAWAY
261+
only (at most ``_CONTROL_PAYLOAD`` bytes of it) and skipped otherwise. A
262+
``_ProtocolError`` from ``on_frame`` stops the feed; it is kept in ``error`` and the
263+
ends before the offending frame are returned."""
231264

232265
def __init__(self, on_frame):
233266
self._on_frame = on_frame
234267
self._header = bytearray()
235268
self._frame = None # (type, flags, stream) while its payload is read
269+
self._after = b"" # what on_frame returned for it
236270
self._payload = bytearray()
237271
self._keep = 0 # payload bytes still to read
238272
self._skip = 0 # payload bytes still to skip
239-
240-
def feed(self, data: bytes) -> None:
241-
at = 0
242-
while at < len(data):
243-
if self._keep:
244-
step = min(self._keep, len(data) - at)
245-
self._payload += data[at : at + step]
246-
self._keep -= step
247-
at += step
248-
if not self._keep:
249-
self._emit()
250-
continue
251-
if self._skip:
252-
step = min(self._skip, len(data) - at)
253-
self._skip -= step
254-
at += step
255-
continue
256-
step = min(_FRAME.size - len(self._header), len(data) - at)
257-
self._header += data[at : at + step]
258-
at += step
259-
if len(self._header) == _FRAME.size:
260-
high, low, kind, flags, stream = _FRAME.unpack(self._header)
261-
self._header.clear()
262-
length = high << 16 | low
263-
self._frame = (kind, flags, stream & 0x7FFFFFFF)
264-
if kind in (_SETTINGS, _GOAWAY):
265-
self._keep = min(length, _CONTROL_PAYLOAD)
266-
self._skip = length - self._keep
267-
else:
268-
self._skip = length
269-
if not self._keep:
273+
self._in_block = False # inside a header block: HEADERS ... CONTINUATION
274+
self.error: _ProtocolError | None = None
275+
276+
@property
277+
def at_boundary(self) -> bool:
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."""
287+
ends: list[tuple[int, bytes]] = []
288+
at, size = 0, len(data)
289+
clear = 0 if self.at_boundary else -1 # the last such place in ``data``
290+
try:
291+
while at < size:
292+
if self._keep:
293+
step = min(self._keep, size - at)
294+
self._payload += data[at : at + step]
295+
self._keep -= step
296+
at += step
297+
if self._keep:
298+
break
270299
self._emit()
300+
elif self._skip:
301+
step = min(self._skip, size - at)
302+
self._skip -= step
303+
at += step
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)
308+
self._header += data[at : at + step]
309+
at += step
310+
if len(self._header) < _FRAME.size:
311+
break
312+
self._start(*_FRAME.unpack(self._header))
313+
self._header.clear()
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""
322+
except _ProtocolError as refusal:
323+
self.error = refusal
324+
if clear > (ends[-1][0] if ends else 0):
325+
ends.append((clear, b""))
326+
return ends
327+
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()
271338

272339
def _emit(self):
273340
payload = bytes(self._payload)
274341
self._payload.clear()
275-
self._on_frame(*self._frame, payload)
342+
self._after = self._on_frame(*self._frame, payload) or b""
276343

277344

278345
class _Connection:
@@ -299,60 +366,133 @@ async def _relay(self, reader, writer, head):
299366
300367
The frame headers say which calls are open: a client HEADERS on a new stream (odd,
301368
above every stream before it) opens one, and the server's END_STREAM, either
302-
side's RST_STREAM, or the server's GOAWAY above the stream ends it. A client that
303-
opens an even stream, or more calls than the server allows at once
304-
(SETTINGS_MAX_CONCURRENT_STREAMS, ``DEFAULT_MAX_OPEN_CALLS`` when it advertises
305-
none), is closed: no HTTP/2 client does either."""
369+
side's RST_STREAM, or the server's GOAWAY above the stream ends it. A call past
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."""
306377
upstream_reader, upstream_writer = await asyncio.open_connection(*self.grpc_address)
307378
upstream_writer.write(head)
308379
loop = asyncio.get_running_loop()
309380
last = loop.time()
310381
calls: set[int] = set()
311-
highest = 0
382+
highest = accepted = 0
383+
refusing = 0 # the refused stream whose header block is still arriving
312384
max_calls = DEFAULT_MAX_OPEN_CALLS
385+
advertised = False # the server sent SETTINGS_MAX_CONCURRENT_STREAMS
386+
to_client = bytearray() # frames for the client, written at a frame boundary
387+
flushed = asyncio.Event() # set when the server pump wrote them
388+
told = False # the face wrote to the client since the client pump last drained
313389

314390
def from_client(kind, flags, stream, payload):
315-
nonlocal highest
316-
if kind == _HEADERS:
391+
nonlocal highest, accepted, refusing
392+
if kind == _HEADERS and stream > highest:
317393
if stream % 2 == 0:
318394
raise _ProtocolError(f"HEADERS on stream {stream}, which a server opens")
319-
if stream <= highest:
320-
# Trailers on a call, or a stream the server refuses as closed:
321-
# never a new call.
322-
return
323395
highest = stream
324-
if len(calls) >= max_calls:
325-
raise _ProtocolError(f"more than {max_calls} calls open at once")
326-
calls.add(stream)
396+
if len(calls) < max_calls:
397+
calls.add(stream)
398+
accepted = stream
399+
elif not advertised:
400+
refusing = stream
401+
tell_client(_rst_stream(stream, _ENHANCE_YOUR_CALM))
402+
# Past an advertised limit the server refuses the call itself.
403+
elif kind == _HEADERS and stream % 2 == 0:
404+
raise _ProtocolError(f"HEADERS on stream {stream}, which a server opens")
327405
elif kind == _RST_STREAM:
328406
calls.discard(stream)
407+
# A lower odd stream id opens nothing: trailers on a call, or a stream the
408+
# server refuses as closed.
409+
if refusing and stream == refusing and kind in (_HEADERS, _CONTINUATION):
410+
if flags & _END_HEADERS_FLAG:
411+
refusing = 0
412+
return _rst_stream(stream, _CANCEL) # after its header block
413+
return None
329414

330415
def from_server(kind, flags, stream, payload):
331-
nonlocal max_calls
416+
nonlocal max_calls, advertised
332417
if kind == _RST_STREAM or (kind in (_DATA, _HEADERS) and flags & _END_STREAM_FLAG):
333418
calls.discard(stream)
334419
elif kind == _SETTINGS and not flags & _ACK_FLAG:
335420
for at in range(0, len(payload) - 5, 6):
336421
key, value = struct.unpack_from(">HI", payload, at)
337422
if key == _MAX_CONCURRENT_STREAMS:
338-
max_calls = value
423+
max_calls, advertised = value, True
339424
elif kind == _GOAWAY and len(payload) >= 4:
340425
# Streams above the last one the server processes are never answered.
341426
last_stream = struct.unpack_from(">I", payload)[0] & 0x7FFFFFFF
342427
calls.difference_update({call for call in calls if call > last_stream})
428+
return None
343429

344-
async def pump(source, sink, frames):
345-
nonlocal last
430+
client, server = _Frames(from_client), _Frames(from_server)
431+
432+
def tell_client(frame):
433+
nonlocal told
434+
# Only between two of the server's frames: queued while one is half relayed,
435+
# and written by the server pump at that frame's end. Nothing to a client gone.
436+
if writer.is_closing():
437+
return
438+
if server.at_boundary and not to_client:
439+
writer.write(frame)
440+
told = True
441+
else:
442+
to_client.extend(frame)
443+
444+
async def written():
445+
"""Wait until the server pump has written what is queued for the client."""
446+
while to_client:
447+
flushed.clear()
448+
await flushed.wait()
449+
450+
def relay(chunk, sink, frames, ends, queued):
451+
# As few writes as the frames to insert allow: one per insertion point.
452+
at = 0
453+
for end, after in ends:
454+
if not after and not queued:
455+
continue
456+
sink.write(chunk[at:end] + after)
457+
at = end
458+
if queued:
459+
sink.write(bytes(queued))
460+
queued.clear()
461+
flushed.set()
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])
465+
466+
async def pump(source, sink, frames, queued):
467+
nonlocal last, told
346468
try:
347469
while chunk := await source.read(65536):
470+
if writer.is_closing():
471+
return # the client is gone: nothing left to relay
348472
last = loop.time()
349-
frames.feed(chunk)
350-
sink.write(chunk)
473+
relay(chunk, sink, frames, frames.feed(chunk, bool(queued)), queued)
474+
if frames.error is not None:
475+
_log.info("connect face: closing a gRPC connection: %s", frames.error)
476+
tell_client(_goaway(accepted, _PROTOCOL_ERROR, str(frames.error)))
477+
try:
478+
await asyncio.wait_for(written(), _GOAWAY_GRACE_S)
479+
except TimeoutError:
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+
)
485+
return
351486
await sink.drain()
487+
if sink is not writer:
488+
# The refusals: a client must read them before it is read again.
489+
if len(to_client) >= _MAX_QUEUED_FOR_CLIENT:
490+
await written()
491+
if told:
492+
told = False
493+
await writer.drain()
352494
except (ConnectionError, OSError):
353495
pass
354-
except _ProtocolError as refusal:
355-
_log.info("connect face: closing a gRPC connection: %s", refusal)
356496

357497
async def idle():
358498
# The clock runs from the last byte either way, and only ends the connection
@@ -364,8 +504,8 @@ async def idle():
364504
await asyncio.sleep(remaining if remaining > 0 else self.idle_timeout)
365505

366506
tasks = (
367-
asyncio.ensure_future(pump(reader, upstream_writer, _Frames(from_client))),
368-
asyncio.ensure_future(pump(upstream_reader, writer, _Frames(from_server))),
507+
asyncio.ensure_future(pump(reader, upstream_writer, client, bytearray())),
508+
asyncio.ensure_future(pump(upstream_reader, writer, server, to_client)),
369509
asyncio.ensure_future(idle()),
370510
)
371511
try:

0 commit comments

Comments
 (0)