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.
Files changed (133) hide show
  1. {httpunk-0.1.4 → httpunk-0.1.5}/Cargo.lock +1 -1
  2. {httpunk-0.1.4 → httpunk-0.1.5}/Cargo.toml +1 -1
  3. {httpunk-0.1.4 → httpunk-0.1.5}/PKG-INFO +1 -1
  4. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/connection.py +41 -12
  5. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/streams.py +14 -0
  6. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_asyncio_protocol.py +30 -0
  7. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_errors.py +46 -0
  8. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_server.py +149 -0
  9. {httpunk-0.1.4 → httpunk-0.1.5}/LICENSE +0 -0
  10. {httpunk-0.1.4 → httpunk-0.1.5}/README.md +0 -0
  11. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/Cargo.toml +0 -0
  12. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/LICENSE +0 -0
  13. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
  14. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/codec/error.rs +0 -0
  15. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/codec/mod.rs +0 -0
  16. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/ext.rs +0 -0
  17. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/data.rs +0 -0
  18. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/go_away.rs +0 -0
  19. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/head.rs +0 -0
  20. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/headers.rs +0 -0
  21. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/mod.rs +0 -0
  22. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/ping.rs +0 -0
  23. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/priority.rs +0 -0
  24. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/reason.rs +0 -0
  25. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/reset.rs +0 -0
  26. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/settings.rs +0 -0
  27. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
  28. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/util.rs +0 -0
  29. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/frame/window_update.rs +0 -0
  30. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
  31. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
  32. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/header.rs +0 -0
  33. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
  34. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
  35. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/mod.rs +0 -0
  36. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/hpack/table.rs +0 -0
  37. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/lib.rs +0 -0
  38. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/proto/error.rs +0 -0
  39. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/proto/mod.rs +0 -0
  40. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
  41. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
  42. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
  43. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/Cargo.toml +0 -0
  44. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/LICENSE +0 -0
  45. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
  46. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/body/length.rs +0 -0
  47. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/body/mod.rs +0 -0
  48. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/cfg.rs +0 -0
  49. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/common/date.rs +0 -0
  50. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/common/mod.rs +0 -0
  51. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/error.rs +0 -0
  52. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
  53. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/ext/informational.rs +0 -0
  54. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/ext/mod.rs +0 -0
  55. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/headers.rs +0 -0
  56. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/lib.rs +0 -0
  57. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
  58. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
  59. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
  60. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
  61. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
  62. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
  63. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/proto/mod.rs +0 -0
  64. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper/src/trace.rs +0 -0
  65. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/Cargo.toml +0 -0
  66. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/LICENSE +0 -0
  67. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
  68. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
  69. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
  70. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
  71. {httpunk-0.1.4 → httpunk-0.1.5}/crates/vendor-hyper-util/src/lib.rs +0 -0
  72. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/__init__.py +0 -0
  73. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/_backend/__init__.py +0 -0
  74. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/_backend/asyncio.py +0 -0
  75. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/_backend/tonio.py +0 -0
  76. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/_common.py +0 -0
  77. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/_httpunk.pyi +0 -0
  78. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/asyncio.py +0 -0
  79. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/exceptions.py +0 -0
  80. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h1/__init__.py +0 -0
  81. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h1/client.py +0 -0
  82. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h1/connection.py +0 -0
  83. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h1/server.py +0 -0
  84. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h1/share.py +0 -0
  85. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/__init__.py +0 -0
  86. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/client.py +0 -0
  87. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/server.py +0 -0
  88. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/settings.py +0 -0
  89. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/share.py +0 -0
  90. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/h2/stream.py +0 -0
  91. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/http.py +0 -0
  92. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/py.typed +0 -0
  93. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/types.py +0 -0
  94. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/__init__.py +0 -0
  95. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/auto.py +0 -0
  96. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/client.py +0 -0
  97. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/graceful.py +0 -0
  98. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/pool.py +0 -0
  99. {httpunk-0.1.4 → httpunk-0.1.5}/httpunk/util/proxy.py +0 -0
  100. {httpunk-0.1.4 → httpunk-0.1.5}/pyproject.toml +0 -0
  101. {httpunk-0.1.4 → httpunk-0.1.5}/src/errors.rs +0 -0
  102. {httpunk-0.1.4 → httpunk-0.1.5}/src/h1/codec.rs +0 -0
  103. {httpunk-0.1.4 → httpunk-0.1.5}/src/h1/mod.rs +0 -0
  104. {httpunk-0.1.4 → httpunk-0.1.5}/src/h2/codec.rs +0 -0
  105. {httpunk-0.1.4 → httpunk-0.1.5}/src/h2/mod.rs +0 -0
  106. {httpunk-0.1.4 → httpunk-0.1.5}/src/h2/streams.rs +0 -0
  107. {httpunk-0.1.4 → httpunk-0.1.5}/src/http/mod.rs +0 -0
  108. {httpunk-0.1.4 → httpunk-0.1.5}/src/lib.rs +0 -0
  109. {httpunk-0.1.4 → httpunk-0.1.5}/src/proxy.rs +0 -0
  110. {httpunk-0.1.4 → httpunk-0.1.5}/tests/_client.py +0 -0
  111. {httpunk-0.1.4 → httpunk-0.1.5}/tests/conftest.py +0 -0
  112. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_asyncio_backend.py +0 -0
  113. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_asyncio_client_protocol.py +0 -0
  114. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_asyncio_e2e.py +0 -0
  115. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_backend.py +0 -0
  116. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h1_codec.py +0 -0
  117. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h1_conn.py +0 -0
  118. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h1_decode.py +0 -0
  119. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h1_server.py +0 -0
  120. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_codec.py +0 -0
  121. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_conn.py +0 -0
  122. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_flow.py +0 -0
  123. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_flow_control.py +0 -0
  124. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_ready.py +0 -0
  125. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_settings.py +0 -0
  126. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_h2_state.py +0 -0
  127. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_headermap.py +0 -0
  128. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_auto.py +0 -0
  129. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_connect.py +0 -0
  130. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_graceful.py +0 -0
  131. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_pool.py +0 -0
  132. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_proxy.py +0 -0
  133. {httpunk-0.1.4 → httpunk-0.1.5}/tests/test_util_tls.py +0 -0
@@ -118,7 +118,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
118
118
 
119
119
  [[package]]
120
120
  name = "httpunk"
121
- version = "0.1.4"
121
+ version = "0.1.5"
122
122
  dependencies = [
123
123
  "bytes",
124
124
  "h2",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "httpunk"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  description = "The Rust HTTP library for Python"
5
5
  authors = [
6
6
  "Giovanni Barillari <g@baro.dev>",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpunk
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: BSD License
@@ -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). Best-effort:
145
- # the connection is closing regardless, so a send failure is ignored.
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.error is None and self._transport is not None:
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
- async def _maybe_goaway_reply(self):
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
- send our acknowledging GOAWAY(NO_ERROR, last-processed-id) exactly once and
261
- report that the pump should stop (F23). No-op otherwise.
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
- await self.send_frame(self.codec.serialize_go_away(self._goaway_last_stream_id(), int(H2Reason.NO_ERROR)))
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, send our
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 transport's
314
- # FIN follows on the caller's `__aexit__` (httpunk's user-driven lifecycle).
315
- if await self._maybe_goaway_reply():
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