httpunk 0.4.3__tar.gz → 0.4.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 (142) hide show
  1. {httpunk-0.4.3 → httpunk-0.4.5}/Cargo.lock +1 -1
  2. {httpunk-0.4.3 → httpunk-0.4.5}/Cargo.toml +1 -1
  3. {httpunk-0.4.3 → httpunk-0.4.5}/PKG-INFO +1 -1
  4. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/_backend/asyncio.py +74 -0
  5. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/_backend/tonio.py +71 -0
  6. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/_httpunk.pyi +41 -10
  7. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h1/client.py +120 -65
  8. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h1/connection.py +39 -18
  9. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h1/server.py +115 -81
  10. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h1/share.py +8 -9
  11. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/auto.py +55 -47
  12. {httpunk-0.4.3 → httpunk-0.4.5}/src/h1/codec.rs +24 -0
  13. {httpunk-0.4.3 → httpunk-0.4.5}/src/h1/conn.rs +135 -17
  14. {httpunk-0.4.3 → httpunk-0.4.5}/tests/_transport.py +3 -0
  15. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h1_codec.py +48 -0
  16. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h1_conn.py +118 -0
  17. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h1_server.py +56 -0
  18. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_auto.py +19 -3
  19. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_connect.py +5 -1
  20. {httpunk-0.4.3 → httpunk-0.4.5}/LICENSE +0 -0
  21. {httpunk-0.4.3 → httpunk-0.4.5}/README.md +0 -0
  22. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/Cargo.toml +0 -0
  23. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/LICENSE +0 -0
  24. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
  25. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/codec/error.rs +0 -0
  26. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/codec/mod.rs +0 -0
  27. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/error.rs +0 -0
  28. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/ext.rs +0 -0
  29. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/data.rs +0 -0
  30. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/go_away.rs +0 -0
  31. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/head.rs +0 -0
  32. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/headers.rs +0 -0
  33. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/mod.rs +0 -0
  34. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/ping.rs +0 -0
  35. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/priority.rs +0 -0
  36. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/reason.rs +0 -0
  37. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/reset.rs +0 -0
  38. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/settings.rs +0 -0
  39. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
  40. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/util.rs +0 -0
  41. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/window_update.rs +0 -0
  42. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
  43. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
  44. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/header.rs +0 -0
  45. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
  46. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
  47. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/mod.rs +0 -0
  48. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/table.rs +0 -0
  49. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/lib.rs +0 -0
  50. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/proto/error.rs +0 -0
  51. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/proto/mod.rs +0 -0
  52. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
  53. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
  54. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
  55. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/Cargo.toml +0 -0
  56. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/LICENSE +0 -0
  57. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
  58. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/body/length.rs +0 -0
  59. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/body/mod.rs +0 -0
  60. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/cfg.rs +0 -0
  61. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/common/date.rs +0 -0
  62. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/common/mod.rs +0 -0
  63. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/error.rs +0 -0
  64. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
  65. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/ext/informational.rs +0 -0
  66. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/ext/mod.rs +0 -0
  67. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/headers.rs +0 -0
  68. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/lib.rs +0 -0
  69. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
  70. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
  71. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
  72. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
  73. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
  74. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
  75. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/mod.rs +0 -0
  76. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/trace.rs +0 -0
  77. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/Cargo.toml +0 -0
  78. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/LICENSE +0 -0
  79. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
  80. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
  81. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
  82. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
  83. {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/src/lib.rs +0 -0
  84. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/__init__.py +0 -0
  85. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/_backend/__init__.py +0 -0
  86. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/_common.py +0 -0
  87. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/asyncio.py +0 -0
  88. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/exceptions.py +0 -0
  89. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h1/__init__.py +0 -0
  90. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/__init__.py +0 -0
  91. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/client.py +0 -0
  92. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/connection.py +0 -0
  93. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/server.py +0 -0
  94. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/share.py +0 -0
  95. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/stream.py +0 -0
  96. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/http.py +0 -0
  97. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/py.typed +0 -0
  98. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/types.py +0 -0
  99. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/__init__.py +0 -0
  100. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/client.py +0 -0
  101. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/graceful.py +0 -0
  102. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/pool.py +0 -0
  103. {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/proxy.py +0 -0
  104. {httpunk-0.4.3 → httpunk-0.4.5}/pyproject.toml +0 -0
  105. {httpunk-0.4.3 → httpunk-0.4.5}/src/errors.rs +0 -0
  106. {httpunk-0.4.3 → httpunk-0.4.5}/src/h1/errors.rs +0 -0
  107. {httpunk-0.4.3 → httpunk-0.4.5}/src/h1/mod.rs +0 -0
  108. {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/codec.rs +0 -0
  109. {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/conn.rs +0 -0
  110. {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/errors.rs +0 -0
  111. {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/mod.rs +0 -0
  112. {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/reason.rs +0 -0
  113. {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/settings.rs +0 -0
  114. {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/streams.rs +0 -0
  115. {httpunk-0.4.3 → httpunk-0.4.5}/src/http/mod.rs +0 -0
  116. {httpunk-0.4.3 → httpunk-0.4.5}/src/lib.rs +0 -0
  117. {httpunk-0.4.3 → httpunk-0.4.5}/src/proxy.rs +0 -0
  118. {httpunk-0.4.3 → httpunk-0.4.5}/src/py.rs +0 -0
  119. {httpunk-0.4.3 → httpunk-0.4.5}/tests/_client.py +0 -0
  120. {httpunk-0.4.3 → httpunk-0.4.5}/tests/conftest.py +0 -0
  121. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_asyncio_backend.py +0 -0
  122. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_asyncio_client_protocol.py +0 -0
  123. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_asyncio_e2e.py +0 -0
  124. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_asyncio_protocol.py +0 -0
  125. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_backend.py +0 -0
  126. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_error_retention.py +0 -0
  127. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h1_decode.py +0 -0
  128. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_codec.py +0 -0
  129. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_conn.py +0 -0
  130. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_errors.py +0 -0
  131. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_flow.py +0 -0
  132. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_flow_control.py +0 -0
  133. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_ready.py +0 -0
  134. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_server.py +0 -0
  135. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_settings.py +0 -0
  136. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_state.py +0 -0
  137. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_write_batch.py +0 -0
  138. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_headermap.py +0 -0
  139. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_graceful.py +0 -0
  140. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_pool.py +0 -0
  141. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_proxy.py +0 -0
  142. {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_tls.py +0 -0
@@ -134,7 +134,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
134
134
 
135
135
  [[package]]
136
136
  name = "httpunk"
137
- version = "0.4.3"
137
+ version = "0.4.5"
138
138
  dependencies = [
139
139
  "bytes",
140
140
  "h2",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "httpunk"
3
- version = "0.4.3"
3
+ version = "0.4.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.4.3
3
+ Version: 0.4.5
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: BSD License
@@ -25,6 +25,7 @@ from ..exceptions import fresh_exc as _fresh_exc
25
25
 
26
26
 
27
27
  _READ_HIGH_WATER = 2**16 # 64 KiB — pause reading past this (matches StreamReader's default limit)
28
+ _detached = set() # strong references to detached tasks until they finish (`spawn_detached`)
28
29
 
29
30
 
30
31
  class _AsyncioStream(asyncio.Protocol):
@@ -205,6 +206,37 @@ class _AsyncioStream(asyncio.Protocol):
205
206
  else:
206
207
  self._transport.close()
207
208
 
209
+ def readable_now(self):
210
+ """Bytes (or EOF / an error) can be read NOW without suspending."""
211
+ return bool(self._buffered or self._eof)
212
+
213
+ async def wait_readable_or(self, event):
214
+ """Park until bytes / EOF / an error land (the read future `receive_some` parks
215
+ on, resolved by the protocol callbacks) OR `event` is set — first of the two, the
216
+ loser cancelled and drained. Single-reader contract as `receive_some`."""
217
+ if self._read_waiter is not None:
218
+ raise RuntimeError("concurrent receive_some on one stream (single-reader contract)")
219
+ waiter = self._read_waiter = self._loop.create_future()
220
+ ev = asyncio.ensure_future(event.wait())
221
+ try:
222
+ await asyncio.wait({waiter, ev}, return_when=asyncio.FIRST_COMPLETED)
223
+ finally:
224
+ self._read_waiter = None
225
+ ev.cancel()
226
+ await asyncio.gather(ev, return_exceptions=True)
227
+
228
+ def writable_now(self):
229
+ """The socket can take a write NOW: asyncio's transport buffers every write, so
230
+ this is false only under write backpressure (`pause_writing` until `resume_writing`)."""
231
+ return not self._writing_paused
232
+
233
+ def drain_future(self):
234
+ """The future `send_all` parks on under backpressure (created here if none):
235
+ resolved by `resume_writing` / `connection_lost`."""
236
+ if self._drain_waiter is None:
237
+ self._drain_waiter = self._loop.create_future()
238
+ return self._drain_waiter
239
+
208
240
  def read_nowait(self, max_bytes=65536):
209
241
  """Synchronous non-blocking peek: whatever is buffered right now, `b""` once
210
242
  EOF arrived, else `None` (the `receive_nowait` primitive — approach B peeks
@@ -389,6 +421,48 @@ class AsyncioBackend:
389
421
  own read (`_AsyncioStream.receive_bounded`: one timer handle on the read future)."""
390
422
  return transport.receive_bounded
391
423
 
424
+ def readable_wait(self, transport):
425
+ """The read-side twin of `writable_wait` (see the tonio twin): `wait(event) ->
426
+ awaitable | None` — `None` when the stream's buffer holds bytes (or EOF) now, else
427
+ the read future beside the event."""
428
+
429
+ def wait(event):
430
+ if transport.readable_now():
431
+ return None
432
+ return transport.wait_readable_or(event)
433
+
434
+ return wait
435
+
436
+ def writable_wait(self, transport):
437
+ """Chosen once per connection (see the tonio twin): `wait(event) -> awaitable |
438
+ None`. asyncio's transport never refuses a write (it buffers), so the socket is
439
+ writable unless the protocol is under backpressure; then the wait is the drain
440
+ future beside the event, first of the two, the loser cancelled and drained."""
441
+
442
+ async def wait_both(event):
443
+ drain = transport.drain_future()
444
+ ev = asyncio.ensure_future(event.wait())
445
+ try:
446
+ await asyncio.wait({drain, ev}, return_when=asyncio.FIRST_COMPLETED)
447
+ finally:
448
+ ev.cancel()
449
+ await asyncio.gather(ev, return_exceptions=True)
450
+
451
+ def wait(event):
452
+ if transport.writable_now():
453
+ return None
454
+ return wait_both(event)
455
+
456
+ return wait
457
+
458
+ def spawn_detached(self, coro):
459
+ """Spawn a task with no handle (see the tonio twin): the loop holds the only
460
+ strong reference while it runs (a task parked on a future is reachable from that
461
+ future's callbacks; `_detached` keeps one until it finishes, as asyncio advises)."""
462
+ task = asyncio.ensure_future(coro)
463
+ _detached.add(task)
464
+ task.add_done_callback(_detached.discard)
465
+
392
466
  def close_transport(self, transport):
393
467
  """The ABORTIVE close (sync): hyper dropping the IO without `poll_shutdown` —
394
468
  an error out of the connection (a transport failure, the header-read deadline,
@@ -211,6 +211,72 @@ class TonioBackend:
211
211
 
212
212
  return read
213
213
 
214
+ def readable_wait(self, transport):
215
+ """The read-side twin of `writable_wait`, chosen ONCE per sniff: `wait(event) ->
216
+ awaitable | None` — `None` when bytes can be read NOW, else ONE suspension on the
217
+ socket's readable readiness merged with `event` (the auto server's cancel
218
+ signal beside its preface peek: no closer task, nothing cancelled). The caller
219
+ reads without suspending afterwards (`receive_nowait`), asking this again first
220
+ (the readiness question, for the tick guard).
221
+
222
+ - **Plain socket**: `waiter_readable()` is the arm.
223
+ - **TLS (`TLSStream`)**: `watch_readable()` — plaintext already decoded counts as
224
+ readable; the receive lock is held across the park and released before the
225
+ decrypting peek."""
226
+ if isinstance(transport, _TLSStream):
227
+ watch = transport.watch_readable
228
+
229
+ def wait(event):
230
+ with watch() as watcher:
231
+ if (waiter := watcher.waiter()) is None:
232
+ return None
233
+ return waiter | event.waiter(None)
234
+
235
+ return wait
236
+ arm = transport.waiter_readable
237
+
238
+ def wait(event):
239
+ if (waiter := arm()) is None:
240
+ return None
241
+ return waiter | event.waiter(None)
242
+
243
+ return wait
244
+
245
+ def writable_wait(self, transport):
246
+ """Chosen ONCE per connection (like `bounded_reader`): how the h1 client's
247
+ exchange waits for `transport` to have room for a write, beside an event — the
248
+ write side of hyper's `poll_loop` turn (`poll_flush` Pending, then the task
249
+ wakes on writable OR readable). Returns `wait(event) -> awaitable | None`:
250
+ `None` when the socket can take bytes NOW (the readiness bits are set: nothing
251
+ to wait for), else ONE suspension on the socket's writable readiness merged
252
+ with `event` (tonio's `Waiter.__or__`: any-mode) — no task, nothing cancelled,
253
+ no verdict: the caller reads the event's flag. The arm is only a wake: the
254
+ `send_all` that follows asks the readiness question itself before its syscall
255
+ (tonio's `send`), so the tick guard is honoured there.
256
+
257
+ - **Plain socket**: `waiter_writable()` is the arm, `None` when the bits landed.
258
+ - **TLS (`TLSStream`)**: `watch_writable()` holds the send lock across the arm
259
+ and the park (as tonio's `wait_writable`); the lock is released before the
260
+ writer task runs, so the two never hold it together."""
261
+ if isinstance(transport, _TLSStream):
262
+ watch = transport.watch_writable
263
+
264
+ def wait(event):
265
+ with watch() as watcher:
266
+ if (waiter := watcher.waiter()) is None:
267
+ return None
268
+ return waiter | event.waiter(None)
269
+
270
+ return wait
271
+ arm = transport.waiter_writable
272
+
273
+ def wait(event):
274
+ if (waiter := arm()) is None:
275
+ return None
276
+ return waiter | event.waiter(None)
277
+
278
+ return wait
279
+
214
280
  def close_transport(self, transport):
215
281
  """The ABORTIVE close (sync): hyper dropping the IO without `poll_shutdown` —
216
282
  an error out of the connection (a transport failure, the header-read deadline,
@@ -266,6 +332,11 @@ class TonioBackend:
266
332
  # route errors into connection state); an escaped one surfaces at the
267
333
  # join in a backend-specific shape and is a driver bug, not API.
268
334
  spawn_without_results = staticmethod(_colored.spawn.without_results)
335
+ # Spawn a task NOW with NO handle at all: nothing to join, nothing to cancel. The
336
+ # primitive for a task whose completion is its own event (the h1 client's request
337
+ # writer sets one in its `finally`) and whose only external end is the transport
338
+ # closing under its parked write. Spawned coroutines must not let exceptions escape.
339
+ spawn_detached = staticmethod(_colored.spawn.without_tracking)
269
340
 
270
341
  select = staticmethod(_colored.select)
271
342
  # `await select_events(*events)`: resume once ANY of the events is set — ONE suspension
@@ -293,6 +293,9 @@ class H1ResponseHead:
293
293
  content_length: int | None
294
294
  is_upgrade: bool # 101 upgrade, or 2xx to CONNECT — the connection becomes a tunnel
295
295
  http10: bool
296
+ # hyper `Conn::wants_keep_alive()` after this head: the response allows keep-alive, its
297
+ # body is not close-delimited, and the request carried no `Connection: close`.
298
+ wants_keep_alive: bool
296
299
  def __repr__(self) -> str: ...
297
300
 
298
301
  class H1RequestHead:
@@ -333,6 +336,9 @@ class H1BodyDecoder:
333
336
  def take_trailers(self) -> HeaderMap | None:
334
337
  """The chunked trailers once the body is complete, if any; taken (moved)."""
335
338
 
339
+ def finish(self) -> tuple[HeaderMap | None, int]:
340
+ """The body is complete, one step: `(trailers taken, bytes buffered past the body)`."""
341
+
336
342
  @property
337
343
  def buffered(self) -> int:
338
344
  """Bytes buffered past the body, without moving them (hyper's
@@ -711,15 +717,25 @@ class H1ServerState:
711
717
  """The one-poll drain's outcome: `H1_NEXT_CLOSE` + transport, or the read verdict."""
712
718
 
713
719
  def unpark_idle_read(self) -> bool:
714
- """The idle read returned; True = a shutdown closed the connection while it was parked."""
720
+ """A head read ended without bytes to parse (an error, EOF); True = the connection
721
+ was closed under it (a graceful shutdown while idle, or the head-read deadline).
722
+ A read that returned bytes ends its park in `accept_head`."""
723
+
724
+ def head_read_overdue(self, timeout: float) -> tuple[bool, float | None, object | None]:
725
+ """The deadline watchdog's step: `(expired, remaining, transport)`. `expired` = a
726
+ head has been in the reading for `timeout` seconds: closed here, the transport
727
+ to close is returned. Else `remaining` = what is left of the head read in
728
+ progress (None: no head is being read). Closed connection: `(False, None, None)`."""
715
729
 
716
730
  def accept_head(self, data: bytes) -> tuple[H1RequestHead, int, H1BodyDecoder] | None:
717
- """Feed the head parser; once a head is complete it is the current request:
718
- `(head, seq, its body decoder — fed the bytes read alongside the head)`; None =
719
- read more. Raises `H1ParseError` (the codec remembers the automatic status)."""
731
+ """The read returned: end its park and feed the head parser (one step); once a
732
+ head is complete it is the current request: `(head, seq, its body decoder — fed
733
+ the bytes read alongside the head)`; None = read more — also the answer of a
734
+ connection closed under the read, which parses nothing. Raises `H1ParseError`
735
+ (the codec remembers the automatic status)."""
720
736
 
721
737
  def fail_read(self) -> object | None:
722
- """A head parse failure / deadline / broken transport: closed; the transport to close."""
738
+ """A head parse failure / broken transport at the head: closed; the transport to close."""
723
739
 
724
740
  def stop_serving(self) -> None: ...
725
741
  def mark_closed(self) -> tuple[object | None, object | None, bool]:
@@ -762,10 +778,11 @@ class H1ServerState:
762
778
  content_length: int | None = ...,
763
779
  chunked: bool = ...,
764
780
  want: bool = ...,
765
- ) -> tuple[int, bytes | None, bool]:
781
+ ) -> tuple[int, bytes | None, bool, object | None]:
766
782
  """The head step as one transition: claim, verdicts, encode, head-time
767
- decisions, arm decision. `(H1_REQ_* code, encoded head, armed)`: `H1_REQ_OK` +
768
- head (+ spawn the watcher for `done` if `armed`); `H1_REQ_CONTINUE` = claimed,
783
+ decisions, arm decision. `(H1_REQ_* code, encoded head, armed, transport)`:
784
+ `H1_REQ_OK` + head + the transport to write it with, taken under the claim's lock
785
+ (None = closed) (+ spawn the watcher for `done` if `armed`); `H1_REQ_CONTINUE` = claimed,
769
786
  await the request's continue event and call again; `H1_REQ_PEER_CLOSED`;
770
787
  `H1_REQ_ALREADY` / `H1_REQ_STALE`. Raises the encoder's `H1UserError`."""
771
788
 
@@ -787,10 +804,24 @@ class H1ClientState:
787
804
  """The connection state of one HTTP/1 client connection, `frozen` +
788
805
  subclassable: the transport, the single in-flight slot (hyper `Conn::is_busy`),
789
806
  the error slot (first writer wins), the idle watcher's hand-off, the background
790
- writer's scope, the peer's version."""
807
+ writer's scope, the peer's version; holds the connection's `H1Codec` (one per
808
+ connection, hyper's `Conn`), reset at each exchange's claim."""
791
809
 
792
- def __init__(self, transport: object) -> None: ...
810
+ def __init__(self, codec: H1Codec, transport: object) -> None: ...
811
+ @property
812
+ def codec(self) -> H1Codec: ...
793
813
  def transport_ref(self) -> object | None: ...
814
+ def begin_send(self) -> tuple[object | None, object | None]:
815
+ """The claimed exchange's send-time facts, one step: `(transport, watcher done
816
+ event)`; transport None = dead (the caller raises with `request_unsent`)."""
817
+
818
+ def finish_exchange(self, resp_keep_alive: bool) -> tuple[bool, bool, object | None]:
819
+ """`release_slot`'s verdict, one step: `(reuse, fully_sent, transport to close)` —
820
+ reuse iff keep-alive and the writer finished; else closed here."""
821
+
822
+ def take_watcher_handoff(self) -> tuple[object | None, bytes | None, BaseException | None]:
823
+ """The completed watcher's hand-off, one step: `(handle to join, data, error)`,
824
+ the slot cleared."""
794
825
  @property
795
826
  def closed(self) -> bool: ...
796
827
  @property
@@ -7,8 +7,8 @@ client-side driver over the Rust `H1ClientState`, src/h1/conn.rs) and
7
7
  and the `request` wrapper — so a caller can treat h1 and h2 connections identically.
8
8
 
9
9
  The state — the transport, the single in-flight slot (hyper `Conn::is_busy`), the
10
- error slot, the idle watcher's hand-off, the background writer's scope, the peer's
11
- version — lives in `H1ClientState` under one mutex; every decision is one call
10
+ error slot, the idle watcher's hand-off, the in-flight request writer (`_Writer`), the
11
+ peer's version — lives in `H1ClientState` under one mutex; every decision is one call
12
12
  into it. This file holds only the async machinery (HTTPUNK_RUST_STATE_DESIGN.md §3.3).
13
13
 
14
14
  Cross-reference: hyper `client::conn::http1` (`SendRequest`/`Connection`) +
@@ -37,7 +37,7 @@ from ..exceptions import (
37
37
  fresh_exc,
38
38
  )
39
39
  from ..types import Response, Version
40
- from .connection import H1Framing
40
+ from .connection import BODY_ASYNC, H1Framing
41
41
  from .share import H1ResponseBody, H1Upgraded
42
42
 
43
43
 
@@ -54,6 +54,27 @@ def _version_of(head):
54
54
  return Version.HTTP_10 if head.http10 else Version.HTTP_11
55
55
 
56
56
 
57
+ class _Writer:
58
+ """The in-flight request writer, as the state stores it (`store_writer`): its
59
+ completion event — set by the writer in its `finally`, whatever ended it — and,
60
+ for an async body only, the scope that can cancel a write parked inside the app's
61
+ generator. A write parked on the transport is ended by the transport closing,
62
+ which every teardown does first; the event is then the join."""
63
+
64
+ __slots__ = ("done", "scope")
65
+
66
+ def __init__(self, done, scope=None):
67
+ self.done = done
68
+ self.scope = scope
69
+
70
+ async def finish(self, cancel):
71
+ if self.scope is not None:
72
+ if cancel:
73
+ self.scope.cancel()
74
+ await self.scope.__aexit__(None, None, None)
75
+ await self.done.wait()
76
+
77
+
57
78
  class Connection(H1Framing, H1ClientState):
58
79
  """The client-side h1 driver: writes a request, reads a response, reuses the
59
80
  connection on keep-alive. Mirrors hyper's Client `Dispatcher` over `Conn`.
@@ -90,11 +111,18 @@ class Connection(H1Framing, H1ClientState):
90
111
  """
91
112
 
92
113
  def __new__(cls, transport, *, authority=None, backend=None):
93
- return H1ClientState.__new__(cls, transport)
114
+ # ONE codec per connection (hyper's `Conn`), held by the state: its read buffer
115
+ # persists across exchanges, its per-message state is reset at each claim.
116
+ return H1ClientState.__new__(cls, H1Codec(), transport)
94
117
 
95
118
  def __init__(self, transport, *, authority=None, backend=None):
96
119
  self.backend = _backend.resolve(backend)
97
120
  self.authority = authority
121
+ self._codec = self.codec # the one codec the state holds (hyper's `Conn`)
122
+ self._max_buf_size = self._codec.max_buf_size # the coalescing cap (`_body_plan`)
123
+ # The exchange's writable wait, bound ONCE (the transport's kind is fixed for the
124
+ # connection's life): `backend.writable_wait`.
125
+ self._wait_writable = self.backend.writable_wait(transport)
98
126
  # Wakes the slot waiters when an exchange releases the connection (the waiter
99
127
  # idiom: try, clear, try again, wait).
100
128
  self._idle_evt = self.backend.event()
@@ -167,21 +195,17 @@ class Connection(H1Framing, H1ClientState):
167
195
  # ----- the background writer -----
168
196
 
169
197
  async def _teardown_writer(self, *, cancel):
170
- """Finish with the in-flight body writer: `cancel=True` aborts it (still
171
- running — an early response we didn't wait out), then joins; `cancel=False`
172
- just joins an already-finished writer (instant). Single-owner via the pop in
173
- the state: a `close()` racing the exchange's own teardown for the SAME scope
174
- finds nothing (a second `Scope.__aexit__` would raise)."""
175
- scope = self.take_writer()
176
- if scope is None:
198
+ """Finish with the in-flight request writer (`_Writer`): `cancel=True` — still
199
+ running, an early response we didn't wait out — the transport is closed by now,
200
+ which ends a write parked on it; an async body's scope is cancelled too (its
201
+ write may be parked in the app's generator); then the completion event is
202
+ awaited. `cancel=False` awaits an already-finished writer (instant).
203
+ Single-owner via the pop in the state: a `close()` racing the exchange's own
204
+ teardown finds nothing."""
205
+ writer = self.take_writer()
206
+ if writer is None:
177
207
  return
178
- await self._exit_scope(scope, cancel)
179
-
180
- @staticmethod
181
- async def _exit_scope(scope, cancel):
182
- if cancel:
183
- scope.cancel()
184
- await scope.__aexit__(None, None, None)
208
+ await writer.finish(cancel)
185
209
 
186
210
  # ----- the idle watcher -----
187
211
 
@@ -265,7 +289,9 @@ class Connection(H1Framing, H1ClientState):
265
289
  # `request_unsent` mirrors hyper handing the request back
266
290
  # (`TrySendError { message: Some(req) }`, L247-263): nothing was written,
267
291
  # so the caller may safely retry ANY body, streamed included.
268
- if self.is_dead():
292
+ # One step: dead?, the transport, the watcher's done event (`begin_send`).
293
+ transport, done = self.begin_send()
294
+ if transport is None:
269
295
  raise self._unsent_error()
270
296
  # `require_empty_read`'s buffered-bytes fast path at send time (conn.rs
271
297
  # L463-465): bytes ALREADY DELIVERED when a request is submitted are always
@@ -280,17 +306,12 @@ class Connection(H1Framing, H1ClientState):
280
306
  # sees nothing while it is parked: bytes still ciphertext in the socket reach
281
307
  # the watcher's read instead — hyper's `read_buf` over rustls holds decrypted
282
308
  # bytes only, and its next `poll_read_head` parses the rest as the response too.)
283
- transport = self.transport_ref()
284
- pending = self.backend.receive_nowait(transport, _READ_SIZE) if transport is not None else b""
309
+ pending = self.backend.receive_nowait(transport, _READ_SIZE)
285
310
  if pending:
286
311
  self.poison_unexpected(len(pending)) # records the error AND closes
287
312
  raise self._unsent_error()
288
- # The exchange starts NOW: the watcher is not stopped (its parked read is
289
- # never cancelled); the state redirects its completing read to the exchange,
290
- # whose `_read_head` joins it and takes the bytes as the response's first read.
291
- self.exchange_started()
292
- codec = H1Codec()
293
- content_length, chunked = self._body_framing(body)
313
+ codec = self._codec # started on the next message by the claim (`try_begin_exchange`)
314
+ content_length, chunked, kind, coalesce = self._body_plan(body, self._max_buf_size)
294
315
  # A previous HTTP/1.0 response on this (reused) connection downgrades this
295
316
  # request to 1.0 and re-asserts keep-alive (hyper conn.rs L662-702); the codec
296
317
  # also allow-lists chunked trailers from the request's own `Trailer` header.
@@ -302,32 +323,63 @@ class Connection(H1Framing, H1ClientState):
302
323
  content_length=content_length,
303
324
  chunked=chunked,
304
325
  )
305
- # hyper's `poll_loop` drives reads and writes INDEPENDENTLY each turn
306
- # (dispatch.rs L172-211): a response head can arrive while the request
307
- # body is still being written, and an early response (413/401/redirect)
308
- # does NOT truncate the upload. So we write head+body in a DETACHED
309
- # background task (a per-request scope that outlives this call) and read
310
- # the head concurrently. The writer is NOT cancelled at head-arrival (F11);
311
- # `release_slot` decides its fate when the caller has finished the response.
326
+ # hyper's `poll_loop` turn (dispatch.rs L166-215): `poll_read`, `poll_write`,
327
+ # `poll_flush`, then the ONE task yields for readable OR writable. The same
328
+ # turn here, from this task, by what it waits for:
329
+ #
330
+ # A. nothing sent yet — wait writable | readable. "readable" is the
331
+ # watcher's completing read (it holds the connection's one parked read,
332
+ # never a second reader): bytes or EOF BEFORE the request went out are
333
+ # the idle verdicts (unexpected message / idle close), and the failure
334
+ # carries `request_unsent` — nothing was written. Writable now (the
335
+ # common case): no wait at all.
336
+ # B. writing — the write runs in its own task (`send_all` is the transport's
337
+ # only send and suspends inside; a task is what lets this one keep
338
+ # reading, F11): a head arriving meanwhile is delivered and the upload
339
+ # goes on, `release_slot` judging it. The write's completion needs no
340
+ # action here (`writer_done` is the writer's own), so the wait is the
341
+ # readable one, as in
342
+ # C. written — wait readable: `_read_head`.
343
+ #
344
+ # The writer is DETACHED — no handle, no scope: its completion is the
345
+ # `_Writer.done` event (its `finally`), and a write parked on the transport
346
+ # is ended by the transport closing, which every teardown does first. Only an
347
+ # async body can park elsewhere (the app's generator), and keeps a scope for
348
+ # the cancel — the server's `_send_async_body` split.
349
+ if done is not None and (wait := self._wait_writable(done)) is not None:
350
+ await wait
351
+ if done.is_set(): # the watcher's read completed first: the idle rules judged it
352
+ raise self._unsent_error()
353
+ # The exchange starts NOW: the watcher is not stopped (its parked read is
354
+ # never cancelled); the state redirects its completing read to the exchange,
355
+ # whose `_read_head` joins it and takes the bytes as the response's first read.
356
+ # The `request_unsent` boundary: this spawn is httpunk's analogue of hyper's
357
+ # dispatcher taking the request off the channel (dispatch.rs `poll_msg`). From
358
+ # here on a failure NEVER carries the marker.
359
+ self.exchange_started()
312
360
  write_error = []
313
- scope = self.backend.scope()
314
- await scope.__aenter__()
315
- # The `request_unsent` boundary: this spawn is httpunk's analogue of
316
- # hyper's dispatcher taking the request off the channel (dispatch.rs
317
- # `poll_msg`). From here on a failure NEVER carries the marker.
318
- scope.spawn(self._write_request(codec, head, body, write_error, trailers))
319
- refused = self.store_writer(scope)
361
+ write_done = self.backend.event()
362
+ coro = self._write_request(codec, head, body, write_error, trailers, coalesce, write_done)
363
+ if kind is BODY_ASYNC:
364
+ scope = self.backend.scope()
365
+ await scope.__aenter__()
366
+ scope.spawn(coro)
367
+ writer = _Writer(write_done, scope)
368
+ else:
369
+ self.backend.spawn_detached(coro)
370
+ writer = _Writer(write_done)
371
+ refused = self.store_writer(writer)
320
372
  if refused is not None:
321
373
  # Closed meanwhile (the idle watcher won the race for bytes/EOF that
322
374
  # landed in the send-time window and poisoned/closed the connection):
323
375
  # nobody else will tear the writer down, and the recorded verdict — the
324
376
  # unexpected-bytes poison, or the clean close — is what surfaces.
325
- await self._exit_scope(refused, True)
377
+ await refused.finish(True)
326
378
  err = self.error
327
379
  if err is not None:
328
380
  raise fresh_exc(err) from err
329
381
  raise ConnectionClosedError("connection closed")
330
- resp_head = await self._read_head(codec, write_error)
382
+ resp_head = await self._read_head(codec, write_error, done)
331
383
  # Remember the peer's version so the next request on a reused
332
384
  # connection can fix itself up (hyper conn.rs L295).
333
385
  self.set_peer_http10(resp_head.http10)
@@ -347,14 +399,11 @@ class Connection(H1Framing, H1ClientState):
347
399
  return Response(resp_head.status, resp_head.headers, body, version=_version_of(resp_head))
348
400
  decoder = H1BodyDecoder(resp_head.body_kind, resp_head.content_length or 0)
349
401
  codec.take_body_into(decoder) # body bytes already read alongside the head: moved, in Rust
350
- # The response's own keep-alive contribution; `release_slot` ANDs it with
351
- # "the request body was fully sent". A close-delimited body can never be
352
- # reused (hyper conn.rs L458-489); a request carrying `Connection: close`
353
- # is never reused, whatever the response says (hyper 1.11.1 `encode_head`
354
- # -> `connection_any_close` -> `disable_keep_alive`).
355
- resp_keep_alive = (
356
- resp_head.keep_alive and resp_head.body_kind != "close" and not codec.request_connection_close
357
- )
402
+ # hyper `wants_keep_alive()` once the head is read (the response allows it,
403
+ # its body is not close-delimited, the request carried no `Connection:
404
+ # close`), computed by the codec with the head; `release_slot` ANDs it with
405
+ # "the request body was fully sent".
406
+ resp_keep_alive = resp_head.wants_keep_alive
358
407
  # The response body owns the slot from here; it releases it (and resolves
359
408
  # the writer) when fully read or on aclose. A bodyless response has nothing
360
409
  # to read, so resolve it now (in this async context) instead.
@@ -382,14 +431,15 @@ class Connection(H1Framing, H1ClientState):
382
431
  exc.__cause__ = err
383
432
  return exc
384
433
 
385
- async def _write_request(self, codec, head, body, write_error, trailers=None):
434
+ async def _write_request(self, codec, head, body, write_error, trailers, coalesce, done):
386
435
  # Write the head then the framed body. A write failure (e.g. the server
387
436
  # closed the read side after answering early) must not mask a response
388
437
  # that did arrive: record it so `_read_head` can still deliver the head,
389
438
  # and only surface it if no response is forthcoming. Cancellation
390
- # (BaseException) propagates so the scope can unwind cleanly.
439
+ # (BaseException) propagates so a scope can unwind cleanly. `done` is set
440
+ # whatever ended the write: it is the writer's join (`_Writer`).
391
441
  try:
392
- await self._send_head_and_body(codec, head, body, trailers)
442
+ await self._send_head_and_body(codec, head, body, trailers, self.transport_ref(), coalesce)
393
443
  self.writer_done()
394
444
  except OSError as exc:
395
445
  # A TRANSPORT write failure (broken pipe / reset): the peer may have closed
@@ -406,20 +456,25 @@ class Connection(H1Framing, H1ClientState):
406
456
  # (never cancel it): `_read_head` sees EOF and surfaces this error (F12).
407
457
  write_error.append(exc.with_traceback(None)) # stored past this frame — strip (exceptions.fresh_exc)
408
458
  self._fail(exc)
459
+ finally:
460
+ done.set()
409
461
 
410
- async def _read_head(self, codec, write_error):
462
+ async def _read_head(self, codec, write_error, done):
411
463
  # hyper: conn.rs `can_read_head` (L175) + `read_head` -> role.rs
412
464
  # `Client::parse` (L1013), which loops past 1xx informational responses.
465
+ # `done`: the armed idle watcher's done event (`begin_send`), None if none.
413
466
  data = None
414
- done = self.watcher_done()
415
467
  if done is not None:
416
468
  # An idle watcher is armed: its parked `receive_some` is the connection's
417
469
  # single reader (F55), so the response's FIRST read is its completing read —
418
- # join it and take the hand-off (bytes, `b""` EOF, or a transport error
419
- # re-raised here, exactly as if this task had done the read). Only after
420
- # the join does this task own the transport.
421
- await self._join_watcher()
422
- data, exc = self.take_watcher_result()
470
+ # await it and take the hand-off in one step (`take_watcher_handoff`: the
471
+ # handle, joined at once since its done fired; the bytes, `b""` EOF, or a
472
+ # transport error re-raised here, exactly as if this task had done the
473
+ # read). Only after that does this task own the transport.
474
+ await done.wait()
475
+ handle, data, exc = self.take_watcher_handoff()
476
+ if handle is not None:
477
+ await handle
423
478
  if exc is not None:
424
479
  if write_error: # the writer's failure ended the read: it is the cause
425
480
  raise fresh_exc(write_error[0]) from write_error[0]
@@ -501,9 +556,9 @@ class Connection(H1Framing, H1ClientState):
501
556
  `poll_shutdown`) — the orderly end, awaited after the verdict (the TLS
502
557
  `close_notify`; an interruption there still closes the socket); an upload
503
558
  still in flight is cancelled and the connection dropped — abortive, sync."""
504
- fully_sent = self.writer_finished
505
- reuse = resp_keep_alive and fully_sent
506
- transport = None if reuse else self.close_now() # the verdict, committed synchronously
559
+ # The verdict, ONE step in the state (`finish_exchange`: the writer's flag read
560
+ # and the close under the same lock), committed synchronously.
561
+ reuse, fully_sent, transport = self.finish_exchange(resp_keep_alive)
507
562
  if not fully_sent:
508
563
  self._close(transport)
509
564
  try:
@@ -540,7 +595,7 @@ class Connection(H1Framing, H1ClientState):
540
595
  if handle is not None:
541
596
  await handle
542
597
  if writer is not None:
543
- await self._exit_scope(writer, True)
598
+ await writer.finish(True)
544
599
 
545
600
  def _fail(self, exc):
546
601
  # Sync poison + close, first writer wins. A stripped COPY is stored: the