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.
- {httpunk-0.4.3 → httpunk-0.4.5}/Cargo.lock +1 -1
- {httpunk-0.4.3 → httpunk-0.4.5}/Cargo.toml +1 -1
- {httpunk-0.4.3 → httpunk-0.4.5}/PKG-INFO +1 -1
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/_backend/asyncio.py +74 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/_backend/tonio.py +71 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/_httpunk.pyi +41 -10
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h1/client.py +120 -65
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h1/connection.py +39 -18
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h1/server.py +115 -81
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h1/share.py +8 -9
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/auto.py +55 -47
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h1/codec.rs +24 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h1/conn.rs +135 -17
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/_transport.py +3 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h1_codec.py +48 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h1_conn.py +118 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h1_server.py +56 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_auto.py +19 -3
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_connect.py +5 -1
- {httpunk-0.4.3 → httpunk-0.4.5}/LICENSE +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/README.md +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/Cargo.toml +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/LICENSE +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/codec/error.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/codec/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/error.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/ext.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/data.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/go_away.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/head.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/headers.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/ping.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/priority.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/reason.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/reset.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/settings.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/util.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/frame/window_update.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/header.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/hpack/table.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/lib.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/proto/error.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/proto/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/Cargo.toml +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/LICENSE +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/body/length.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/body/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/cfg.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/common/date.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/common/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/error.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/ext/informational.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/ext/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/headers.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/lib.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/proto/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper/src/trace.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/Cargo.toml +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/LICENSE +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/crates/vendor-hyper-util/src/lib.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/__init__.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/_backend/__init__.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/_common.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/asyncio.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/exceptions.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h1/__init__.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/__init__.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/client.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/connection.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/server.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/share.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/h2/stream.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/http.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/py.typed +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/types.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/__init__.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/client.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/graceful.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/pool.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/httpunk/util/proxy.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/pyproject.toml +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/errors.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h1/errors.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h1/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/codec.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/conn.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/errors.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/reason.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/settings.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/h2/streams.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/http/mod.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/lib.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/proxy.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/src/py.rs +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/_client.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/conftest.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_asyncio_backend.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_asyncio_client_protocol.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_asyncio_e2e.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_asyncio_protocol.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_backend.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_error_retention.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h1_decode.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_codec.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_conn.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_errors.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_flow.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_flow_control.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_ready.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_server.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_settings.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_state.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_h2_write_batch.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_headermap.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_graceful.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_pool.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_proxy.py +0 -0
- {httpunk-0.4.3 → httpunk-0.4.5}/tests/test_util_tls.py +0 -0
|
@@ -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
|
-
"""
|
|
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
|
-
"""
|
|
718
|
-
`(head, seq, its body decoder — fed
|
|
719
|
-
|
|
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 /
|
|
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)`:
|
|
768
|
-
head
|
|
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
|
|
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
|
-
|
|
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
|
|
171
|
-
running
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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`
|
|
306
|
-
#
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
#
|
|
310
|
-
#
|
|
311
|
-
#
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
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
|
-
#
|
|
351
|
-
#
|
|
352
|
-
#
|
|
353
|
-
#
|
|
354
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
419
|
-
#
|
|
420
|
-
#
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
transport =
|
|
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
|
|
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
|