httpunk 0.4.0__tar.gz → 0.4.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 (142) hide show
  1. {httpunk-0.4.0 → httpunk-0.4.1}/Cargo.lock +1 -1
  2. {httpunk-0.4.0 → httpunk-0.4.1}/Cargo.toml +1 -1
  3. {httpunk-0.4.0 → httpunk-0.4.1}/PKG-INFO +8 -2
  4. {httpunk-0.4.0 → httpunk-0.4.1}/README.md +6 -0
  5. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/_backend/tonio.py +22 -12
  6. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h1/server.py +68 -54
  7. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h2/server.py +8 -2
  8. {httpunk-0.4.0 → httpunk-0.4.1}/pyproject.toml +1 -1
  9. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_backend.py +32 -10
  10. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h1_server.py +25 -0
  11. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h2_server.py +3 -2
  12. {httpunk-0.4.0 → httpunk-0.4.1}/LICENSE +0 -0
  13. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/Cargo.toml +0 -0
  14. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/LICENSE +0 -0
  15. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
  16. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/codec/error.rs +0 -0
  17. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/codec/mod.rs +0 -0
  18. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/error.rs +0 -0
  19. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/ext.rs +0 -0
  20. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/data.rs +0 -0
  21. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/go_away.rs +0 -0
  22. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/head.rs +0 -0
  23. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/headers.rs +0 -0
  24. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/mod.rs +0 -0
  25. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/ping.rs +0 -0
  26. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/priority.rs +0 -0
  27. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/reason.rs +0 -0
  28. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/reset.rs +0 -0
  29. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/settings.rs +0 -0
  30. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
  31. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/util.rs +0 -0
  32. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/frame/window_update.rs +0 -0
  33. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
  34. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
  35. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/hpack/header.rs +0 -0
  36. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
  37. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
  38. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/hpack/mod.rs +0 -0
  39. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/hpack/table.rs +0 -0
  40. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/lib.rs +0 -0
  41. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/proto/error.rs +0 -0
  42. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/proto/mod.rs +0 -0
  43. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
  44. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
  45. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
  46. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/Cargo.toml +0 -0
  47. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/LICENSE +0 -0
  48. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
  49. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/body/length.rs +0 -0
  50. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/body/mod.rs +0 -0
  51. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/cfg.rs +0 -0
  52. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/common/date.rs +0 -0
  53. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/common/mod.rs +0 -0
  54. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/error.rs +0 -0
  55. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
  56. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/ext/informational.rs +0 -0
  57. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/ext/mod.rs +0 -0
  58. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/headers.rs +0 -0
  59. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/lib.rs +0 -0
  60. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
  61. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
  62. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
  63. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
  64. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
  65. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
  66. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/proto/mod.rs +0 -0
  67. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper/src/trace.rs +0 -0
  68. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper-util/Cargo.toml +0 -0
  69. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper-util/LICENSE +0 -0
  70. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
  71. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
  72. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
  73. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
  74. {httpunk-0.4.0 → httpunk-0.4.1}/crates/vendor-hyper-util/src/lib.rs +0 -0
  75. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/__init__.py +0 -0
  76. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/_backend/__init__.py +0 -0
  77. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/_backend/asyncio.py +0 -0
  78. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/_common.py +0 -0
  79. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/_httpunk.pyi +0 -0
  80. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/asyncio.py +0 -0
  81. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/exceptions.py +0 -0
  82. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h1/__init__.py +0 -0
  83. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h1/client.py +0 -0
  84. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h1/connection.py +0 -0
  85. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h1/share.py +0 -0
  86. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h2/__init__.py +0 -0
  87. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h2/client.py +0 -0
  88. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h2/connection.py +0 -0
  89. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h2/share.py +0 -0
  90. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/h2/stream.py +0 -0
  91. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/http.py +0 -0
  92. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/py.typed +0 -0
  93. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/types.py +0 -0
  94. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/util/__init__.py +0 -0
  95. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/util/auto.py +0 -0
  96. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/util/client.py +0 -0
  97. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/util/graceful.py +0 -0
  98. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/util/pool.py +0 -0
  99. {httpunk-0.4.0 → httpunk-0.4.1}/httpunk/util/proxy.py +0 -0
  100. {httpunk-0.4.0 → httpunk-0.4.1}/src/errors.rs +0 -0
  101. {httpunk-0.4.0 → httpunk-0.4.1}/src/h1/codec.rs +0 -0
  102. {httpunk-0.4.0 → httpunk-0.4.1}/src/h1/conn.rs +0 -0
  103. {httpunk-0.4.0 → httpunk-0.4.1}/src/h1/errors.rs +0 -0
  104. {httpunk-0.4.0 → httpunk-0.4.1}/src/h1/mod.rs +0 -0
  105. {httpunk-0.4.0 → httpunk-0.4.1}/src/h2/codec.rs +0 -0
  106. {httpunk-0.4.0 → httpunk-0.4.1}/src/h2/conn.rs +0 -0
  107. {httpunk-0.4.0 → httpunk-0.4.1}/src/h2/errors.rs +0 -0
  108. {httpunk-0.4.0 → httpunk-0.4.1}/src/h2/mod.rs +0 -0
  109. {httpunk-0.4.0 → httpunk-0.4.1}/src/h2/reason.rs +0 -0
  110. {httpunk-0.4.0 → httpunk-0.4.1}/src/h2/settings.rs +0 -0
  111. {httpunk-0.4.0 → httpunk-0.4.1}/src/h2/streams.rs +0 -0
  112. {httpunk-0.4.0 → httpunk-0.4.1}/src/http/mod.rs +0 -0
  113. {httpunk-0.4.0 → httpunk-0.4.1}/src/lib.rs +0 -0
  114. {httpunk-0.4.0 → httpunk-0.4.1}/src/proxy.rs +0 -0
  115. {httpunk-0.4.0 → httpunk-0.4.1}/src/py.rs +0 -0
  116. {httpunk-0.4.0 → httpunk-0.4.1}/tests/_client.py +0 -0
  117. {httpunk-0.4.0 → httpunk-0.4.1}/tests/_transport.py +0 -0
  118. {httpunk-0.4.0 → httpunk-0.4.1}/tests/conftest.py +0 -0
  119. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_asyncio_backend.py +0 -0
  120. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_asyncio_client_protocol.py +0 -0
  121. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_asyncio_e2e.py +0 -0
  122. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_asyncio_protocol.py +0 -0
  123. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_error_retention.py +0 -0
  124. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h1_codec.py +0 -0
  125. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h1_conn.py +0 -0
  126. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h1_decode.py +0 -0
  127. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h2_codec.py +0 -0
  128. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h2_conn.py +0 -0
  129. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h2_errors.py +0 -0
  130. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h2_flow.py +0 -0
  131. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h2_flow_control.py +0 -0
  132. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h2_ready.py +0 -0
  133. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h2_settings.py +0 -0
  134. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h2_state.py +0 -0
  135. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_h2_write_batch.py +0 -0
  136. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_headermap.py +0 -0
  137. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_util_auto.py +0 -0
  138. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_util_connect.py +0 -0
  139. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_util_graceful.py +0 -0
  140. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_util_pool.py +0 -0
  141. {httpunk-0.4.0 → httpunk-0.4.1}/tests/test_util_proxy.py +0 -0
  142. {httpunk-0.4.0 → httpunk-0.4.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.4.0"
121
+ version = "0.4.1"
122
122
  dependencies = [
123
123
  "bytes",
124
124
  "h2",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "httpunk"
3
- version = "0.4.0"
3
+ version = "0.4.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.4.0
3
+ Version: 0.4.1
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: BSD License
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Classifier: Programming Language :: Python
21
21
  Classifier: Programming Language :: Rust
22
22
  Classifier: Topic :: Internet :: WWW/HTTP
23
- Requires-Dist: tonio~=0.9.15 ; python_full_version >= '3.14' and extra == 'tonio'
23
+ Requires-Dist: tonio~=0.9.17 ; python_full_version >= '3.14' and extra == 'tonio'
24
24
  Provides-Extra: tonio
25
25
  License-File: LICENSE
26
26
  Summary: The Rust HTTP library for Python
@@ -275,6 +275,12 @@ await stream.send_data(b"data: 2\n\n", end_stream=True)
275
275
  # stream.send_trailers({...}) ends the body with trailers; stream.send_reset() aborts it
276
276
  ```
277
277
 
278
+ On HTTP/1, `send_response` arms the mid-message detection described above (the body may
279
+ park between chunks); `detect_eof=False` skips that, so no read is parked for the response
280
+ and a client that closes is noticed one write later, at the peer's RST, instead — unless
281
+ `peer_closed()` asks, which still arms on demand. This is httpunk's own knob (hyper's read is
282
+ always on); HTTP/2 accepts it and ignores it, a reset arrives through the connection anyway.
283
+
278
284
  `end_stream=True` on `send_response` is a bodyless response. Framing follows hyper: a
279
285
  `content-length` you set is honoured, otherwise the body is chunked (HTTP/1.1) or
280
286
  close-delimited (HTTP/1.0); on HEAD/204/304 body chunks are discarded, as hyper never polls
@@ -239,6 +239,12 @@ await stream.send_data(b"data: 2\n\n", end_stream=True)
239
239
  # stream.send_trailers({...}) ends the body with trailers; stream.send_reset() aborts it
240
240
  ```
241
241
 
242
+ On HTTP/1, `send_response` arms the mid-message detection described above (the body may
243
+ park between chunks); `detect_eof=False` skips that, so no read is parked for the response
244
+ and a client that closes is noticed one write later, at the peer's RST, instead — unless
245
+ `peer_closed()` asks, which still arms on demand. This is httpunk's own knob (hyper's read is
246
+ always on); HTTP/2 accepts it and ignores it, a reset arrives through the connection anyway.
247
+
242
248
  `end_stream=True` on `send_response` is a bodyless response. Framing follows hyper: a
243
249
  `content-length` you set is honoured, otherwise the body is chunked (HTTP/1.1) or
244
250
  close-delimited (HTTP/1.0); on HEAD/204/304 body chunks are discarded, as hyper never polls
@@ -145,10 +145,17 @@ class TonioBackend:
145
145
  and the plain-socket reader is one coroutine over bound methods resolved here —
146
146
  the cost of tonio's own `receive_some`, plus one arm on the (cold) timer path.
147
147
 
148
- Bytes first, hyper's order (`poll_read_head` polls the timer only after `parse`
149
- returned Pending): a wake that finds bytes returns them, whatever the timer did.
150
-
151
- - **Plain socket**: the socket's own arm (`_io_arm_r(timeout)`) puts the readiness
148
+ Bytes first, hyper's order (`poll_read_head`: `parse` -> `poll_read`, and the timer
149
+ is polled only once that returned Pending): every pass tries the syscall before it
150
+ looks at the clock, and a wake that finds bytes returns them, whatever the timer did.
151
+
152
+ - **Plain socket**: the syscall comes first — the readiness bits are stale-set after
153
+ any successful read (nothing but a failed syscall clears them), exactly as tokio's
154
+ readiness word is when hyper's `poll_read` runs, so both pay one `EAGAIN` probe
155
+ per head; only on `EAGAIN` (`clear_r`, as tonio's own `recv`) is the remaining time
156
+ computed. Past the deadline that is the answer at once: hyper's Pending followed
157
+ by a ready timer poll (`new_header_timeout`) — no zero-length timer is ever
158
+ armed. Otherwise the socket's own arm (`_io_arm_r(timeout)`) puts the readiness
152
159
  wait and the timer on ONE suspension (a `Waiter` with a timer). Both wakes resume
153
160
  with `None`; the readiness word tells them apart: a readiness wake set bits
154
161
  before waking (`set_readiness` precedes `wake`), a timer wake set none — so a
@@ -174,18 +181,21 @@ class TonioBackend:
174
181
 
175
182
  async def read(max_bytes, deadline):
176
183
  while True:
177
- micros = round((deadline - _now()) * 1_000_000)
178
- waiter = arm(0 if micros < 0 else micros)
179
- if waiter is not None:
180
- await waiter
181
- if arm() is not None: # no readiness bits: the timer woke us
182
- return None
183
184
  try:
184
- return recv(max_bytes) # bytes first, whatever the timer did
185
+ return recv(max_bytes) # bytes first: hyper's `poll_read` before its timer poll
185
186
  except InterruptedError:
186
187
  continue
187
188
  except BlockingIOError:
188
- clear() # a spurious readiness wake: re-arm for the remaining time
189
+ clear() # EAGAIN: the bits were stale (or a spurious wake) — now the timer
190
+ micros = round((deadline - _now()) * 1_000_000)
191
+ if micros <= 0:
192
+ return None # nothing readable and the deadline passed: hyper's timer poll is ready
193
+ waiter = arm(micros)
194
+ if waiter is None:
195
+ continue # bits landed between the EAGAIN and the arm: read now
196
+ await waiter
197
+ if arm() is not None: # no readiness bits: the timer woke us
198
+ return None
189
199
 
190
200
  return read
191
201
 
@@ -238,9 +238,9 @@ class ServerRequest:
238
238
  self._conn.close_window(self._seq)
239
239
  self._peer_closed_evt.set()
240
240
 
241
- async def respond(
241
+ def respond(
242
242
  self, status: int, *, headers: HeadersInput = None, body: Body = None, trailers: HeadersInput = None
243
- ) -> None:
243
+ ) -> Awaitable[None]:
244
244
  """Send the whole response: head + body (+ trailers). `body` is None, `bytes`,
245
245
  or a (sync/async) iterable of `bytes`. hyper: `Server::encode` + the dispatcher
246
246
  polling the response `Body`. The pull convenience over `send_response`: same
@@ -258,9 +258,11 @@ class ServerRequest:
258
258
  cancelled at that await (hyper drops the body future) and has unwound — its
259
259
  cleanup ran — before this raises. Cancelling the task awaiting `respond()`
260
260
  cancels the producer the same way."""
261
- await self._conn._send_response(self, status, headers, body, trailers)
261
+ return self._conn._send_response(self, status, headers, body, trailers)
262
262
 
263
- async def send_response(self, status: int, *, headers: HeadersInput = None, end_stream: bool = False) -> SendStream:
263
+ def send_response(
264
+ self, status: int, *, headers: HeadersInput = None, end_stream: bool = False, detect_eof: bool = True
265
+ ) -> Awaitable[SendStream]:
264
266
  """Push-style response: send the head now and return a `SendStream` to write
265
267
  the body with (`send_data` / `send_trailers` / `send_reset`). `end_stream=True`
266
268
  = a bodyless response (hyper: a `Body` that `is_end_stream()` at encode time —
@@ -269,8 +271,18 @@ class ServerRequest:
269
271
  `content-length`, which the encoder honours (hyper `set_length`'s
270
272
  `existing_con_len`). hyper's h1 has no push API; this is the h1 twin of h2's
271
273
  `SendResponse::send_response -> SendStream`, over the same codec calls
272
- `respond()` makes, so the wire behaviour is hyper's either way."""
273
- return await self._conn._send_response_head(self, status, headers, end_stream=end_stream)
274
+ `respond()` makes, so the wire behaviour is hyper's either way.
275
+
276
+ `detect_eof` (httpunk's own — hyper has no per-response knob, its mid-message read
277
+ is always on; the connection-level twin is `half_close`): a push body may park
278
+ between chunks, so by default this head arms the mid-message watcher (hyper
279
+ `mid_message_detect_eof`) so a client FIN mid-response is seen as it lands. `False`
280
+ skips that ask: nothing is parked for this response, saving the watcher task and
281
+ its hand-off at the next head read. A client that closes then is noticed one write
282
+ later — at the kernel's RST, not at the FIN — unless something asks: `peer_closed()`
283
+ still arms on demand, exactly as it does before the head. Meant for a caller whose
284
+ disconnect detection IS that explicit ask (an ASGI `receive()`)."""
285
+ return self._conn._send_response_head(self, status, headers, end_stream=end_stream, detect_eof=detect_eof)
274
286
 
275
287
  def detach(self) -> bytes:
276
288
  """Take over the raw connection for a protocol upgrade (WebSocket, or a custom protocol):
@@ -323,7 +335,11 @@ class SendStream:
323
335
  Fails with `ConnectionClosedError` once the client closed its side mid-request
324
336
  (hyper: the connection errored with `IncompleteMessage`, nothing more is written)."""
325
337
  self._check_open()
326
- await self._conn._check_peer_open(self._req)
338
+ conn, req = self._conn, self._req
339
+ if conn.peer_closed_flag(req._seq):
340
+ # hyper: once `mid_message_detect_eof` saw EOF the connection is errored and
341
+ # nothing more is written for this exchange. Poison + close, `IncompleteMessage`.
342
+ await conn._fail_response(req, H1IncompleteMessageError(_PEER_CLOSED_MSG))
327
343
  codec = self._codec
328
344
  try:
329
345
  if codec.body_is_eof():
@@ -336,28 +352,35 @@ class SendStream:
336
352
  # `serialize_end` on a body short of its Content-Length: hyper `end_body` ->
337
353
  # `User::BodyWriteAborted` + `Writing::Closed`. Same poison as a failed write.
338
354
  self._done = True
339
- await self._conn._fail_response(self._req, exc)
355
+ await conn._fail_response(req, exc)
340
356
  if end_stream:
341
357
  self._done = True
342
358
  if buf:
343
- await self._write(buf)
344
- if end_stream:
345
- await self._conn._finish_response(self._req)
359
+ try:
360
+ await conn.write(buf)
361
+ except BaseException as exc:
362
+ self._done = True
363
+ await conn._fail_response(req, exc)
364
+ if end_stream and (end := conn._finish_response(req)) is not None:
365
+ await end
346
366
 
347
367
  async def send_trailers(self, trailers: HeadersInput) -> None:
348
368
  """Finish a chunked body with a trailer block (hyper `Server::encode` allow-lists
349
369
  the fields named in the response's `Trailer` header; others are dropped, and a
350
370
  non-chunked body gets a bare terminator — `Encoder::encode_trailers`)."""
351
371
  self._check_open()
352
- await self._conn._check_peer_open(self._req)
372
+ conn, req = self._conn, self._req
373
+ if conn.peer_closed_flag(req._seq): # as `send_data`
374
+ await conn._fail_response(req, H1IncompleteMessageError(_PEER_CLOSED_MSG))
353
375
  hdrs = trailers if isinstance(trailers, HeaderMap) else HeaderMap(trailers)
354
376
  self._done = True
355
377
  try:
356
378
  buf = self._codec.serialize_trailers(hdrs) # bare terminator unless the request said `TE: trailers`
379
+ await conn.write(buf)
357
380
  except BaseException as exc:
358
- await self._conn._fail_response(self._req, exc)
359
- await self._write(buf)
360
- await self._conn._finish_response(self._req)
381
+ await conn._fail_response(req, exc)
382
+ if (end := conn._finish_response(req)) is not None:
383
+ await end
361
384
 
362
385
  async def send_reset(self, reason: int | None = None) -> None:
363
386
  """Abort the response. HTTP/1 has no per-stream reset frame, so this is what
@@ -373,13 +396,6 @@ class SendStream:
373
396
  if self._done:
374
397
  raise RuntimeError("response body already complete")
375
398
 
376
- async def _write(self, data):
377
- try:
378
- await self._conn.write(data)
379
- except BaseException as exc:
380
- self._done = True
381
- await self._conn._fail_response(self._req, exc)
382
-
383
399
 
384
400
  class ServerConnection(H1Framing, H1ServerState):
385
401
  """The server-side h1 driver: reads a request, hands back a `ServerRequest`,
@@ -602,12 +618,6 @@ class ServerConnection(H1Framing, H1ServerState):
602
618
  return transport.receive_some(n)
603
619
  return self._read_bounded(n, deadline)
604
620
 
605
- async def _check_peer_open(self, req):
606
- # hyper: once `mid_message_detect_eof` saw EOF the connection is errored and nothing
607
- # more is written for this exchange. Poison + close, surface `IncompleteMessage`.
608
- if self.peer_closed_flag(req._seq):
609
- await self._fail_response(req, H1IncompleteMessageError(_PEER_CLOSED_MSG))
610
-
611
621
  async def _send_async_body(self, req, body, trailers):
612
622
  """Stream an ASYNC response body and fail fast on a client FIN. hyper polls the
613
623
  connection (so `mid_message_detect_eof`) while the body's next chunk is pending,
@@ -699,8 +709,16 @@ class ServerConnection(H1Framing, H1ServerState):
699
709
  if code == H1_NEXT_NONE:
700
710
  return None
701
711
  codec = self._codec
712
+ # hyper's `header_read_timeout` (slowloris defence, http1.rs L249; default 30s, `None`
713
+ # disables it): ONE deadline per head, armed at the first poll for it — i.e. from the
714
+ # moment the connection is idle between requests, so it covers the keep-alive wait
715
+ # plus the head's bytes — never reset by partial bytes (conn.rs `poll_read_head`:
716
+ # `h1_header_read_timeout_running` stays set until a head parses). The bound rides
717
+ # each read (`_read_bounded`), so no task is spawned per head and no parked read is
718
+ # ever cancelled.
719
+ deadline = None if self._header_read_timeout is None else self._now() + self._header_read_timeout
702
720
  try:
703
- accepted = await self._read_request_head(code, obj)
721
+ accepted = await self._read_head_frames(code, obj, deadline)
704
722
  except H1ParseError:
705
723
  # hyper conn.rs `on_parse_error`: an HTTP/2 preface is `Parse::VersionH2` and
706
724
  # the connection errors (dropped: the abortive end); any other malformed head
@@ -722,6 +740,11 @@ class ServerConnection(H1Framing, H1ServerState):
722
740
  # the clean EOF below, so it surfaces as a clean end-of-iteration (F47).
723
741
  self._close(self.fail_read())
724
742
  return None
743
+ if accepted is _DEADLINE:
744
+ # Expiry is `Error::new_header_timeout` out of the dispatcher: closed, no
745
+ # response, the abortive end.
746
+ self._close(self.fail_read())
747
+ return None
725
748
  if accepted is None: # clean EOF between requests (or a shutdown released the idle read)
726
749
  self.stop_serving()
727
750
  return None
@@ -756,22 +779,6 @@ class ServerConnection(H1Framing, H1ServerState):
756
779
  pass
757
780
  return dec.is_complete
758
781
 
759
- async def _read_request_head(self, code, obj):
760
- # hyper's `header_read_timeout` (slowloris defence, http1.rs L249; default 30s, `None`
761
- # disables it): ONE deadline per head, armed at the first poll for it — i.e. from the
762
- # moment the connection is idle between requests, so it covers the keep-alive wait
763
- # plus the head's bytes — never reset by partial bytes (conn.rs `poll_read_head`:
764
- # `h1_header_read_timeout_running` stays set until a head parses). Expiry is
765
- # `Error::new_header_timeout` out of the dispatcher: closed, no response, the
766
- # abortive end. The bound rides each read (`_read_bounded`), so no task is spawned
767
- # per head and no parked read is ever cancelled.
768
- deadline = None if self._header_read_timeout is None else self._now() + self._header_read_timeout
769
- accepted = await self._read_head_frames(code, obj, deadline)
770
- if accepted is _DEADLINE:
771
- self._close(self.fail_read())
772
- return None
773
- return accepted
774
-
775
782
  async def _read_head_frames(self, code, obj, deadline):
776
783
  """Read + parse the next head from `begin_read`'s verdict: `READ_PARSE` = bytes of
777
784
  it are already buffered (pipelined), parse before touching the transport;
@@ -888,17 +895,20 @@ class ServerConnection(H1Framing, H1ServerState):
888
895
  await self._send_head_and_body(self._codec, head, body, trailers)
889
896
  except BaseException as exc:
890
897
  await self._fail_response(req, exc)
891
- await self._finish_response(req)
898
+ if (end := self._finish_response(req)) is not None:
899
+ await end
892
900
 
893
- async def _send_response_head(self, req, status, headers, *, end_stream):
901
+ async def _send_response_head(self, req, status, headers, *, end_stream, detect_eof):
894
902
  """The push path (`ServerRequest.send_response`): write the head now, return
895
903
  the `SendStream`. `end_stream` maps onto hyper's two encode inputs the same way a
896
904
  pull body does: True = no body (`_body_framing(None)`), False = a body of unknown
897
905
  length (streamed -> chunked, or length if the headers carry `content-length`).
898
906
  The peer-closed check is the claim's (`_claim_response`), which precedes this."""
899
907
  content_length, chunked = self._body_framing(None) if end_stream else (None, True)
900
- # A push producer may park between chunks: make the FIN observable (`want`).
901
- head = await self._respond_head(req, status, headers, content_length, chunked, want=not end_stream)
908
+ # A push producer may park between chunks: make the FIN observable (`want`) —
909
+ # unless the caller declined (`detect_eof=False`) or there is no body to park in.
910
+ want = detect_eof and not end_stream
911
+ head = await self._respond_head(req, status, headers, content_length, chunked, want=want)
902
912
  stream = SendStream(self, req, self._codec)
903
913
  if end_stream:
904
914
  stream._done = True
@@ -908,8 +918,8 @@ class ServerConnection(H1Framing, H1ServerState):
908
918
  await self.write(head)
909
919
  except BaseException as exc:
910
920
  await self._fail_response(req, exc)
911
- if end_stream:
912
- await self._finish_response(req)
921
+ if end_stream and (end := self._finish_response(req)) is not None:
922
+ await end
913
923
  return stream
914
924
 
915
925
  async def _respond_head(self, req, status, headers, content_length, chunked, *, want):
@@ -990,12 +1000,15 @@ class ServerConnection(H1Framing, H1ServerState):
990
1000
  self._close(self.fail_response(req._seq))
991
1001
  req._peer_closed_evt.set()
992
1002
 
993
- async def _finish_response(self, req):
1003
+ def _finish_response(self, req):
994
1004
  """Completion, shared by both paths: the response is fully on the wire — apply
995
1005
  the head-time reuse decision (hyper `try_keep_alive` after `Writing::KeepAlive`),
996
1006
  or hand off the tunnel for a protocol switch. One step in the state; the
997
1007
  `peer_closed()` waiters wake after it. A non-reusable connection completes:
998
- `try_keep_alive` closes it and the future `poll_shutdown`s — the orderly end."""
1008
+ `try_keep_alive` closes it and the future `poll_shutdown`s — the orderly end.
1009
+ Sync: the reusable case (the hot path) suspends nowhere; the close is handed back
1010
+ as the awaitable to finish with (None otherwise), so no wrapper coroutine rides
1011
+ every response."""
999
1012
  switch, close, transport, leftover = self.finish_response(req._seq)
1000
1013
  req._peer_closed_evt.set()
1001
1014
  if switch:
@@ -1003,7 +1016,8 @@ class ServerConnection(H1Framing, H1ServerState):
1003
1016
  # the start of the tunnel) is the caller's; the state detached.
1004
1017
  req.upgraded = H1Upgraded(transport, leftover)
1005
1018
  elif close:
1006
- await self._shutdown(transport)
1019
+ return self._shutdown(transport)
1020
+ return None
1007
1021
 
1008
1022
 
1009
1023
  class H1Server(BaseServer[ServerRequest]):
@@ -110,11 +110,17 @@ class ServerRequest:
110
110
  cleanup ran — before this raises."""
111
111
  return self._conn._send_response(self._stream, status, headers, body, trailers)
112
112
 
113
- async def send_response(self, status: int, *, headers: HeadersInput = None, end_stream: bool = False) -> SendStream:
113
+ async def send_response(
114
+ self, status: int, *, headers: HeadersInput = None, end_stream: bool = False, detect_eof: bool = True
115
+ ) -> SendStream:
114
116
  """Push-style response: send HEADERS now and return a `SendStream` to write the
115
117
  body with (`send_data` / `send_trailers` / `send_reset`). `end_stream=True` ends
116
118
  the stream on the HEADERS frame (a bodyless response). h2:
117
- `SendResponse::send_response(response, end_of_stream) -> SendStream`."""
119
+ `SendResponse::send_response(response, end_of_stream) -> SendStream`.
120
+
121
+ `detect_eof` is accepted for API symmetry with the HTTP/1 twin and has no effect
122
+ here: a client abandoning the request is a RST_STREAM the connection's pump
123
+ delivers regardless (`reset_received`), there is no per-response read to arm."""
118
124
  await self._conn._send_response_head(self._stream, status, headers, end_stream=end_stream)
119
125
  stream = SendStream(self._conn, self._stream)
120
126
  if end_stream:
@@ -37,7 +37,7 @@ requires-python = ">=3.10"
37
37
  dependencies = []
38
38
 
39
39
  [project.optional-dependencies]
40
- tonio = ["tonio~=0.9.15; python_version >= '3.14'"]
40
+ tonio = ["tonio~=0.9.17; python_version >= '3.14'"]
41
41
 
42
42
  [dependency-groups]
43
43
  build = [
@@ -215,34 +215,56 @@ async def _bounded(sock, timeout=1.0):
215
215
 
216
216
 
217
217
  @pytest.mark.tonio
218
- async def test_receive_bounded_readable_at_once_never_parks():
219
- sock = _FakeArmSocket(arms=[False], recvs=[b"head"])
218
+ async def test_receive_bounded_readable_at_once_never_arms():
219
+ # The syscall comes first (hyper `poll_read` before its timer poll): bytes there = done.
220
+ sock = _FakeArmSocket(arms=[], recvs=[b"head"])
220
221
  assert await _bounded(sock) == b"head"
221
- assert len(sock.calls) == 1 # bits were set: no wait
222
+ assert sock.calls == [] and sock.cleared == 0 # no arm, no clock, no clear
222
223
 
223
224
 
224
225
  @pytest.mark.tonio
225
226
  async def test_receive_bounded_timer_wake_is_told_by_the_readiness_word():
226
- # park -> wake -> the readiness question hands out a waiter: no bits, so the timer fired.
227
- sock = _FakeArmSocket(arms=[True, True], recvs=[])
227
+ # EAGAIN (the probe: stale bits, as tokio's readiness) -> clear -> arm with the remaining
228
+ # time -> park -> wake -> the readiness question hands out a waiter: no bits, the timer fired.
229
+ sock = _FakeArmSocket(arms=[True, True], recvs=[BlockingIOError])
228
230
  assert await _bounded(sock, 0.5) is None
229
231
  assert 499_000 < sock.calls[0] <= 500_000 # the arm carried the timer (micros; the clock ran a little)
230
232
  assert sock.calls[1] is None # the question carries none
231
- assert sock.cleared == 0 # no syscall on the timer path
233
+ assert sock.cleared == 1 # the one probe; no syscall after the timer wake
232
234
 
233
235
 
234
236
  @pytest.mark.tonio
235
237
  async def test_receive_bounded_readiness_wake_reads_bytes_first():
236
- sock = _FakeArmSocket(arms=[True, False], recvs=[b"head"])
238
+ # EAGAIN -> clear -> arm -> park -> wake -> readable? yes -> the bytes.
239
+ sock = _FakeArmSocket(arms=[True, False], recvs=[BlockingIOError, b"head"])
237
240
  assert await _bounded(sock) == b"head"
241
+ assert sock.cleared == 1
242
+
243
+
244
+ @pytest.mark.tonio
245
+ async def test_receive_bounded_bits_landing_before_the_arm_read_at_once():
246
+ # EAGAIN -> clear -> arm finds the bits already set (data landed in between) -> read now.
247
+ sock = _FakeArmSocket(arms=[False], recvs=[BlockingIOError, b"head"])
248
+ assert await _bounded(sock) == b"head"
249
+ assert len(sock.calls) == 1 and sock.cleared == 1
238
250
 
239
251
 
240
252
  @pytest.mark.tonio
241
253
  async def test_receive_bounded_spurious_wake_rearms_with_the_remaining_time():
242
- # park -> wake -> readable? yes -> EAGAIN (a spurious wake) -> clear -> re-arm, then bytes.
243
- sock = _FakeArmSocket(arms=[True, False, True, False], recvs=[BlockingIOError, b"head"])
254
+ # ... -> park -> wake -> readable? yes -> EAGAIN (a spurious wake) -> clear -> re-arm, then bytes.
255
+ sock = _FakeArmSocket(arms=[True, False, True, False], recvs=[BlockingIOError, BlockingIOError, b"head"])
244
256
  assert await _bounded(sock, 0.5) == b"head"
245
- assert sock.cleared == 1
257
+ assert sock.cleared == 2
246
258
  first, again = sock.calls[0], sock.calls[2]
247
259
  assert 499_000 < first <= 500_000
248
260
  assert 0 < again <= first # the deadline is absolute: never reset to the full timeout
261
+
262
+
263
+ @pytest.mark.tonio
264
+ async def test_receive_bounded_past_deadline_expires_without_arming():
265
+ # Nothing readable and the deadline already passed (a spurious wake landing after it, a
266
+ # partial head whose next read starts late): hyper's Pending + a ready timer poll ->
267
+ # `None` at once. No zero-length timer is ever armed.
268
+ sock = _FakeArmSocket(arms=[], recvs=[BlockingIOError])
269
+ assert await _bounded(sock, -0.001) is None
270
+ assert sock.calls == [] and sock.cleared == 1
@@ -1990,6 +1990,31 @@ async def test_server_header_read_timeout_bounds_the_watcher_hand_off():
1990
1990
  await conn.close() # joins the watcher (its read ended by the close)
1991
1991
 
1992
1992
 
1993
+ @pytest.mark.tonio
1994
+ async def test_server_push_response_detect_eof_off_parks_nothing_until_asked():
1995
+ """`send_response(detect_eof=False)` (httpunk's own knob): the head arms no
1996
+ mid-message watcher — no read parked, no task — and the body still goes out; a
1997
+ later `peer_closed()` arms on demand, exactly as it does before the head. The
1998
+ default keeps today's arm (`test_server_header_read_timeout_bounds_the_watcher_hand_off`)."""
1999
+ stub = _SilentStub(b"GET / HTTP/1.1\r\nhost: x\r\n\r\n")
2000
+ conn = ServerConnection(stub)
2001
+ await conn.start()
2002
+ req = await conn.next_request()
2003
+ stream = await req.send_response(200, headers={"content-length": "4"}, detect_eof=False)
2004
+ assert not conn.has_watcher and not stub.parked.is_set() # nothing parked by the head
2005
+ await stream.send_data(b"ti")
2006
+ assert not conn.has_watcher and not stub.parked.is_set() # nor mid-body
2007
+ seen = []
2008
+ async with scope() as s:
2009
+ s.spawn(_watch(req, seen)) # the explicit ask: arms now
2010
+ await stub.parked.wait()
2011
+ assert conn.has_watcher
2012
+ await stream.send_data(b"ck", end_stream=True) # the exchange completes: `False`
2013
+ assert seen == [False]
2014
+ assert stub.sent.endswith(b"tick")
2015
+ await conn.close() # joins the watcher (its read ended by the close)
2016
+
2017
+
1993
2018
  @pytest.mark.asyncio
1994
2019
  async def test_immediate_body_coalesces_with_the_head_up_to_the_codecs_buffer_cap():
1995
2020
  """hyper `WriteBuf` Flatten up to `max_buf_size`: an immediate body no larger than the
@@ -489,14 +489,15 @@ async def test_server_response_date_header_option():
489
489
  @pytest.mark.tonio
490
490
  async def test_server_push_response_streams_data_frames():
491
491
  """`send_response` + `send_data(..., end_stream=True)`: HEADERS (with Date) then DATA
492
- frames, END_STREAM on the last; the connection keeps multiplexing afterwards."""
492
+ frames, END_STREAM on the last; the connection keeps multiplexing afterwards.
493
+ `detect_eof` (the h1 twin's knob) is accepted and has no effect here."""
493
494
  listener, host, port = await _listener()
494
495
 
495
496
  async def serve():
496
497
  transport = await listener.accept()
497
498
  async with H2Server(transport) as server:
498
499
  async for req in server:
499
- stream = await req.send_response(200, headers={"x-path": req.path})
500
+ stream = await req.send_response(200, headers={"x-path": req.path}, detect_eof=req.path == "/2")
500
501
  await stream.send_data(b"a" * 10)
501
502
  await stream.send_data(b"b" * 5, end_stream=True)
502
503
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes