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