httpunk 0.2.0__tar.gz → 0.2.1__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.2.0 → httpunk-0.2.1}/Cargo.lock +1 -1
  2. {httpunk-0.2.0 → httpunk-0.2.1}/Cargo.toml +1 -1
  3. {httpunk-0.2.0 → httpunk-0.2.1}/PKG-INFO +1 -1
  4. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h2/connection.py +72 -20
  5. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h2/server.py +12 -1
  6. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h2/streams.py +37 -0
  7. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h1_server.py +103 -63
  8. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h2_server.py +175 -138
  9. {httpunk-0.2.0 → httpunk-0.2.1}/LICENSE +0 -0
  10. {httpunk-0.2.0 → httpunk-0.2.1}/README.md +0 -0
  11. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/Cargo.toml +0 -0
  12. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/LICENSE +0 -0
  13. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
  14. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/codec/error.rs +0 -0
  15. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/codec/mod.rs +0 -0
  16. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/ext.rs +0 -0
  17. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/data.rs +0 -0
  18. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/go_away.rs +0 -0
  19. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/head.rs +0 -0
  20. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/headers.rs +0 -0
  21. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/mod.rs +0 -0
  22. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/ping.rs +0 -0
  23. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/priority.rs +0 -0
  24. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/reason.rs +0 -0
  25. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/reset.rs +0 -0
  26. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/settings.rs +0 -0
  27. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
  28. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/util.rs +0 -0
  29. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/frame/window_update.rs +0 -0
  30. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
  31. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
  32. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/hpack/header.rs +0 -0
  33. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
  34. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
  35. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/hpack/mod.rs +0 -0
  36. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/hpack/table.rs +0 -0
  37. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/lib.rs +0 -0
  38. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/proto/error.rs +0 -0
  39. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/proto/mod.rs +0 -0
  40. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
  41. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
  42. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
  43. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/Cargo.toml +0 -0
  44. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/LICENSE +0 -0
  45. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
  46. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/body/length.rs +0 -0
  47. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/body/mod.rs +0 -0
  48. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/cfg.rs +0 -0
  49. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/common/date.rs +0 -0
  50. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/common/mod.rs +0 -0
  51. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/error.rs +0 -0
  52. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
  53. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/ext/informational.rs +0 -0
  54. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/ext/mod.rs +0 -0
  55. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/headers.rs +0 -0
  56. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/lib.rs +0 -0
  57. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
  58. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
  59. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
  60. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
  61. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
  62. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
  63. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/proto/mod.rs +0 -0
  64. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper/src/trace.rs +0 -0
  65. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper-util/Cargo.toml +0 -0
  66. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper-util/LICENSE +0 -0
  67. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
  68. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
  69. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
  70. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
  71. {httpunk-0.2.0 → httpunk-0.2.1}/crates/vendor-hyper-util/src/lib.rs +0 -0
  72. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/__init__.py +0 -0
  73. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/_backend/__init__.py +0 -0
  74. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/_backend/asyncio.py +0 -0
  75. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/_backend/tonio.py +0 -0
  76. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/_common.py +0 -0
  77. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/_httpunk.pyi +0 -0
  78. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/asyncio.py +0 -0
  79. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/exceptions.py +0 -0
  80. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h1/__init__.py +0 -0
  81. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h1/client.py +0 -0
  82. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h1/connection.py +0 -0
  83. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h1/server.py +0 -0
  84. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h1/share.py +0 -0
  85. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h2/__init__.py +0 -0
  86. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h2/client.py +0 -0
  87. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h2/settings.py +0 -0
  88. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h2/share.py +0 -0
  89. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/h2/stream.py +0 -0
  90. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/http.py +0 -0
  91. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/py.typed +0 -0
  92. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/types.py +0 -0
  93. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/util/__init__.py +0 -0
  94. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/util/auto.py +0 -0
  95. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/util/client.py +0 -0
  96. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/util/graceful.py +0 -0
  97. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/util/pool.py +0 -0
  98. {httpunk-0.2.0 → httpunk-0.2.1}/httpunk/util/proxy.py +0 -0
  99. {httpunk-0.2.0 → httpunk-0.2.1}/pyproject.toml +0 -0
  100. {httpunk-0.2.0 → httpunk-0.2.1}/src/errors.rs +0 -0
  101. {httpunk-0.2.0 → httpunk-0.2.1}/src/h1/codec.rs +0 -0
  102. {httpunk-0.2.0 → httpunk-0.2.1}/src/h1/mod.rs +0 -0
  103. {httpunk-0.2.0 → httpunk-0.2.1}/src/h2/codec.rs +0 -0
  104. {httpunk-0.2.0 → httpunk-0.2.1}/src/h2/mod.rs +0 -0
  105. {httpunk-0.2.0 → httpunk-0.2.1}/src/h2/streams.rs +0 -0
  106. {httpunk-0.2.0 → httpunk-0.2.1}/src/http/mod.rs +0 -0
  107. {httpunk-0.2.0 → httpunk-0.2.1}/src/lib.rs +0 -0
  108. {httpunk-0.2.0 → httpunk-0.2.1}/src/proxy.rs +0 -0
  109. {httpunk-0.2.0 → httpunk-0.2.1}/tests/_client.py +0 -0
  110. {httpunk-0.2.0 → httpunk-0.2.1}/tests/conftest.py +0 -0
  111. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_asyncio_backend.py +0 -0
  112. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_asyncio_client_protocol.py +0 -0
  113. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_asyncio_e2e.py +0 -0
  114. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_asyncio_protocol.py +0 -0
  115. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_backend.py +0 -0
  116. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h1_codec.py +0 -0
  117. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h1_conn.py +0 -0
  118. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h1_decode.py +0 -0
  119. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h2_codec.py +0 -0
  120. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h2_conn.py +0 -0
  121. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h2_errors.py +0 -0
  122. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h2_flow.py +0 -0
  123. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h2_flow_control.py +0 -0
  124. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h2_ready.py +0 -0
  125. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h2_settings.py +0 -0
  126. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_h2_state.py +0 -0
  127. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_headermap.py +0 -0
  128. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_util_auto.py +0 -0
  129. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_util_connect.py +0 -0
  130. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_util_graceful.py +0 -0
  131. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_util_pool.py +0 -0
  132. {httpunk-0.2.0 → httpunk-0.2.1}/tests/test_util_proxy.py +0 -0
  133. {httpunk-0.2.0 → httpunk-0.2.1}/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.2.0"
