httpunk 0.4.0__tar.gz → 0.4.2__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 (143) hide show
  1. {httpunk-0.4.0 → httpunk-0.4.2}/Cargo.lock +1 -1
  2. {httpunk-0.4.0 → httpunk-0.4.2}/Cargo.toml +1 -1
  3. {httpunk-0.4.0 → httpunk-0.4.2}/PKG-INFO +8 -2
  4. {httpunk-0.4.0 → httpunk-0.4.2}/README.md +6 -0
  5. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/_backend/tonio.py +22 -12
  6. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/_httpunk.pyi +4 -0
  7. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h1/server.py +68 -54
  8. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h2/client.py +32 -7
  9. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h2/connection.py +56 -41
  10. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h2/server.py +8 -2
  11. {httpunk-0.4.0 → httpunk-0.4.2}/pyproject.toml +1 -1
  12. {httpunk-0.4.0 → httpunk-0.4.2}/src/h2/conn.rs +355 -31
  13. {httpunk-0.4.0 → httpunk-0.4.2}/src/h2/mod.rs +1 -0
  14. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_backend.py +32 -10
  15. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h1_server.py +31 -0
  16. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h2_flow.py +147 -11
  17. httpunk-0.4.2/tests/test_h2_flow_control.py +384 -0
  18. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h2_server.py +13 -6
  19. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_util_pool.py +76 -70
  20. httpunk-0.4.0/tests/test_h2_flow_control.py +0 -174
  21. {httpunk-0.4.0 → httpunk-0.4.2}/LICENSE +0 -0
  22. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/Cargo.toml +0 -0
  23. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/LICENSE +0 -0
  24. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
  25. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/codec/error.rs +0 -0
  26. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/codec/mod.rs +0 -0
  27. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/error.rs +0 -0
  28. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/ext.rs +0 -0
  29. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/data.rs +0 -0
  30. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/go_away.rs +0 -0
  31. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/head.rs +0 -0
  32. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/headers.rs +0 -0
  33. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/mod.rs +0 -0
  34. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/ping.rs +0 -0
  35. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/priority.rs +0 -0
  36. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/reason.rs +0 -0
  37. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/reset.rs +0 -0
  38. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/settings.rs +0 -0
  39. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
  40. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/util.rs +0 -0
  41. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/frame/window_update.rs +0 -0
  42. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
  43. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
  44. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/hpack/header.rs +0 -0
  45. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
  46. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
  47. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/hpack/mod.rs +0 -0
  48. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/hpack/table.rs +0 -0
  49. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/lib.rs +0 -0
  50. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/proto/error.rs +0 -0
  51. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/proto/mod.rs +0 -0
  52. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
  53. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
  54. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
  55. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/Cargo.toml +0 -0
  56. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/LICENSE +0 -0
  57. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
  58. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/body/length.rs +0 -0
  59. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/body/mod.rs +0 -0
  60. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/cfg.rs +0 -0
  61. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/common/date.rs +0 -0
  62. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/common/mod.rs +0 -0
  63. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/error.rs +0 -0
  64. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
  65. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/ext/informational.rs +0 -0
  66. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/ext/mod.rs +0 -0
  67. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/headers.rs +0 -0
  68. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/lib.rs +0 -0
  69. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
  70. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
  71. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
  72. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
  73. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
  74. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
  75. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/proto/mod.rs +0 -0
  76. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper/src/trace.rs +0 -0
  77. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper-util/Cargo.toml +0 -0
  78. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper-util/LICENSE +0 -0
  79. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
  80. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
  81. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
  82. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
  83. {httpunk-0.4.0 → httpunk-0.4.2}/crates/vendor-hyper-util/src/lib.rs +0 -0
  84. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/__init__.py +0 -0
  85. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/_backend/__init__.py +0 -0
  86. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/_backend/asyncio.py +0 -0
  87. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/_common.py +0 -0
  88. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/asyncio.py +0 -0
  89. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/exceptions.py +0 -0
  90. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h1/__init__.py +0 -0
  91. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h1/client.py +0 -0
  92. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h1/connection.py +0 -0
  93. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h1/share.py +0 -0
  94. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h2/__init__.py +0 -0
  95. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h2/share.py +0 -0
  96. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/h2/stream.py +0 -0
  97. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/http.py +0 -0
  98. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/py.typed +0 -0
  99. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/types.py +0 -0
  100. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/util/__init__.py +0 -0
  101. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/util/auto.py +0 -0
  102. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/util/client.py +0 -0
  103. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/util/graceful.py +0 -0
  104. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/util/pool.py +0 -0
  105. {httpunk-0.4.0 → httpunk-0.4.2}/httpunk/util/proxy.py +0 -0
  106. {httpunk-0.4.0 → httpunk-0.4.2}/src/errors.rs +0 -0
  107. {httpunk-0.4.0 → httpunk-0.4.2}/src/h1/codec.rs +0 -0
  108. {httpunk-0.4.0 → httpunk-0.4.2}/src/h1/conn.rs +0 -0
  109. {httpunk-0.4.0 → httpunk-0.4.2}/src/h1/errors.rs +0 -0
  110. {httpunk-0.4.0 → httpunk-0.4.2}/src/h1/mod.rs +0 -0
  111. {httpunk-0.4.0 → httpunk-0.4.2}/src/h2/codec.rs +0 -0
  112. {httpunk-0.4.0 → httpunk-0.4.2}/src/h2/errors.rs +0 -0
  113. {httpunk-0.4.0 → httpunk-0.4.2}/src/h2/reason.rs +0 -0
  114. {httpunk-0.4.0 → httpunk-0.4.2}/src/h2/settings.rs +0 -0
  115. {httpunk-0.4.0 → httpunk-0.4.2}/src/h2/streams.rs +0 -0
  116. {httpunk-0.4.0 → httpunk-0.4.2}/src/http/mod.rs +0 -0
  117. {httpunk-0.4.0 → httpunk-0.4.2}/src/lib.rs +0 -0
  118. {httpunk-0.4.0 → httpunk-0.4.2}/src/proxy.rs +0 -0
  119. {httpunk-0.4.0 → httpunk-0.4.2}/src/py.rs +0 -0
  120. {httpunk-0.4.0 → httpunk-0.4.2}/tests/_client.py +0 -0
  121. {httpunk-0.4.0 → httpunk-0.4.2}/tests/_transport.py +0 -0
  122. {httpunk-0.4.0 → httpunk-0.4.2}/tests/conftest.py +0 -0
  123. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_asyncio_backend.py +0 -0
  124. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_asyncio_client_protocol.py +0 -0
  125. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_asyncio_e2e.py +0 -0
  126. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_asyncio_protocol.py +0 -0
  127. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_error_retention.py +0 -0
  128. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h1_codec.py +0 -0
  129. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h1_conn.py +0 -0
  130. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h1_decode.py +0 -0
  131. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h2_codec.py +0 -0
  132. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h2_conn.py +0 -0
  133. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h2_errors.py +0 -0
  134. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h2_ready.py +0 -0
  135. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h2_settings.py +0 -0
  136. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h2_state.py +0 -0
  137. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_h2_write_batch.py +0 -0
  138. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_headermap.py +0 -0
  139. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_util_auto.py +0 -0
  140. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_util_connect.py +0 -0
  141. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_util_graceful.py +0 -0
  142. {httpunk-0.4.0 → httpunk-0.4.2}/tests/test_util_proxy.py +0 -0
  143. {httpunk-0.4.0 → httpunk-0.4.2}/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.2"
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.2"
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.2
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
 
