httpunk 0.1.4__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {httpunk-0.1.4 → httpunk-0.1.5}/Cargo.lock +1 -1
- {httpunk-0.1.4 → httpunk-0.1.5}/Cargo.toml +1 -1
- {httpunk-0.1.4 → httpunk-0.1.5}/PKG-INFO +1 -1
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/connection.py +41 -12
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/streams.py +14 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_asyncio_protocol.py +30 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_errors.py +46 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_server.py +149 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/LICENSE +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/README.md +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/Cargo.toml +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/LICENSE +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/codec/error.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/codec/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/ext.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/data.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/go_away.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/head.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/headers.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/ping.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/priority.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/reason.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/reset.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/settings.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/util.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/window_update.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/header.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/table.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/lib.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/proto/error.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/proto/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/Cargo.toml +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/LICENSE +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/body/length.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/body/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/cfg.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/common/date.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/common/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/error.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/ext/informational.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/ext/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/headers.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/lib.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/trace.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/Cargo.toml +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/LICENSE +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/src/lib.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/__init__.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/_backend/__init__.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/_backend/asyncio.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/_backend/tonio.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/_common.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/_httpunk.pyi +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/asyncio.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/exceptions.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h1/__init__.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h1/client.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h1/connection.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h1/server.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h1/share.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/__init__.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/client.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/server.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/settings.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/share.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/stream.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/http.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/py.typed +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/types.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/__init__.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/auto.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/client.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/graceful.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/pool.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/proxy.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/pyproject.toml +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/src/errors.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/src/h1/codec.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/src/h1/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/src/h2/codec.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/src/h2/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/src/h2/streams.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/src/http/mod.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/src/lib.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/src/proxy.rs +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/_client.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/conftest.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_asyncio_backend.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_asyncio_client_protocol.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_asyncio_e2e.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_backend.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h1_codec.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h1_conn.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h1_decode.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h1_server.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_codec.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_conn.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_flow.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_flow_control.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_ready.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_settings.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_state.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_headermap.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_auto.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_connect.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_graceful.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_pool.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_proxy.py +0 -0
- {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_tls.py +0 -0
|
@@ -141,11 +141,16 @@ class H2ConnectionBase:
|
|
|
141
141
|
# `resp.aclose()` immediately followed by close ENQUEUES the RST_STREAM,
|
|
142
142
|
# and whether the pump wakes before the cancel above is a scheduler race;
|
|
143
143
|
# dropping it would ship the close without ever telling the peer to stop
|
|
144
|
-
# (h2 likewise drains its pending-send queue before shutdown).
|
|
145
|
-
#
|
|
144
|
+
# (h2 likewise drains its pending-send queue before shutdown). Unconditional
|
|
145
|
+
# on `self.error`: h2's `State::Closing` runs `codec.shutdown` = flush THEN
|
|
146
|
+
# shutdown on every non-IO close (connection.rs L304-310), including the
|
|
147
|
+
# GOAWAY-exchanged clean close (F23), whose reply GOAWAY sits in this queue
|
|
148
|
+
# with `error` already set. Best-effort: the connection is closing
|
|
149
|
+
# regardless, so a send failure (a dead transport — h2's Io branch, which
|
|
150
|
+
# skips straight to Closed) is ignored.
|
|
146
151
|
with self._write_buf_lock:
|
|
147
152
|
pending, self._write_buf = bytes(self._write_buf), bytearray()
|
|
148
|
-
if pending and self.
|
|
153
|
+
if pending and self._transport is not None:
|
|
149
154
|
with contextlib.suppress(Exception):
|
|
150
155
|
async with self._send_lock:
|
|
151
156
|
await self._transport.send_all(pending)
|
|
@@ -190,7 +195,10 @@ class H2ConnectionBase:
|
|
|
190
195
|
# - Connection-lifecycle frames (preface/SETTINGS, GOAWAY, acks) keep plain
|
|
191
196
|
# `send_frame`: they run in the pump/handshake/shutdown, not under user
|
|
192
197
|
# cancellation, and GOAWAY needs flushed-before-stop semantics a queue would
|
|
193
|
-
# complicate.
|
|
198
|
+
# complicate. One exception: the idle-after-peer-GOAWAY reply
|
|
199
|
+
# (`_maybe_goaway_reply`) is queued, because its trigger can be a sync stream
|
|
200
|
+
# close in user-task context; `close()` drains the queue before the FIN, so
|
|
201
|
+
# flushed-before-stop still holds.
|
|
194
202
|
#
|
|
195
203
|
# The full-fidelity alternative — port h2's buffered-send/`reserve_capacity`
|
|
196
204
|
# model and route DATA through the queue too — remains open as a future step;
|
|
@@ -255,10 +263,27 @@ class H2ConnectionBase:
|
|
|
255
263
|
self.backend.close_transport(self._transport)
|
|
256
264
|
raise
|
|
257
265
|
|
|
258
|
-
|
|
266
|
+
def _maybe_goaway_reply(self):
|
|
259
267
|
"""If the peer has GOAWAY'd us with a REAL last-stream-id and no streams remain,
|
|
260
|
-
|
|
261
|
-
|
|
268
|
+
queue our acknowledging GOAWAY(NO_ERROR, last-processed-id) exactly once and
|
|
269
|
+
mark the connection DONE (F23). No-op otherwise; returns whether it fired.
|
|
270
|
+
|
|
271
|
+
h2: proto/connection.rs `poll` (L287-295) — `go_away_now(NO_ERROR)` once
|
|
272
|
+
`error.is_some() && !has_streams()`, after which `should_close_now()` makes
|
|
273
|
+
the connection future resolve `Ready(Ok(()))`: sending the reply and being
|
|
274
|
+
done are ONE step. The "done" half here is `_fail`, the fan-out every other
|
|
275
|
+
pump exit gets (server: end the accept loop so the caller's `__aexit__`
|
|
276
|
+
closes the transport; client: wake openers). `fail_all` aborts nothing
|
|
277
|
+
(no streams remain) and `error` is set so later use fails as closed — the
|
|
278
|
+
client still reports `GoAwayError` first (`open_stream`/`wait_until_ready`
|
|
279
|
+
check `_goaway` before `error`, as h2's conn_error is never overwritten).
|
|
280
|
+
|
|
281
|
+
Two triggers, like h2's connection task being woken both by inbound frames
|
|
282
|
+
and by a stream closing (streams.rs `drop_stream_ref` L1647): the read pump
|
|
283
|
+
after a batch, and `_close_stream`/`_abort_stream` when the last stream
|
|
284
|
+
goes. The latter is sync user-task context (a `respond()`/`aclose()`
|
|
285
|
+
finishing), so the frame goes through `enqueue_frame` — exactly h2's
|
|
286
|
+
`GoAway.pending` queue; `close()` drains it before the FIN (codec.shutdown).
|
|
262
287
|
|
|
263
288
|
A phase-1 graceful GOAWAY (`last_stream_id == 2^31-1`) is explicitly NOT a
|
|
264
289
|
trigger — it means "I'm shutting down, keep your in-flight work going" and is
|
|
@@ -267,6 +292,7 @@ class H2ConnectionBase:
|
|
|
267
292
|
(h2's `should_close_on_idle` excludes `StreamId::MAX` for the same reason)."""
|
|
268
293
|
if (
|
|
269
294
|
self._goaway_replied
|
|
295
|
+
or self.error is not None
|
|
270
296
|
or self.streams._goaway is None
|
|
271
297
|
or self.streams._streams
|
|
272
298
|
or self.streams._goaway_last_id is None
|
|
@@ -274,7 +300,8 @@ class H2ConnectionBase:
|
|
|
274
300
|
):
|
|
275
301
|
return False
|
|
276
302
|
self._goaway_replied = True
|
|
277
|
-
|
|
303
|
+
self.enqueue_frame(self.codec.serialize_go_away(self._goaway_last_stream_id(), int(H2Reason.NO_ERROR)))
|
|
304
|
+
self._fail(ConnectionClosedError("connection closed: GOAWAY exchanged"))
|
|
278
305
|
return True
|
|
279
306
|
|
|
280
307
|
# ----- inbound -----
|
|
@@ -306,13 +333,15 @@ class H2ConnectionBase:
|
|
|
306
333
|
# resets (F25).
|
|
307
334
|
if se.args[2] != "remote":
|
|
308
335
|
await self.streams.reset_on_error(se.args[0], se.args[1])
|
|
309
|
-
# After a peer GOAWAY, once every in-flight stream has finished,
|
|
336
|
+
# After a peer GOAWAY, once every in-flight stream has finished, queue our
|
|
310
337
|
# acknowledging GOAWAY(NO_ERROR, last-processed-id) and stop serving —
|
|
311
338
|
# h2's poll loop does exactly this (`go_away_now(NO_ERROR)` once
|
|
312
339
|
# `error.is_some()` and `!has_streams()`, connection.rs L287-295), rather
|
|
313
|
-
# than lingering until the peer closes the socket (F23). The
|
|
314
|
-
#
|
|
315
|
-
|
|
340
|
+
# than lingering until the peer closes the socket (F23). The connection is
|
|
341
|
+
# DONE (`_fail` inside): the accept loop / openers are woken, and the
|
|
342
|
+
# transport's FIN follows on the caller's `__aexit__` → `close()`, which
|
|
343
|
+
# drains the queued GOAWAY first (httpunk's user-driven lifecycle).
|
|
344
|
+
if self._maybe_goaway_reply():
|
|
316
345
|
break
|
|
317
346
|
except H2Error as exc:
|
|
318
347
|
# A protocol/flow violation we detected (bad state, HPACK/CONTINUATION,
|
|
@@ -896,6 +896,19 @@ class StreamManager:
|
|
|
896
896
|
"""
|
|
897
897
|
if st.state.is_closed() and self._streams.pop(st.id, None) is not None:
|
|
898
898
|
self._release_slot(st)
|
|
899
|
+
self._on_stream_gone()
|
|
900
|
+
|
|
901
|
+
def _on_stream_gone(self):
|
|
902
|
+
"""A stream left `_streams`: re-run the connection's idle-close check.
|
|
903
|
+
|
|
904
|
+
h2: streams.rs `drop_stream_ref` (L1647) wakes the connection task when a
|
|
905
|
+
closed stream is released, so `Connection::poll` re-evaluates
|
|
906
|
+
`error.is_some() && !has_streams()` → `go_away_now(NO_ERROR)`
|
|
907
|
+
(connection.rs L287-295). Without this, a peer GOAWAY that arrived while
|
|
908
|
+
a request was in flight would never be acknowledged if the peer sends
|
|
909
|
+
nothing further — the read pump only re-checks after inbound bytes.
|
|
910
|
+
"""
|
|
911
|
+
self._conn._maybe_goaway_reply()
|
|
899
912
|
|
|
900
913
|
def _abort_stream(self, st, exc):
|
|
901
914
|
"""Error a stream, unblock its waiters, remove it, and free its slot."""
|
|
@@ -912,6 +925,7 @@ class StreamManager:
|
|
|
912
925
|
st.window_evt.set() # unblock a sender parked on flow control (it re-checks st.error)
|
|
913
926
|
if self._streams.pop(st.id, None) is not None:
|
|
914
927
|
self._release_slot(st)
|
|
928
|
+
self._on_stream_gone() # no-op during `fail_all` (`_conn.error` already set)
|
|
915
929
|
|
|
916
930
|
def handle_go_away(self, last_stream_id, exc):
|
|
917
931
|
"""Peer sent GOAWAY: refuse new streams; streams > last_stream_id were
|
|
@@ -159,6 +159,36 @@ async def test_h2_protocol_graceful_shutdown():
|
|
|
159
159
|
await conn.__aexit__(None, None, None)
|
|
160
160
|
|
|
161
161
|
|
|
162
|
+
@pytest.mark.asyncio
|
|
163
|
+
async def test_h2_protocol_closes_after_client_goaway():
|
|
164
|
+
"""The CLIENT GOAWAYs an idle connection: the server replies GOAWAY(NO_ERROR) and
|
|
165
|
+
then CLOSES — its serve task ends and the client reads EOF (F23, server role, on
|
|
166
|
+
the asyncio backend). It used to reply and then leak the connection forever:
|
|
167
|
+
the read pump exited without ending the accept loop, so nothing closed."""
|
|
168
|
+
server, host, port, protocols = await _serve(_EchoH2)
|
|
169
|
+
async with server:
|
|
170
|
+
r, w = await asyncio.open_connection(host, port)
|
|
171
|
+
# Preface + empty SETTINGS; let the server's SETTINGS arrive and ack it so our
|
|
172
|
+
# ack is not left unread in its buffer (which would turn the FIN into a RST).
|
|
173
|
+
w.write(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + b"\x00\x00\x00\x04\x00\x00\x00\x00\x00")
|
|
174
|
+
await w.drain()
|
|
175
|
+
await asyncio.wait_for(r.read(65536), 5) # server SETTINGS (+ WINDOW_UPDATE)
|
|
176
|
+
w.write(b"\x00\x00\x00\x04\x01\x00\x00\x00\x00") # SETTINGS ack
|
|
177
|
+
w.write(b"\x00\x00\x08\x07\x00\x00\x00\x00\x00" + b"\x00" * 8) # GOAWAY(last=0, NO_ERROR)
|
|
178
|
+
await w.drain()
|
|
179
|
+
got = b""
|
|
180
|
+
while chunk := await asyncio.wait_for(r.read(65536), 5): # hangs here without the fix
|
|
181
|
+
got += chunk
|
|
182
|
+
frames, i = [], 0 # raw framing: (type, payload)
|
|
183
|
+
while i < len(got):
|
|
184
|
+
n = int.from_bytes(got[i : i + 3], "big")
|
|
185
|
+
frames.append((got[i + 3], got[i + 9 : i + 9 + n]))
|
|
186
|
+
i += 9 + n
|
|
187
|
+
assert [p for t, p in frames if t == 0x07] == [b"\x00" * 8] # GOAWAY(last=0, NO_ERROR), then EOF
|
|
188
|
+
await asyncio.wait_for(protocols[0].wait_closed(), 5)
|
|
189
|
+
w.close()
|
|
190
|
+
|
|
191
|
+
|
|
162
192
|
@pytest.mark.asyncio
|
|
163
193
|
async def test_h1_protocol_graceful_shutdown_releases_idle():
|
|
164
194
|
# h1 graceful releases the idle head-read (via backend.select) and closes; the
|
|
@@ -9,6 +9,7 @@ from tonio.colored import Event, scope, sleep
|
|
|
9
9
|
from tonio.colored.net import open_tcp_listeners
|
|
10
10
|
|
|
11
11
|
from httpunk import (
|
|
12
|
+
ConnectionClosedError,
|
|
12
13
|
GoAwayError,
|
|
13
14
|
H2Connection,
|
|
14
15
|
H2Error,
|
|
@@ -24,6 +25,7 @@ from httpunk._httpunk import (
|
|
|
24
25
|
H2FrameData as Data,
|
|
25
26
|
H2FrameGoAway as GoAway,
|
|
26
27
|
H2FrameHeaders as Headers,
|
|
28
|
+
H2FramePing as Ping,
|
|
27
29
|
H2FrameRstStream as RstStream,
|
|
28
30
|
H2FrameSettings as Settings,
|
|
29
31
|
)
|
|
@@ -809,3 +811,47 @@ async def test_end_stream_with_content_length_resets_not_hangs():
|
|
|
809
811
|
with pytest.raises(H2Error): # RST_STREAM(PROTOCOL_ERROR), woken promptly
|
|
810
812
|
await conn.request("GET", "/")
|
|
811
813
|
s.cancel()
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
@pytest.mark.tonio
|
|
817
|
+
async def test_close_flushes_queued_frames_even_after_connection_error():
|
|
818
|
+
"""`close()` drains the control-frame queue BEFORE the FIN regardless of
|
|
819
|
+
`error` being set — h2's `State::Closing` runs `codec.shutdown` (flush, then
|
|
820
|
+
shutdown) on every non-IO close (connection.rs L304-310). The GOAWAY-exchanged
|
|
821
|
+
clean close (F23) relies on this: its reply GOAWAY is queued with `error` already
|
|
822
|
+
set, and whether the write pump wakes before `close()` tears it down is a
|
|
823
|
+
scheduler race. The write-pump wake is stubbed out so the queue is guaranteed
|
|
824
|
+
to still hold the frame when `close()` runs."""
|
|
825
|
+
listener = (await open_tcp_listeners(0, host="127.0.0.1"))[0]
|
|
826
|
+
host, port = listener.socket.getsockname()[:2]
|
|
827
|
+
seen = []
|
|
828
|
+
|
|
829
|
+
async def server():
|
|
830
|
+
stream, codec, frames = await _accept_handshake(listener)
|
|
831
|
+
for f in frames:
|
|
832
|
+
if isinstance(f, Settings) and not f.ack:
|
|
833
|
+
await stream.send_all(codec.serialize_settings_ack())
|
|
834
|
+
while True:
|
|
835
|
+
chunk = await stream.receive_some(65536)
|
|
836
|
+
if not chunk:
|
|
837
|
+
seen.append("eof")
|
|
838
|
+
break
|
|
839
|
+
for f in codec.receive(chunk):
|
|
840
|
+
if isinstance(f, Settings) and not f.ack:
|
|
841
|
+
await stream.send_all(codec.serialize_settings_ack())
|
|
842
|
+
else:
|
|
843
|
+
seen.append(f)
|
|
844
|
+
|
|
845
|
+
async with scope() as s:
|
|
846
|
+
s.spawn(server())
|
|
847
|
+
async with open_h2(host, port) as conn:
|
|
848
|
+
inner = conn._conn
|
|
849
|
+
inner._write_evt.set = lambda: None # the pump never wakes: the frame stays queued
|
|
850
|
+
inner.enqueue_frame(inner.codec.serialize_ping(b"flushme!"))
|
|
851
|
+
inner.error = ConnectionClosedError("simulated failure")
|
|
852
|
+
# `__aexit__` -> `close()` must have written the queued PING before closing.
|
|
853
|
+
while seen[-1:] != ["eof"]:
|
|
854
|
+
await sleep(0)
|
|
855
|
+
s.cancel()
|
|
856
|
+
|
|
857
|
+
assert any(isinstance(f, Ping) and f.data == b"flushme!" for f in seen), seen
|
|
@@ -568,3 +568,152 @@ async def test_h2_bodyless_request_with_trailers():
|
|
|
568
568
|
|
|
569
569
|
assert seen["body"] == b""
|
|
570
570
|
assert seen["trailers"].get("x-done") == b"1"
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# ===== peer GOAWAY → acknowledging GOAWAY + connection done (F23, server role) =====
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
async def _serve_and_report(listener, done):
|
|
577
|
+
"""Serve one connection; set `done` once the accept loop has exited and the
|
|
578
|
+
server's `__aexit__` has run (i.e. the transport is closed)."""
|
|
579
|
+
transport = await listener.accept()
|
|
580
|
+
with contextlib.suppress(Exception):
|
|
581
|
+
async with H2Server(transport) as server, scope() as handlers:
|
|
582
|
+
async for req in server:
|
|
583
|
+
|
|
584
|
+
async def handle(r):
|
|
585
|
+
await r.read()
|
|
586
|
+
await r.respond(200, body=b"ok")
|
|
587
|
+
|
|
588
|
+
handlers.spawn(handle(req))
|
|
589
|
+
done.set()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
async def _settle_handshake(transport, codec, seen):
|
|
593
|
+
"""Read the server's SETTINGS and ack it BEFORE the test sends GOAWAY: once the
|
|
594
|
+
server has replied GOAWAY it stops reading (h2: `should_close_now` → no more
|
|
595
|
+
`poll_next`), so a SETTINGS ack still in flight would sit unread in its socket
|
|
596
|
+
buffer and turn its close into a TCP RST instead of a FIN."""
|
|
597
|
+
while not any(isinstance(f, Settings) and not f.ack for f in seen):
|
|
598
|
+
data = await transport.receive_some(65536)
|
|
599
|
+
assert data
|
|
600
|
+
for f in codec.receive(data):
|
|
601
|
+
if isinstance(f, Settings) and not f.ack:
|
|
602
|
+
await transport.send_all(codec.serialize_settings_ack())
|
|
603
|
+
seen.append(f)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
async def _read_until_eof(transport, codec, *, seen=None):
|
|
607
|
+
"""Read frames (acking SETTINGS) until the peer's FIN. Returns the frames seen."""
|
|
608
|
+
seen = [] if seen is None else seen
|
|
609
|
+
while True:
|
|
610
|
+
data = await transport.receive_some(65536)
|
|
611
|
+
if not data:
|
|
612
|
+
return seen
|
|
613
|
+
for f in codec.receive(data):
|
|
614
|
+
if isinstance(f, Settings) and not f.ack:
|
|
615
|
+
await transport.send_all(codec.serialize_settings_ack())
|
|
616
|
+
seen.append(f)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
async def _assert_goaway_then_eof(transport, codec, done, *, seen=None):
|
|
620
|
+
"""The one contract under test: the server replies GOAWAY(NO_ERROR), then CLOSES
|
|
621
|
+
(the client reads EOF — without the fix it hangs here), and its accept loop ends."""
|
|
622
|
+
seen = await _read_until_eof(transport, codec, seen=seen)
|
|
623
|
+
goaways = [f for f in seen if isinstance(f, GoAway)]
|
|
624
|
+
assert len(goaways) == 1 and goaways[0].error_code == H2Reason.NO_ERROR
|
|
625
|
+
await done.wait()
|
|
626
|
+
transport.close()
|
|
627
|
+
return seen
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@pytest.mark.tonio
|
|
631
|
+
@pytest.mark.parametrize("last_id", [0, 1])
|
|
632
|
+
async def test_server_closes_after_goaway_reply_when_idle(last_id):
|
|
633
|
+
"""Peer GOAWAY on an idle connection (one completed request): the server sends its
|
|
634
|
+
acknowledging GOAWAY(NO_ERROR) AND closes the connection / ends the accept loop —
|
|
635
|
+
h2 `go_away_now` → `should_close_now` → the connection future resolves (F23).
|
|
636
|
+
Previously only the GOAWAY went out and the socket leaked forever."""
|
|
637
|
+
listener, host, port = await _listener()
|
|
638
|
+
done = Event()
|
|
639
|
+
async with scope() as s:
|
|
640
|
+
s.spawn(_serve_and_report(listener, done))
|
|
641
|
+
transport, codec = await _raw_handshake(host, port)
|
|
642
|
+
await transport.send_all(codec.serialize_request_headers(1, "GET", "http://x/", end_stream=True))
|
|
643
|
+
seen = []
|
|
644
|
+
while not any(isinstance(f, Headers) for f in seen): # full response for stream 1
|
|
645
|
+
data = await transport.receive_some(65536)
|
|
646
|
+
assert data
|
|
647
|
+
for f in codec.receive(data):
|
|
648
|
+
if isinstance(f, Settings) and not f.ack:
|
|
649
|
+
await transport.send_all(codec.serialize_settings_ack())
|
|
650
|
+
seen.append(f)
|
|
651
|
+
await transport.send_all(codec.serialize_go_away(last_id, H2Reason.NO_ERROR))
|
|
652
|
+
await _assert_goaway_then_eof(transport, codec, done, seen=seen)
|
|
653
|
+
s.cancel()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@pytest.mark.tonio
|
|
657
|
+
async def test_server_closes_after_goaway_reply_no_streams_ever():
|
|
658
|
+
"""Peer GOAWAY right after the handshake, no stream ever opened: same contract."""
|
|
659
|
+
listener, host, port = await _listener()
|
|
660
|
+
done = Event()
|
|
661
|
+
async with scope() as s:
|
|
662
|
+
s.spawn(_serve_and_report(listener, done))
|
|
663
|
+
transport, codec = await _raw_handshake(host, port)
|
|
664
|
+
seen = []
|
|
665
|
+
await _settle_handshake(transport, codec, seen)
|
|
666
|
+
await transport.send_all(codec.serialize_go_away(0, H2Reason.NO_ERROR))
|
|
667
|
+
await _assert_goaway_then_eof(transport, codec, done, seen=seen)
|
|
668
|
+
s.cancel()
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@pytest.mark.tonio
|
|
672
|
+
async def test_server_closes_after_goaway_received_mid_request():
|
|
673
|
+
"""Peer GOAWAY(last=1) while stream 1 is still in flight, and the peer sends NOTHING
|
|
674
|
+
more. Stream 1 finishing is a local event — h2's `drop_stream_ref` wakes the
|
|
675
|
+
connection task so the idle check re-runs (streams.rs L1647); httpunk must do the
|
|
676
|
+
same from the stream-close path, not only after inbound bytes."""
|
|
677
|
+
listener, host, port = await _listener()
|
|
678
|
+
done = Event()
|
|
679
|
+
async with scope() as s:
|
|
680
|
+
s.spawn(_serve_and_report(listener, done))
|
|
681
|
+
transport, codec = await _raw_handshake(host, port)
|
|
682
|
+
seen = []
|
|
683
|
+
await _settle_handshake(transport, codec, seen)
|
|
684
|
+
# Request body still pending (no END_STREAM) when the GOAWAY lands...
|
|
685
|
+
await transport.send_all(codec.serialize_request_headers(1, "POST", "http://x/", end_stream=False))
|
|
686
|
+
await transport.send_all(codec.serialize_go_away(1, H2Reason.NO_ERROR))
|
|
687
|
+
# ...then finish it; after this the client is silent.
|
|
688
|
+
await transport.send_all(codec.serialize_data(1, b"x", end_stream=True))
|
|
689
|
+
seen = await _assert_goaway_then_eof(transport, codec, done, seen=seen)
|
|
690
|
+
# Stream 1 was <= last_stream_id, so it was served before the close.
|
|
691
|
+
assert any(isinstance(f, Headers) and f.stream_id == 1 and f.status == 200 for f in seen)
|
|
692
|
+
s.cancel()
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@pytest.mark.tonio
|
|
696
|
+
async def test_server_keeps_serving_after_phase1_goaway():
|
|
697
|
+
"""A phase-1 graceful GOAWAY (last_stream_id = 2^31-1) is NOT an idle-close
|
|
698
|
+
trigger: the connection must stay open and keep serving (h2 `should_close_on_idle`
|
|
699
|
+
excludes `StreamId::MAX`)."""
|
|
700
|
+
listener, host, port = await _listener()
|
|
701
|
+
done = Event()
|
|
702
|
+
async with scope() as s:
|
|
703
|
+
s.spawn(_serve_and_report(listener, done))
|
|
704
|
+
transport, codec = await _raw_handshake(host, port)
|
|
705
|
+
await transport.send_all(codec.serialize_go_away(2**31 - 1, H2Reason.NO_ERROR))
|
|
706
|
+
await transport.send_all(codec.serialize_request_headers(1, "GET", "http://x/", end_stream=True))
|
|
707
|
+
seen = []
|
|
708
|
+
while not any(isinstance(f, Headers) for f in seen):
|
|
709
|
+
data = await transport.receive_some(65536)
|
|
710
|
+
assert data, "server closed after a phase-1 GOAWAY"
|
|
711
|
+
for f in codec.receive(data):
|
|
712
|
+
if isinstance(f, Settings) and not f.ack:
|
|
713
|
+
await transport.send_all(codec.serialize_settings_ack())
|
|
714
|
+
seen.append(f)
|
|
715
|
+
assert not any(isinstance(f, GoAway) for f in seen)
|
|
716
|
+
assert not done.is_set()
|
|
717
|
+
transport.close()
|
|
718
|
+
await done.wait() # the server sees our FIN and exits cleanly
|
|
719
|
+
s.cancel()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|