121
+ version = "0.2.1"
122
122
  dependencies = [
123
123
  "bytes",
124
124
  "h2",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "httpunk"
3
- version = "0.2.0"
3
+ version = "0.2.1"
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.2.0
3
+ Version: 0.2.1
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: BSD License
@@ -178,12 +178,12 @@ class H2ConnectionBase:
178
178
  # (F23). Best-effort: the connection is closing regardless, so a send
179
179
  # failure (a dead transport — h2's Io branch, which skips straight to
180
180
  # Closed) is ignored.
181
- with self._write_buf_lock:
182
- pending, self._write_buf = bytes(self._write_buf), bytearray()
183
- if pending and self._transport is not None:
181
+ if self._transport is not None:
184
182
  with contextlib.suppress(Exception):
185
183
  async with self._send_lock:
186
- await self._transport.send_all(pending)
184
+ pending = self._take_pending()
185
+ if pending:
186
+ await self._transport.send_all(pending)
187
187
  if self._transport is not None:
188
188
  self.backend.close_transport(self._transport)
189
189
  handle, self._read_handle = self._read_handle, None
@@ -197,8 +197,13 @@ class H2ConnectionBase:
197
197
  self.streams.fail_all(self.error or ConnectionClosedError("connection closed"))
198
198
 
199
199
  async def send_frame(self, data):
200
+ # Drain-then-send under the one send lock: control frames committed
201
+ # (enqueued) before we acquired the wire go out first, so wire order =
202
+ # commit order — h2's single-queue property (a GOAWAY thus follows the
203
+ # RSTs committed before it, like h2 flushing its pending queue). See
204
+ # the design note below.
200
205
  async with self._send_lock:
201
- await self._transport.send_all(data)
206
+ await self._transport.send_all(self._take_pending() + data)
202
207
 
203
208
  # Design note (why three send paths, vs h2's single frame queue): in the h2
204
209
  # crate ALL socket I/O belongs to the one connection task — user handles only
@@ -229,6 +234,24 @@ class H2ConnectionBase:
229
234
  # close in user-task context; `close()` drains the queue before the FIN, so
230
235
  # flushed-before-stop still holds.
231
236
  #
237
+ # ORDERING INVARIANT (the property h2's single queue gives for free, restored
238
+ # here for the hybrid): a control frame committed to the pending buffer before
239
+ # a sender acquires `_send_lock` reaches the wire before that sender's bytes.
240
+ # Two pieces enforce it, and both are load-bearing:
241
+ # - inline senders (`send_frame`, `send_frame_or_fail`) drain the buffer via
242
+ # `_take_pending` and write it FIRST, under the send lock they hold;
243
+ # - the pump swaps the buffer INSIDE `_send_lock` (`_write_pump`), so a batch
244
+ # swapped out but not yet written can never coexist with an available lock
245
+ # (or an inline sender slipping in between swap and flush would still
246
+ # invert the order).
247
+ # Without it, the RST/WINDOW_UPDATE a dispatch enqueues can lose the wire
248
+ # race to a response committed AFTER it (h2 guarantees the reverse), which is
249
+ # peer-observable: the forgotten-stream RST arriving after the next stream's
250
+ # response flaked F17 in CI. Lock order is send -> buf everywhere; the buffer
251
+ # lock is a sync leaf, never held across an await. NOT covered (unchanged,
252
+ # matches the pre-existing hybrid semantics): two INLINE sends serialize in
253
+ # lock-acquisition order, not state-commit order.
254
+ #
232
255
  # The full-fidelity alternative — port h2's buffered-send/`reserve_capacity`
233
256
  # model and route DATA through the queue too — remains open as a future step;
234
257
  # this hybrid was chosen to keep F41's zero-buffering and the inline hot path.
@@ -243,6 +266,18 @@ class H2ConnectionBase:
243
266
  self._write_buf += data
244
267
  self._write_evt.set()
245
268
 
269
+ def _take_pending(self):
270
+ """Swap out everything committed to the pending-send buffer. MUST be
271
+ called while holding `_send_lock` — writing what you take while still
272
+ holding it is what makes wire order equal commit order (see the design
273
+ note above). Deliberately does NOT clear `_write_evt`: only the pump
274
+ clears, in the same locked section where it observes `_write_stop` — a
275
+ drain that cleared here could erase `close()`'s paired stop+wake and
276
+ hang the pump join. The cost is one spurious empty pump wake."""
277
+ with self._write_buf_lock:
278
+ data, self._write_buf = bytes(self._write_buf), bytearray()
279
+ return data
280
+
246
281
  async def _write_pump(self):
247
282
  """Flush the pending-send buffer to the transport. Runs as a bare task
248
283
  (`_pump_handle`) and is never cancelled (see `close()` — a cancel landing between the
@@ -257,23 +292,32 @@ class H2ConnectionBase:
257
292
  or after the clear (its wake survives for the next iteration) — an
258
293
  unlocked clear could erase a wake for bytes not yet swapped (lost
259
294
  wakeup on the free-threaded runtime). A write failure fails the whole
260
- connection — same as h2, where a connection-task write error is fatal."""
295
+ connection — same as h2, where a connection-task write error is fatal.
296
+
297
+ The swap happens INSIDE `_send_lock`: a batch swapped out but not yet
298
+ written must not coexist with an available send lock, or an inline
299
+ sender could slip its (later-committed) frame onto the wire ahead of
300
+ it — the second half of the ordering invariant (the inline senders'
301
+ `_take_pending` drain is the first). Lock order is send -> buf
302
+ everywhere; the buffer lock is a leaf, never held across an await."""
261
303
  while True:
262
304
  await self._write_evt.wait()
263
- with self._write_buf_lock:
264
- data, self._write_buf = bytes(self._write_buf), bytearray()
265
- self._write_evt.clear()
266
- stopping = self._write_stop
267
- if data:
268
- try:
269
- async with self._send_lock:
305
+ failed = None
306
+ async with self._send_lock:
307
+ with self._write_buf_lock:
308
+ data, self._write_buf = bytes(self._write_buf), bytearray()
309
+ self._write_evt.clear()
310
+ stopping = self._write_stop
311
+ if data:
312
+ try:
270
313
  await self._transport.send_all(data)
271
- except OSError as exc:
272
- self._fail(ConnectionClosedError(f"connection closed: {exc}"))
273
- break
274
- except Exception as exc:
275
- self._fail(exc)
276
- break
314
+ except OSError as exc:
315
+ failed = ConnectionClosedError(f"connection closed: {exc}")
316
+ except Exception as exc:
317
+ failed = exc
318
+ if failed is not None:
319
+ self._fail(failed)
320
+ break
277
321
  if stopping:
278
322
  # Drain-then-exit: anything enqueued during the send above left
279
323
  # the event set (its `set()` came after our `clear()`), so loop
@@ -292,8 +336,16 @@ class H2ConnectionBase:
292
336
  connection ourselves (h2 equivalent: write errors are connection-fatal;
293
337
  partial writes are unrepresentable — the connection task resumes them).
294
338
  Cancellation while WAITING for the send lock is safe (nothing written) and
295
- does not poison."""
339
+ does not poison.
340
+
341
+ Drains the pending control buffer first (under the lock — wire order =
342
+ commit order, see the design note): the RST/WINDOW_UPDATE a dispatch
343
+ committed before this frame can no longer lose the wire race to it
344
+ (the rsts=0 flake in the forgotten-stream test). An interruption then
345
+ also loses the drained control frames — moot, since every branch below
346
+ poisons the connection they belonged to."""
296
347
  async with self._send_lock:
348
+ data = self._take_pending() + data
297
349
  try:
298
350
  await self._transport.send_all(data)
299
351
  except BaseException as exc:
@@ -191,7 +191,18 @@ class ServerStreamManager(StreamManager):
191
191
  if st is not None:
192
192
  if st.state.is_local_error():
193
193
  return None # locally reset: swallow late frames "for some time"
194
- return st # existing stream -> the shared trailers path
194
+ if st.state.is_closed():
195
+ # Completed normally but still stored — the inline-write window
196
+ # (`StreamManager._drop_closed_stored`): drop it and classify
197
+ # below exactly as if already forgotten. For a server HEADERS
198
+ # target the deterministic post-release answer is the
199
+ # decreased-id connection error (h2 recv.rs `open` L127) — a
200
+ # peer sending HEADERS after its own END_STREAM violates either
201
+ # way, and the outcome must not depend on which side of the
202
+ # window the frame lands on.
203
+ self._drop_closed_stored(st)
204
+ else:
205
+ return st # existing stream -> the shared trailers path
195
206
  reset_at = self._reset_streams.get(sid)
196
207
  if reset_at is not None:
197
208
  if self._conn.backend.monotonic() - reset_at <= _RESET_STREAM_SECS:
@@ -678,6 +678,29 @@ class StreamManager:
678
678
 
679
679
  # ===== per-frame recv dispatch (h2: proto/streams/streams.rs recv_*) =====
680
680
 
681
+ def _drop_closed_stored(self, st):
682
+ """Release a normally-completed stream observed while still stored — the
683
+ inline-write window: `respond`/`send_body` transition the state to Closed
684
+ BEFORE their awaited wire write and pop the stream only after it, so a
685
+ truly-parallel dispatch (free-threaded read pump) can catch it in
686
+ between. Upstream can't: its one connection task flushes, releases, and
687
+ dispatches serially under the store mutex, so by the time the peer can
688
+ react to the response the stream is already released. Sync, like all of
689
+ the lookup classification.
690
+
691
+ Deliberately NO waiter repair and NO `st.error`: a fully-closed stream
692
+ has its head delivered and EOF already queued on the body channel, so
693
+ nothing can be parked on it — and a straggling reader re-reads
694
+ `st.error` after EOF, so setting it would turn a completed exchange
695
+ into a spurious StreamResetError (the F18 repair in `reset_stream` is
696
+ for streams that closed ERRORED with waiters still parked, not this).
697
+ The reclaim is a no-op when `respond`/`aclose` already ran it (F22
698
+ `recv_reclaimed`), and covers the in-window case where it hasn't."""
699
+ conn_wu = self._reclaim_stream_accounting(st)
700
+ self._close_stream(st)
701
+ if conn_wu:
702
+ self._conn.enqueue_frame(self._conn.codec.serialize_window_update(0, conn_wu))
703
+
681
704
  def _recv_lookup(self, stream_id):
682
705
  """Resolve the target stream for an inbound HEADERS/DATA frame, or
683
706
  classify why there isn't one.
@@ -698,6 +721,20 @@ class StreamManager:
698
721
  # Locally reset: ignore frames "for some time" (the peer may have
699
722
  # sent trailers/data before receiving our RST_STREAM).
700
723
  return None
724
+ if st.state.is_closed():
725
+ # Completed normally but still stored — the inline-write window
726
+ # (`_drop_closed_stored`). Upstream's serialized lookup can only
727
+ # ever see this stream as already forgotten, so give the peer
728
+ # exactly the forgotten answer — drop it and classify
729
+ # STREAM_CLOSED: the caller then RSTs once and the id enters the
730
+ # reset store (`reset_on_error`'s forgotten path), making the
731
+ # peer-visible outcome independent of which side of the window
732
+ # the frame lands on. (Upstream's own guard here — recv.rs
733
+ # recv_data L665-673 — calls it a connection error, with a TODO
734
+ # conceding STREAM_CLOSED; only its seriality keeps that arm
735
+ # unreachable for a benign peer.)
736
+ self._drop_closed_stored(st)
737
+ raise _StreamError(stream_id, int(H2Reason.STREAM_CLOSED))
701
738
  return st
702
739
  reset_at = self._reset_streams.get(stream_id)
703
740
  if reset_at is not None:
@@ -25,7 +25,14 @@ async def _listener():
25
25
 
26
26
  async def _raw_client(host, port):
27
27
  """A byte-level transport — for wire-fidelity cases the (sequential) httpunk
28
- client can't drive: pipelining, HTTP/1.0, upgrades, malformed heads."""
28
+ client can't drive: pipelining, HTTP/1.0, upgrades, malformed heads.
29
+
30
+ Callers must run their test body in `try/finally: transport.close(); s.cancel()`:
31
+ an assertion that fires while the transport is still open leaves the spawned
32
+ server parked in a read only our EOF can end (tonio's optimistic cancellation
33
+ cannot land on its pre-existing waiter), and the scope join then waits it out
34
+ forever — the conftest deadline turns the real failure into an opaque timeout.
35
+ Both calls are idempotent, so a body that already closed/cancelled is fine."""
29
36
  return await TonioBackend().connect_tcp(host, port)
30
37
 
31
38
 
@@ -248,12 +255,14 @@ async def test_server_pipelined_requests():
248
255
  async with scope() as s:
249
256
  s.spawn(_echo_server(listener, seen))
250
257
  transport = await _raw_client(host, port)
251
- await transport.send_all(b"GET /a HTTP/1.1\r\nhost: x\r\n\r\nGET /b HTTP/1.1\r\nhost: x\r\n\r\n")
252
- data = await _read_until(transport, b"GET /b -> ")
253
- assert b"GET /a -> " in data
254
- assert b"GET /b -> " in data
255
- transport.close()
256
- s.cancel()
258
+ try:
259
+ await transport.send_all(b"GET /a HTTP/1.1\r\nhost: x\r\n\r\nGET /b HTTP/1.1\r\nhost: x\r\n\r\n")
260
+ data = await _read_until(transport, b"GET /b -> ")
261
+ assert b"GET /a -> " in data
262
+ assert b"GET /b -> " in data
263
+ finally:
264
+ transport.close()
265
+ s.cancel()
257
266
  assert seen == ["/a", "/b"]
258
267
 
259
268
 
@@ -265,11 +274,14 @@ async def test_server_http10_response_version_and_close():
265
274
  async with scope() as s:
266
275
  s.spawn(_echo_server(listener))
267
276
  transport = await _raw_client(host, port)
268
- await transport.send_all(b"GET /old HTTP/1.0\r\nhost: x\r\n\r\n")
269
- data = await _drain_all(transport) # 1.0 default-close → server closes after replying
270
- assert data.startswith(b"HTTP/1.0 200")
271
- assert b"GET /old -> " in data
272
- s.cancel()
277
+ try:
278
+ await transport.send_all(b"GET /old HTTP/1.0\r\nhost: x\r\n\r\n")
279
+ data = await _drain_all(transport) # 1.0 default-close → server closes after replying
280
+ assert data.startswith(b"HTTP/1.0 200")
281
+ assert b"GET /old -> " in data
282
+ finally:
283
+ transport.close()
284
+ s.cancel()
273
285
 
274
286
 
275
287
  @pytest.mark.tonio
@@ -287,10 +299,13 @@ async def test_server_header_read_timeout_closes_slow_head():
287
299
  async with scope() as s:
288
300
  s.spawn(serve())
289
301
  transport = await _raw_client(host, port)
290
- await transport.send_all(b"GET / HTTP/1.1\r\nhost: x\r\n") # partial head, never completes
291
- data = await _drain_all(transport) # server times out and closes -> EOF
292
- assert data == b"" # no response sent; the connection was just closed
293
- s.cancel()
302
+ try:
303
+ await transport.send_all(b"GET / HTTP/1.1\r\nhost: x\r\n") # partial head, never completes
304
+ data = await _drain_all(transport) # server times out and closes -> EOF
305
+ assert data == b"" # no response sent; the connection was just closed
306
+ finally:
307
+ transport.close()
308
+ s.cancel()
294
309
 
295
310
 
296
311
  @pytest.mark.asyncio
@@ -333,10 +348,13 @@ async def test_server_detach_hands_off_open_connection():
333
348
  async with scope() as s:
334
349
  s.spawn(serve())
335
350
  client = await _raw_client(host, port)
336
- # request head + trailing bytes the client sent past it (must survive as `leftover`)
337
- await client.send_all(b"GET /up HTTP/1.1\r\nhost: x\r\nupgrade: custom\r\nconnection: upgrade\r\n\r\nEXTRA")
338
- data = await _read_until(client, b"OWNED")
339
- s.cancel()
351
+ try:
352
+ # request head + trailing bytes the client sent past it (must survive as `leftover`)
353
+ await client.send_all(b"GET /up HTTP/1.1\r\nhost: x\r\nupgrade: custom\r\nconnection: upgrade\r\n\r\nEXTRA")
354
+ data = await _read_until(client, b"OWNED")
355
+ finally:
356
+ client.close()
357
+ s.cancel()
340
358
 
341
359
  assert captured["leftover"] == b"EXTRA" # bytes past the head handed back
342
360
  assert b"101 Switching Protocols" in data and data.endswith(b"OWNED") # socket stayed open
@@ -350,11 +368,16 @@ async def test_server_no_100_continue_for_http10():
350
368
  async with scope() as s:
351
369
  s.spawn(_echo_server(listener))
352
370
  transport = await _raw_client(host, port)
353
- await transport.send_all(b"POST /x HTTP/1.0\r\nhost: x\r\ncontent-length: 3\r\nexpect: 100-continue\r\n\r\nabc")
354
- data = await _drain_all(transport) # 1.0 default-close → server closes after replying
355
- assert b"100 Continue" not in data # F16: no interim for a 1.0 client
356
- assert data.startswith(b"HTTP/1.0 200") # the real response still arrives
357
- s.cancel()
371
+ try:
372
+ await transport.send_all(
373
+ b"POST /x HTTP/1.0\r\nhost: x\r\ncontent-length: 3\r\nexpect: 100-continue\r\n\r\nabc"
374
+ )
375
+ data = await _drain_all(transport) # 1.0 default-close → server closes after replying
376
+ assert b"100 Continue" not in data # F16: no interim for a 1.0 client
377
+ assert data.startswith(b"HTTP/1.0 200") # the real response still arrives
378
+ finally:
379
+ transport.close()
380
+ s.cancel()
358
381
 
359
382
 
360
383
  @pytest.mark.tonio
@@ -375,14 +398,16 @@ async def test_server_http10_streamed_body_with_content_length_reuses():
375
398
  async with scope() as s:
376
399
  s.spawn(serve())
377
400
  transport = await _raw_client(host, port)
378
- await transport.send_all(b"GET /a HTTP/1.0\r\nhost: x\r\nconnection: keep-alive\r\n\r\n")
379
- r1 = await _read_until(transport, b"/a") # head + the 2-byte length-framed body
380
- assert r1.startswith(b"HTTP/1.0 200")
381
- assert b"connection: keep-alive" in r1.lower() # reusable, not close-delimited
382
- await transport.send_all(b"GET /b HTTP/1.0\r\nhost: x\r\nconnection: keep-alive\r\n\r\n")
383
- assert b"/b" in await _read_until(transport, b"/b") # connection was reused
384
- transport.close()
385
- s.cancel()
401
+ try:
402
+ await transport.send_all(b"GET /a HTTP/1.0\r\nhost: x\r\nconnection: keep-alive\r\n\r\n")
403
+ r1 = await _read_until(transport, b"/a") # head + the 2-byte length-framed body
404
+ assert r1.startswith(b"HTTP/1.0 200")
405
+ assert b"connection: keep-alive" in r1.lower() # reusable, not close-delimited
406
+ await transport.send_all(b"GET /b HTTP/1.0\r\nhost: x\r\nconnection: keep-alive\r\n\r\n")
407
+ assert b"/b" in await _read_until(transport, b"/b") # connection was reused
408
+ finally:
409
+ transport.close()
410
+ s.cancel()
386
411
 
387
412
 
388
413
  @pytest.mark.tonio
@@ -393,12 +418,14 @@ async def test_server_http10_keep_alive_header():
393
418
  async with scope() as s:
394
419
  s.spawn(_echo_server(listener))
395
420
  transport = await _raw_client(host, port)
396
- await transport.send_all(b"GET /a HTTP/1.0\r\nhost: x\r\nconnection: keep-alive\r\n\r\n")
397
- data = await _read_until(transport, b"\r\n\r\n")
398
- assert data.startswith(b"HTTP/1.0 200")
399
- assert b"connection: keep-alive" in data.lower()
400
- transport.close()
401
- s.cancel()
421
+ try:
422
+ await transport.send_all(b"GET /a HTTP/1.0\r\nhost: x\r\nconnection: keep-alive\r\n\r\n")
423
+ data = await _read_until(transport, b"\r\n\r\n")
424
+ assert data.startswith(b"HTTP/1.0 200")
425
+ assert b"connection: keep-alive" in data.lower()
426
+ finally:
427
+ transport.close()
428
+ s.cancel()
402
429
 
403
430
 
404
431
  @pytest.mark.tonio
@@ -417,11 +444,14 @@ async def test_server_response_connection_close():
417
444
  async with scope() as s:
418
445
  s.spawn(serve())
419
446
  transport = await _raw_client(host, port)
420
- await transport.send_all(b"GET /a HTTP/1.1\r\nhost: x\r\n\r\n")
421
- data = await _drain_all(transport) # server closes despite the keep-alive request
422
- assert b"connection: close" in data.lower()
423
- assert b"bye" in data
424
- s.cancel()
447
+ try:
448
+ await transport.send_all(b"GET /a HTTP/1.1\r\nhost: x\r\n\r\n")
449
+ data = await _drain_all(transport) # server closes despite the keep-alive request
450
+ assert b"connection: close" in data.lower()
451
+ assert b"bye" in data
452
+ finally:
453
+ transport.close()
454
+ s.cancel()
425
455
 
426
456
 
427
457
  @pytest.mark.tonio
@@ -441,10 +471,13 @@ async def test_server_unread_large_body_closes_not_drains():
441
471
  async with scope() as s:
442
472
  s.spawn(serve())
443
473
  transport = await _raw_client(host, port)
444
- await transport.send_all(b"POST / HTTP/1.1\r\nhost: x\r\ncontent-length: 1000000\r\n\r\npartial")
445
- data = await _drain_all(transport) # returns once the server closes (no hang)
446
- assert b"ok" in data
447
- s.cancel()
474
+ try:
475
+ await transport.send_all(b"POST / HTTP/1.1\r\nhost: x\r\ncontent-length: 1000000\r\n\r\npartial")
476
+ data = await _drain_all(transport) # returns once the server closes (no hang)
477
+ assert b"ok" in data
478
+ finally:
479
+ transport.close()
480
+ s.cancel()
448
481
 
449
482
 
450
483
  @pytest.mark.tonio
@@ -468,13 +501,15 @@ async def test_server_upgrade_tunnel():
468
501
  async with scope() as s:
469
502
  s.spawn(serve())
470
503
  transport = await _raw_client(host, port)
471
- await transport.send_all(b"GET /chat HTTP/1.1\r\nhost: x\r\nconnection: upgrade\r\nupgrade: myproto\r\n\r\n")
472
- head = await _read_until(transport, b"\r\n\r\n")
473
- assert head.startswith(b"HTTP/1.1 101")
474
- await transport.send_all(b"ping")
475
- assert await transport.receive_some() == b"echo:ping"
476
- transport.close()
477
- s.cancel()
504
+ try:
505
+ await transport.send_all(b"GET /chat HTTP/1.1\r\nhost: x\r\nconnection: upgrade\r\nupgrade: myproto\r\n\r\n")
506
+ head = await _read_until(transport, b"\r\n\r\n")
507
+ assert head.startswith(b"HTTP/1.1 101")
508
+ await transport.send_all(b"ping")
509
+ assert await transport.receive_some() == b"echo:ping"
510
+ finally:
511
+ transport.close()
512
+ s.cancel()
478
513
 
479
514
 
480
515
  @pytest.mark.tonio
@@ -673,13 +708,18 @@ async def test_abrupt_client_close_ends_iteration_cleanly():
673
708
  async with scope() as s:
674
709
  s.spawn(server())
675
710
  transport = await _raw_client(host, port)
676
- await transport.send_all(b"GET /one HTTP/1.1\r\nhost: x\r\n\r\n")
677
- resp = b""
678
- while b"\r\n\r\n" not in resp:
679
- resp += await transport.receive_some(65536)
680
- # RST the connection while the server is parked in the next head-read.
681
- transport.socket._sock.setsockopt(pysock.SOL_SOCKET, pysock.SO_LINGER, struct.pack("ii", 1, 0))
682
- transport.close()
683
- # The scope join below waits out the server task: it must finish, cleanly.
711
+ try:
712
+ await transport.send_all(b"GET /one HTTP/1.1\r\nhost: x\r\n\r\n")
713
+ resp = b""
714
+ while b"\r\n\r\n" not in resp:
715
+ chunk = await transport.receive_some(65536)
716
+ assert chunk, "connection closed before the response head"
717
+ resp += chunk
718
+ # RST the connection while the server is parked in the next head-read.
719
+ transport.socket._sock.setsockopt(pysock.SOL_SOCKET, pysock.SO_LINGER, struct.pack("ii", 1, 0))
720
+ finally:
721
+ # No s.cancel(): the scope join waiting out the server task — which must
722
+ # finish CLEANLY on its own — is the assertion under test.
723
+ transport.close()
684
724
  assert served == ["/one"]
685
725
  assert not server_errors