@@ -867,6 +867,7 @@ H2_FLAG_WAKE: int # bytes were queued: wake the write pump
867
867
  H2_FLAG_SLOT_FREED: int # client: a MAX_CONCURRENT slot freed / the limit changed
868
868
  H2_FLAG_CONN_DONE: int # the connection failed or finished: wake the role waiters
869
869
  H2_FLAG_STOP_ACCEPTING: int # server: the graceful drain completed
870
+ H2_FLAG_CAPACITY: int # the connection-capacity queue moved: ask `next_capacity_wake`
870
871
 
871
872
  # `H2RecvHeadersVerdict.kind`.
872
873
  H2_HEADERS_IGNORED: int
@@ -1060,6 +1061,9 @@ class H2Streams:
1060
1061
  def stream_send_window(self, sid: int) -> int | None: ...
1061
1062
  @property
1062
1063
  def conn_send_window(self) -> int: ...
1064
+ def next_capacity_wake(self) -> object | None:
1065
+ """After `H2_FLAG_CAPACITY`: the stream handle now first in line for connection
1066
+ window (wake its `window_evt`), or None."""
1063
1067
  @property
1064
1068
  def conn_recv_available(self) -> int: ...
1065
1069
  @property
@@ -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]):
@@ -169,16 +169,41 @@ class Connection(H2ConnectionBase):
169
169
  """Send the request body concurrently with (not before) the caller awaiting
170
170
  the response head: h2's `SendStream` (body) and `ResponseFuture` (head) are
171
171
  independent — an early response that resets the request body (413 / redirect
172
- during upload) must still deliver the received response. The write runs in the
173
- connection's write scope so it can outlive `send_request` (full duplex) and is
174
- torn down when the connection closes."""
175
- self._write_scope.spawn(self._write_body(stream, body, trailers))
172
+ during upload) must still deliver the received response. hyper client.rs
173
+ `poll_pipe`: the pipe is polled once inline, and only if that leaves it pending
174
+ is it handed to the executor as a bare task that outlives `send_request` (full
175
+ duplex) and ends on its own — the body sent, or the stream stopped; `close()`
176
+ waits for the last one (`release_writers`), it never cancels them."""
177
+ offset = 0
178
+ if body is None or isinstance(body, bytes):
179
+ offset = self._send_body_inline(stream, body, trailers)
180
+ if offset is None:
181
+ return
182
+ if self.writer_start(): # refused: the stream is stopped already, nothing to write
183
+ self.backend.spawn_without_results(self._write_body(stream, body, trailers, offset))
184
+
185
+ def _send_body_inline(self, stream, body, trailers):
186
+ """The inline first poll of a buffered body (hyper's `Full`: one chunk, EOS
187
+ set): queue what the windows take NOW. Returns the offset a background writer
188
+ resumes from, or None when nothing is left to write — the body (and trailers)
189
+ fully queued, or the stream already stopped, which the response reader sees."""
190
+ try:
191
+ offset = self._try_send_data(stream, body, 0, trailers is None) if body else None
192
+ if offset is None and trailers is not None:
193
+ self._send_trailers(stream, trailers)
194
+ except Exception:
195
+ return None
196
+ return offset
176
197
 
177
- async def _write_body(self, stream, body, trailers=None):
198
+ async def _write_body(self, stream, body, trailers, offset):
178
199
  # The stream may error/reset mid-send: that is surfaced when the response body
179
200
  # is read; a background writer has nowhere to propagate to.
180
- with contextlib.suppress(Exception):
181
- await self._send_body(stream, body, trailers)
201
+ try:
202
+ with contextlib.suppress(Exception):
203
+ await self._send_body(stream, body, trailers, offset)
204
+ finally:
205
+ if self.writer_end():
206
+ self._writers_evt.set()
182
207
 
183
208
 
184
209
  class H2Connection(BaseClientConnection):
@@ -27,6 +27,7 @@ import contextlib
27
27
  from .. import _backend
28
28
  from .._common import PUMP_ABANDONED, PUMP_DONE, aclose_body, aiter_body, event_result
29
29
  from .._httpunk import (
30
+ H2_FLAG_CAPACITY,
30
31
  H2_FLAG_CONN_DONE,
31
32
  H2_FLAG_SLOT_FREED,
32
33
  H2_FLAG_STOP_ACCEPTING,
@@ -70,9 +71,12 @@ class H2ConnectionBase(H2Streams):
70
71
  # pumps read it, `close()` closes it — never reassigned.
71
72
  self.backend = _backend.resolve(backend)
72
73
  self._transport = transport
73
- # A scope for background request-body writers (client full-duplex send, F6) —
74
- # the one group that legitimately gets CANCELLED at teardown.
75
- self._write_scope = self.backend.scope()
74
+ # Set by the last background request-body writer to finish once `close()` is
75
+ # draining (client full-duplex send, F6): hyper's connection task ends only
76
+ # after every `PipeToSendStream` dropped its `conn_drop_ref`. Writers are bare
77
+ # tasks, never cancelled: a stopped stream (peer reset, GOAWAY, the connection
78
+ # failed or closed) is what ends one.
79
+ self._writers_evt = self.backend.event()
76
80
  # Orders socket writes: the write pump and the inline flushes (`_flush`).
77
81
  self._send_lock = self.backend.lock()
78
82
  # Wakes the write pump. Set by the driver after any verdict that appended to
@@ -109,6 +113,12 @@ class H2ConnectionBase(H2Streams):
109
113
  self._on_conn_done()
110
114
  if flags & H2_FLAG_STOP_ACCEPTING:
111
115
  self._on_stop_accepting()
116
+ if flags & H2_FLAG_CAPACITY:
117
+ # The front of the connection-capacity queue moved on: hand the turn
118
+ # to the next sender in line (h2 `assign_connection_capacity`).
119
+ handle = self.next_capacity_wake()
120
+ if handle is not None:
121
+ handle.window_evt.set()
112
122
 
113
123
  @staticmethod
114
124
  def _notify_stopped(handle, stop, notify_body):
@@ -187,7 +197,6 @@ class H2ConnectionBase(H2Streams):
187
197
  # Shared handshake start (h2 client.rs/server.rs `handshake`): the preface
188
198
  # (client) + our initial SETTINGS + the initial WINDOW_UPDATE(0) are queued by
189
199
  # the state and flushed here, BEFORE the pumps start, so nothing can precede them.
190
- await self._write_scope.__aenter__()
191
200
  self.begin()
192
201
  await self._flush()
193
202
  # Bare tasks (`spawn_without_results`), never cancelled: every point they can
@@ -213,20 +222,21 @@ class H2ConnectionBase(H2Streams):
213
222
  async def close(self):
214
223
  # Stop the write pump by SIGNAL, flush-then-exit, and JOIN — never cancel it
215
224
  # (a cancellation landing between its take and its flush would lose control
216
- # frames). Then cancel + join the body writers, drain what their teardown
217
- # enqueued (h2 `codec.shutdown` = flush THEN shutdown, F23), shut the
218
- # transport down — `FramedWrite::shutdown` ends in the IO's `poll_shutdown`
219
- # (`close_notify` over TLS), which is also what ends the read pump's parked
220
- # read — join it, and wake every straggler. On a transport that already died
221
- # the alert cannot go out and the shutdown is the plain close hyper's drop
222
- # would be: wire-identical.
225
+ # frames). Drain what is enqueued (h2 `codec.shutdown` = flush THEN shutdown,
226
+ # F23), shut the transport down — `FramedWrite::shutdown` ends in the IO's
227
+ # `poll_shutdown` (`close_notify` over TLS), which is also what ends the read
228
+ # pump's parked read — join it, and wake every straggler. On a transport that
229
+ # already died the alert cannot go out and the shutdown is the plain close
230
+ # hyper's drop would be: wire-identical. Last, wait for the background
231
+ # request-body writers: every stream is stopped by now, so each one wakes and
232
+ # exits — hyper's connection task likewise ends only after the last
233
+ # `PipeToSendStream` dropped its `conn_drop_ref`; the writers are never
234
+ # cancelled, here or there.
223
235
  self.stop_pump()
224
236
  self._write_evt.set()
225
237
  handle = self.take_pump_handle()
226
238
  if handle is not None:
227
239
  await handle
228
- self._write_scope.cancel()
229
- await self._write_scope.__aexit__(None, None, None)
230
240
  with contextlib.suppress(Exception): # best-effort: the connection is closing regardless
231
241
  await self._flush()
232
242
  await self.backend.shutdown_transport(self._transport)
@@ -234,6 +244,8 @@ class H2ConnectionBase(H2Streams):
234
244
  if handle is not None:
235
245
  await handle
236
246
  self._fail(None) # no-op when the pump already failed everyone (F44)
247
+ if not self.release_writers():
248
+ await self._writers_evt.wait()
237
249
 
238
250
  # ----- the write pump (h2 `poll_complete` draining its frame queue) -----
239
251
 
@@ -382,18 +394,19 @@ class H2ConnectionBase(H2Streams):
382
394
 
383
395
  # ----- sending (h2 share.rs SendStream over send.rs, hyper PipeToSendStream) -----
384
396
 
385
- async def _send_body(self, st, body, trailers=None):
397
+ async def _send_body(self, st, body, trailers=None, offset=0):
386
398
  """Stream a body, marking END_STREAM on the final DATA frame (or on the
387
399
  trailing HEADERS), then close the send half. A bodyless message never reaches
388
- here — its END_STREAM rode the HEADERS frame."""
400
+ here — its END_STREAM rode the HEADERS frame. `offset`: where a bytes body
401
+ resumes after the client's inline attempt queued its head (`_send_body_inline`)."""
389
402
  if isinstance(body, bytes):
390
403
  # hyper's `Full` body: one poll yields the whole chunk with `is_end_stream()`
391
404
  # set — one DATA carrying END_STREAM (or the trailers carry it, F45).
392
405
  if trailers is None:
393
- await self._send_data(st, body, end_stream=True)
406
+ await self._send_data(st, body, end_stream=True, offset=offset)
394
407
  return
395
408
  if body:
396
- await self._send_data(st, body, end_stream=False)
409
+ await self._send_data(st, body, end_stream=False, offset=offset)
397
410
  self._send_trailers(st, trailers)
398
411
  return
399
412
  if body is not None and not isinstance(body, bytearray) and hasattr(body, "__aiter__"):
@@ -459,16 +472,16 @@ class H2ConnectionBase(H2Streams):
459
472
  await aclose_body(body)
460
473
  done.set()
461
474
 
462
- async def _send_data(self, st, data, end_stream):
463
- """Queue `data` as DATA frame(s), each reserved against min(connection window,
464
- stream window, send-buffer room, peer max_frame_size) — h2 send.rs `send_data`
465
- behind `poll_capacity`. `send_data` is one locked step (reserve + encode +
466
- append — and, with END_STREAM on the last frame, the send half's close, as h2's;
467
- on the server the request's recv half goes with it, hyper dropping the
468
- `RecvStream`: `handle` is the reader to notify when its unread body was reset);
469
- "no window now" is awaited on `window_evt` with the fixed waiter idiom: try,
470
- clear, try again, wait (design §4)."""
471
- offset = 0
475
+ def _try_send_data(self, st, data, offset, end_stream):
476
+ """Queue DATA frames from `data[offset:]` while the windows take them, each
477
+ reserved against min(connection window, stream window, send-buffer room, peer
478
+ max_frame_size) — h2 send.rs `send_data` behind `poll_capacity`. `send_data` is
479
+ one locked step (reserve + encode + append — and, with END_STREAM on the last
480
+ frame, the send half's close, as h2's; on the server the request's recv half
481
+ goes with it, hyper dropping the `RecvStream`: `handle` is the reader to notify
482
+ when its unread body was reset). Returns None once the last frame is queued, or
483
+ the offset to resume from on "no window now" (`sent == 0`, not `done`). Raises
484
+ the stopped error."""
472
485
  while True:
473
486
  v = self.send_data(st.id, data, offset, end_stream)
474
487
  if v.stopped is not None:
@@ -478,23 +491,25 @@ class H2ConnectionBase(H2Streams):
478
491
  if v.done:
479
492
  if v.handle is not None:
480
493
  self._notify_reset(v.handle, v.reader_stop)
481
- return
494
+ return None
495
+ if not v.sent:
496
+ return offset
482
497
  offset += v.sent
483
- if v.sent:
484
- continue
498
+
499
+ async def _send_data(self, st, data, end_stream, offset=0):
500
+ """`_try_send_data` until the body is queued, awaiting "no window now" on
501
+ `window_evt` with the fixed waiter idiom: try, clear, try again, wait
502
+ (design §4)."""
503
+ while True:
504
+ offset = self._try_send_data(st, data, offset, end_stream)
505
+ if offset is None:
506
+ return
485
507
  st.window_evt.clear()
486
- v = self.send_data(st.id, data, offset, end_stream) # re-check after the clear
487
- if v.stopped is not None:
488
- raise self._stopped_error(st, v.stopped)
489
- if v.flags:
490
- self._after(v.flags)
491
- if v.done:
492
- if v.handle is not None:
493
- self._notify_reset(v.handle, v.reader_stop)
508
+ resumed = self._try_send_data(st, data, offset, end_stream) # re-check after the clear
509
+ if resumed is None:
494
510
  return
495
- offset += v.sent
496
- if not v.sent:
497
- await st.window_evt.wait()
511
+ offset = resumed
512
+ await st.window_evt.wait()
498
513
 
499
514
  def _send_trailers(self, st, trailers):
500
515
  """The trailing HEADERS (END_STREAM) closes the send half in the same step
@@ -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 = [