httpunk 0.2.0__tar.gz → 0.2.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.
- {httpunk-0.2.0 → httpunk-0.2.2}/Cargo.lock +1 -1
- {httpunk-0.2.0 → httpunk-0.2.2}/Cargo.toml +1 -1
- {httpunk-0.2.0 → httpunk-0.2.2}/PKG-INFO +1 -1
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/_backend/asyncio.py +16 -5
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/exceptions.py +39 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h1/client.py +28 -14
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/client.py +19 -11
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/connection.py +79 -21
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/server.py +14 -2
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/share.py +5 -1
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/streams.py +40 -3
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/pool.py +4 -1
- httpunk-0.2.2/tests/test_error_retention.py +183 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h1_server.py +105 -63
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_server.py +175 -138
- {httpunk-0.2.0 → httpunk-0.2.2}/LICENSE +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/README.md +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/Cargo.toml +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/LICENSE +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/codec/error.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/codec/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/ext.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/data.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/go_away.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/head.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/headers.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/ping.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/priority.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/reason.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/reset.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/settings.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/util.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/window_update.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/header.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/table.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/lib.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/proto/error.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/proto/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/Cargo.toml +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/LICENSE +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/body/length.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/body/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/cfg.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/common/date.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/common/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/error.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/informational.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/headers.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/lib.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/trace.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/Cargo.toml +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/LICENSE +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/src/lib.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/__init__.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/_backend/__init__.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/_backend/tonio.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/_common.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/_httpunk.pyi +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/asyncio.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h1/__init__.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h1/connection.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h1/server.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h1/share.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/__init__.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/settings.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/stream.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/http.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/py.typed +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/types.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/__init__.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/auto.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/client.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/graceful.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/proxy.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/pyproject.toml +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/src/errors.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/src/h1/codec.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/src/h1/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/src/h2/codec.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/src/h2/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/src/h2/streams.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/src/http/mod.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/src/lib.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/src/proxy.rs +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/_client.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/conftest.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_asyncio_backend.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_asyncio_client_protocol.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_asyncio_e2e.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_asyncio_protocol.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_backend.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h1_codec.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h1_conn.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h1_decode.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_codec.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_conn.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_errors.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_flow.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_flow_control.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_ready.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_settings.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_state.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_headermap.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_auto.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_connect.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_graceful.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_pool.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_proxy.py +0 -0
- {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_tls.py +0 -0
|
@@ -20,6 +20,8 @@ import errno as _errno
|
|
|
20
20
|
import ssl as _ssl
|
|
21
21
|
import time as _time
|
|
22
22
|
|
|
23
|
+
from ..exceptions import fresh_exc as _fresh_exc
|
|
24
|
+
|
|
23
25
|
|
|
24
26
|
_READ_HIGH_WATER = 2**16 # 64 KiB — pause reading past this (matches StreamReader's default limit)
|
|
25
27
|
|
|
@@ -65,7 +67,12 @@ class _AsyncioStream(asyncio.Protocol):
|
|
|
65
67
|
return True
|
|
66
68
|
|
|
67
69
|
def connection_lost(self, exc):
|
|
68
|
-
|
|
70
|
+
# Store the error PRISTINE (no traceback) and only ever raise copies of
|
|
71
|
+
# it (below): this object outlives the failure, and a stored exception
|
|
72
|
+
# instance re-raised through the caller's stack would accumulate those
|
|
73
|
+
# frames onto its `__traceback__` — a refcount-invisible cycle pinning
|
|
74
|
+
# everything the frames reference until a gen-2 GC (exceptions.fresh_exc).
|
|
75
|
+
self._error = exc.with_traceback(None) if exc is not None else None
|
|
69
76
|
self._eof = True
|
|
70
77
|
self._conn_lost = True # writes to a dead socket must now raise (F32)
|
|
71
78
|
self._wake_reader()
|
|
@@ -74,7 +81,7 @@ class _AsyncioStream(asyncio.Protocol):
|
|
|
74
81
|
if exc is None:
|
|
75
82
|
self._drain_waiter.set_result(None)
|
|
76
83
|
else:
|
|
77
|
-
self._drain_waiter.set_exception(exc)
|
|
84
|
+
self._drain_waiter.set_exception(_fresh_exc(exc))
|
|
78
85
|
|
|
79
86
|
def pause_writing(self):
|
|
80
87
|
self._writing_paused = True
|
|
@@ -94,7 +101,9 @@ class _AsyncioStream(asyncio.Protocol):
|
|
|
94
101
|
return self._take(max_bytes)
|
|
95
102
|
if self._eof:
|
|
96
103
|
if self._error is not None:
|
|
97
|
-
raise
|
|
104
|
+
# A fresh copy per raise — never the stored instance (see
|
|
105
|
+
# connection_lost / exceptions.fresh_exc).
|
|
106
|
+
raise _fresh_exc(self._error) from self._error
|
|
98
107
|
return b""
|
|
99
108
|
if self._read_waiter is not None:
|
|
100
109
|
# Single-reader contract: a second concurrent receive_some would overwrite
|
|
@@ -110,7 +119,7 @@ class _AsyncioStream(asyncio.Protocol):
|
|
|
110
119
|
if self._buffer:
|
|
111
120
|
return self._take(max_bytes)
|
|
112
121
|
if self._error is not None:
|
|
113
|
-
raise self._error
|
|
122
|
+
raise _fresh_exc(self._error) from self._error
|
|
114
123
|
return b""
|
|
115
124
|
|
|
116
125
|
async def send_all(self, data):
|
|
@@ -119,7 +128,9 @@ class _AsyncioStream(asyncio.Protocol):
|
|
|
119
128
|
# EPIPE/ECONNRESET — drivers detect a dead peer via that failure. Surface the
|
|
120
129
|
# real error, or EPIPE if the close carried none (a clean peer FIN).
|
|
121
130
|
if self._conn_lost:
|
|
122
|
-
|
|
131
|
+
if self._error is not None:
|
|
132
|
+
raise _fresh_exc(self._error) from self._error
|
|
133
|
+
raise BrokenPipeError(_errno.EPIPE, "connection lost")
|
|
123
134
|
self._transport.write(data)
|
|
124
135
|
if self._writing_paused: # transport buffer over high-water — wait for resume (drain)
|
|
125
136
|
if self._drain_waiter is None:
|
|
@@ -30,6 +30,8 @@ may have begun.
|
|
|
30
30
|
|
|
31
31
|
from __future__ import annotations
|
|
32
32
|
|
|
33
|
+
import copy as _copy
|
|
34
|
+
|
|
33
35
|
from ._httpunk import (
|
|
34
36
|
ConnectionClosedError as ConnectionClosedError,
|
|
35
37
|
H2Error as H2Error,
|
|
@@ -49,6 +51,34 @@ def _reason(code):
|
|
|
49
51
|
return code # unknown/experimental error code — keep the raw int
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
def fresh_exc(exc: BaseException) -> BaseException:
|
|
55
|
+
"""A fresh, traceback-free copy of `exc`, for storing or re-raising a saved
|
|
56
|
+
connection/stream error.
|
|
57
|
+
|
|
58
|
+
Runtime-forced divergence from hyper: hyper shares a connection's fatal error
|
|
59
|
+
by cloning an `Arc<Error>`; a Python exception instance instead accumulates
|
|
60
|
+
every propagation's frames onto its `__traceback__`, and a traceback's frames
|
|
61
|
+
keep their locals alive — so a caught exception stored on a long-lived object
|
|
62
|
+
(conn, stream, backend transport) and re-raised as the same shared instance
|
|
63
|
+
pins the entire frame graph of every propagation in a reference cycle only
|
|
64
|
+
cyclic GC can free (observed as stalled-until-gen-2-GC connections downstream).
|
|
65
|
+
The rule throughout httpunk: **store copies, raise copies** — store
|
|
66
|
+
`fresh_exc(exc)` (or strip a consumed-once hand-off in place), and re-raise
|
|
67
|
+
stored errors as `raise fresh_exc(err) from err`, so no stored instance ever
|
|
68
|
+
carries frames.
|
|
69
|
+
|
|
70
|
+
Copies via `copy.copy`: `BaseException.__reduce__` rebuilds from type + args
|
|
71
|
+
and restores `__dict__` extras (e.g. `request_unsent`); `GoAwayError` /
|
|
72
|
+
`StreamResetError` define `__reduce__` because their args don't roundtrip
|
|
73
|
+
through their constructors. The copy carries no `__traceback__` / `__cause__` /
|
|
74
|
+
`__context__`. An uncopyable exception is returned as-is — degraded (shared
|
|
75
|
+
instance) but functional."""
|
|
76
|
+
try:
|
|
77
|
+
return _copy.copy(exc)
|
|
78
|
+
except Exception:
|
|
79
|
+
return exc
|
|
80
|
+
|
|
81
|
+
|
|
52
82
|
class GoAwayError(H2Error):
|
|
53
83
|
"""The peer sent GOAWAY. Streams with id > `last_stream_id` were not
|
|
54
84
|
processed and are safe to retry on a new connection."""
|
|
@@ -63,6 +93,11 @@ class GoAwayError(H2Error):
|
|
|
63
93
|
self.debug_data = debug_data
|
|
64
94
|
super().__init__(f"GOAWAY(last_stream_id={last_stream_id}, error_code={self.error_code!r})")
|
|
65
95
|
|
|
96
|
+
def __reduce__(self):
|
|
97
|
+
# `args` holds the formatted message, not the constructor params — rebuild
|
|
98
|
+
# from the real params so `copy.copy`/pickle (and `fresh_exc`) work.
|
|
99
|
+
return (type(self), (self.last_stream_id, int(self.error_code), self.debug_data), self.__dict__)
|
|
100
|
+
|
|
66
101
|
|
|
67
102
|
class StreamResetError(H2Error):
|
|
68
103
|
"""The peer sent RST_STREAM for this stream."""
|
|
@@ -74,3 +109,7 @@ class StreamResetError(H2Error):
|
|
|
74
109
|
self.stream_id = stream_id
|
|
75
110
|
self.error_code = _reason(error_code)
|
|
76
111
|
super().__init__(f"RST_STREAM(stream_id={stream_id}, error_code={self.error_code!r})")
|
|
112
|
+
|
|
113
|
+
def __reduce__(self):
|
|
114
|
+
# See GoAwayError.__reduce__.
|
|
115
|
+
return (type(self), (self.stream_id, int(self.error_code)), self.__dict__)
|
|
@@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
18
18
|
|
|
19
19
|
from .._common import BaseClientConnection
|
|
20
20
|
from .._httpunk import H1BodyDecoder, H1Codec
|
|
21
|
-
from ..exceptions import ConnectionClosedError
|
|
21
|
+
from ..exceptions import ConnectionClosedError, fresh_exc
|
|
22
22
|
from ..http import HeaderMap
|
|
23
23
|
from ..types import Response
|
|
24
24
|
from .connection import H1ConnectionBase
|
|
@@ -226,7 +226,11 @@ class Connection(H1ConnectionBase):
|
|
|
226
226
|
data = await transport.receive_some(65536)
|
|
227
227
|
except Exception as exc:
|
|
228
228
|
if self._exchange_active:
|
|
229
|
-
|
|
229
|
+
# Consumed-once hand-off (nulled at the `_read_head` take, so raising
|
|
230
|
+
# the instance there is safe) — but strip the watcher frames now: while
|
|
231
|
+
# parked here it would otherwise pin this conn/transport in a cycle
|
|
232
|
+
# (exceptions.fresh_exc). Surfaced by _read_head's first read.
|
|
233
|
+
self._watcher_error = exc.with_traceback(None)
|
|
230
234
|
elif not self._closed:
|
|
231
235
|
self._fail(exc)
|
|
232
236
|
return
|
|
@@ -248,7 +252,7 @@ class Connection(H1ConnectionBase):
|
|
|
248
252
|
await self._acquire()
|
|
249
253
|
self._slot.release()
|
|
250
254
|
if self.error is not None:
|
|
251
|
-
raise self.error
|
|
255
|
+
raise fresh_exc(self.error) from self.error # a copy per raise (exceptions.fresh_exc)
|
|
252
256
|
if self._closed:
|
|
253
257
|
raise ConnectionClosedError("connection closed")
|
|
254
258
|
|
|
@@ -271,9 +275,11 @@ class Connection(H1ConnectionBase):
|
|
|
271
275
|
if self._closed or self.error is not None:
|
|
272
276
|
self._busy = False
|
|
273
277
|
self._slot.release()
|
|
274
|
-
|
|
278
|
+
# The flag rides the raised copy, not the stored error: it holds for
|
|
279
|
+
# THIS give-back, while a stored flag would leak onto unrelated raises.
|
|
280
|
+
exc = fresh_exc(self.error) if self.error is not None else ConnectionClosedError("connection closed")
|
|
275
281
|
exc.request_unsent = True
|
|
276
|
-
raise exc
|
|
282
|
+
raise exc from self.error
|
|
277
283
|
# `require_empty_read`'s buffered-bytes fast path at send time (conn.rs
|
|
278
284
|
# L463-465; regression test client_read_bytes_before_writing_request):
|
|
279
285
|
# a server may not send anything before the client's next request, and
|
|
@@ -291,8 +297,9 @@ class Connection(H1ConnectionBase):
|
|
|
291
297
|
self.poison_unexpected(len(pending)) # records the error AND closes
|
|
292
298
|
self._busy = False
|
|
293
299
|
self._slot.release()
|
|
294
|
-
self.error
|
|
295
|
-
|
|
300
|
+
exc = fresh_exc(self.error)
|
|
301
|
+
exc.request_unsent = True
|
|
302
|
+
raise exc from self.error
|
|
296
303
|
# The exchange starts NOW: the watcher is not stopped (its parked read
|
|
297
304
|
# is never cancelled — see `_watch_idle`); this sync flag redirects its
|
|
298
305
|
# completing read from the idle rules to the exchange, whose `_read_head`
|
|
@@ -438,7 +445,7 @@ class Connection(H1ConnectionBase):
|
|
|
438
445
|
# for `_read_head`, so DEFER — record it and surface it only if no head
|
|
439
446
|
# arrives (EOF). Do NOT signal `body_failed`, or we'd race away that buffered
|
|
440
447
|
# response (F11).
|
|
441
|
-
write_error.append(exc)
|
|
448
|
+
write_error.append(exc.with_traceback(None)) # stored past this frame — strip (exceptions.fresh_exc)
|
|
442
449
|
except Exception as exc:
|
|
443
450
|
# A BODY-ITERABLE / framing error (the caller's body generator raised, or a
|
|
444
451
|
# length mismatch): the request can't complete and no valid response is
|
|
@@ -447,7 +454,7 @@ class Connection(H1ConnectionBase):
|
|
|
447
454
|
# on `receive_some` until the server times out (F12). The transport is closed
|
|
448
455
|
# by `send_request`'s outer `_fail` once the writer is joined — closing it
|
|
449
456
|
# here (inside the writer's scope) wedges that join.
|
|
450
|
-
write_error.append(exc)
|
|
457
|
+
write_error.append(exc.with_traceback(None)) # stored past this frame — strip (exceptions.fresh_exc)
|
|
451
458
|
body_failed.set()
|
|
452
459
|
|
|
453
460
|
async def _read_head(self, codec, write_error=None):
|
|
@@ -477,15 +484,19 @@ class Connection(H1ConnectionBase):
|
|
|
477
484
|
# dispatches are legitimate). Surface the recorded verdict —
|
|
478
485
|
# the unexpected-bytes poison (hyper `new_unexpected_message`)
|
|
479
486
|
# or the clean close — not a generic error that would mask it.
|
|
480
|
-
|
|
487
|
+
if self.error is not None:
|
|
488
|
+
raise fresh_exc(self.error) from self.error
|
|
489
|
+
raise ConnectionClosedError("connection closed")
|
|
481
490
|
while True:
|
|
482
491
|
if data is None:
|
|
483
492
|
data = await self.transport.receive_some(65536)
|
|
484
493
|
if not data:
|
|
485
494
|
# EOF before a full head. If the body write also failed (server
|
|
486
|
-
# closed both directions), surface that as the cause.
|
|
495
|
+
# closed both directions), surface that as the cause. A copy — the
|
|
496
|
+
# instance stays in `write_error`, whose frames would join the
|
|
497
|
+
# raise's traceback (exceptions.fresh_exc).
|
|
487
498
|
if write_error:
|
|
488
|
-
raise write_error[0]
|
|
499
|
+
raise fresh_exc(write_error[0]) from write_error[0]
|
|
489
500
|
raise ConnectionClosedError("connection closed before the response head")
|
|
490
501
|
buffered += len(data)
|
|
491
502
|
head = codec.receive_head(data)
|
|
@@ -509,7 +520,7 @@ class Connection(H1ConnectionBase):
|
|
|
509
520
|
# NOT fire this (F11 — an early response may be buffered), so the normal path
|
|
510
521
|
# always lets `_read_head` win the race.
|
|
511
522
|
await body_failed.wait()
|
|
512
|
-
raise write_error[0]
|
|
523
|
+
raise fresh_exc(write_error[0]) from write_error[0]
|
|
513
524
|
|
|
514
525
|
def poison_unexpected(self, nbytes):
|
|
515
526
|
"""The server sent `nbytes` unsolicited bytes outside a response — an
|
|
@@ -577,7 +588,10 @@ class Connection(H1ConnectionBase):
|
|
|
577
588
|
# inside an exchange (slot held, watcher joined at send_request entry)
|
|
578
589
|
# or from the watcher itself.
|
|
579
590
|
if self.error is None and not isinstance(exc, ConnectionClosedError):
|
|
580
|
-
|
|
591
|
+
# A stripped COPY: the caught instance keeps propagating to the caller
|
|
592
|
+
# and would accumulate every frame above onto a stored traceback
|
|
593
|
+
# (exceptions.fresh_exc).
|
|
594
|
+
self.error = fresh_exc(exc)
|
|
581
595
|
self._closed = True
|
|
582
596
|
self._close_transport()
|
|
583
597
|
|
|
@@ -23,7 +23,7 @@ from collections.abc import Awaitable
|
|
|
23
23
|
from typing import TYPE_CHECKING, Any
|
|
24
24
|
|
|
25
25
|
from .._common import BaseClientConnection
|
|
26
|
-
from ..exceptions import ConnectionClosedError, H2ProtocolError, H2Reason
|
|
26
|
+
from ..exceptions import ConnectionClosedError, H2ProtocolError, H2Reason, fresh_exc
|
|
27
27
|
from ..types import Response
|
|
28
28
|
from .connection import PREFACE, H2ConnectionBase
|
|
29
29
|
from .settings import LocalSettings, Settings
|
|
@@ -119,12 +119,16 @@ class ClientStreamManager(StreamManager):
|
|
|
119
119
|
# 9113 §8.2.2) — same early-validation rationale as the trailers check
|
|
120
120
|
# in `send_request` (#925): a caller error, the connection stays usable.
|
|
121
121
|
self.check_send_headers(headers)
|
|
122
|
+
# All the stored-error raises below raise a COPY per consumer — many streams
|
|
123
|
+
# surface the one stored `_conn.error`/`_goaway`, and a shared raised
|
|
124
|
+
# instance would accumulate every caller's frames (exceptions.fresh_exc).
|
|
122
125
|
if self._goaway is not None:
|
|
123
|
-
raise self._goaway
|
|
126
|
+
raise fresh_exc(self._goaway) from self._goaway
|
|
124
127
|
await self._acquire_stream_slot() # blocks on the limit; increments the count
|
|
125
128
|
if self._conn.error is not None or self._goaway is not None:
|
|
126
129
|
self._release_count()
|
|
127
|
-
|
|
130
|
+
src = self._conn.error or self._goaway
|
|
131
|
+
raise fresh_exc(src) from src
|
|
128
132
|
async with self._new_stream_lock:
|
|
129
133
|
stream_id = self._next_id
|
|
130
134
|
if stream_id > _MAX_STREAM_ID:
|
|
@@ -152,8 +156,9 @@ class ClientStreamManager(StreamManager):
|
|
|
152
156
|
# missed it. Re-check and abort our own stream (idempotent) so a caller
|
|
153
157
|
# doesn't wait forever on a response head that will never arrive.
|
|
154
158
|
if self._conn.error is not None or self._goaway is not None:
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
src = self._conn.error or self._goaway
|
|
160
|
+
self._abort_stream(st, src)
|
|
161
|
+
raise fresh_exc(src) from src
|
|
157
162
|
return st
|
|
158
163
|
|
|
159
164
|
# ===== MAX_CONCURRENT_STREAMS (h2: proto/streams/counts.rs) =====
|
|
@@ -186,9 +191,9 @@ class ClientStreamManager(StreamManager):
|
|
|
186
191
|
# to free a slot, which may never happen (F20). `_on_go_away`/`_on_fail`
|
|
187
192
|
# set `_slot_evt` to wake us for this re-check.
|
|
188
193
|
if self._goaway is not None:
|
|
189
|
-
raise self._goaway
|
|
194
|
+
raise fresh_exc(self._goaway) from self._goaway
|
|
190
195
|
if self._conn.error is not None:
|
|
191
|
-
raise self._conn.error
|
|
196
|
+
raise fresh_exc(self._conn.error) from self._conn.error
|
|
192
197
|
await self._slot_evt.wait()
|
|
193
198
|
|
|
194
199
|
def _release_count(self):
|
|
@@ -214,9 +219,9 @@ class ClientStreamManager(StreamManager):
|
|
|
214
219
|
# existing conn_error (streams.rs L879). Also keeps this consistent with
|
|
215
220
|
# `open_stream`, which checks `_goaway` first (F20).
|
|
216
221
|
if self._goaway is not None:
|
|
217
|
-
raise self._goaway
|
|
222
|
+
raise fresh_exc(self._goaway) from self._goaway
|
|
218
223
|
if self._conn.error is not None:
|
|
219
|
-
raise self._conn.error
|
|
224
|
+
raise fresh_exc(self._conn.error) from self._conn.error
|
|
220
225
|
# Non-reserving: just observe capacity (a racy read is fine — we don't
|
|
221
226
|
# increment; a concurrent open re-applies the gate under the lock).
|
|
222
227
|
if self._can_open():
|
|
@@ -310,7 +315,7 @@ class Connection(H2ConnectionBase):
|
|
|
310
315
|
await self._begin(PREFACE, settings)
|
|
311
316
|
await self._ready_evt.wait()
|
|
312
317
|
if self.error is not None:
|
|
313
|
-
raise self.error
|
|
318
|
+
raise fresh_exc(self.error) from self.error # a copy per raise (exceptions.fresh_exc)
|
|
314
319
|
|
|
315
320
|
def _signal_ready(self):
|
|
316
321
|
self._ready_evt.set()
|
|
@@ -439,7 +444,10 @@ class H2Connection(BaseClientConnection):
|
|
|
439
444
|
# bug is a scheduling race where `send_request` re-checks only after the
|
|
440
445
|
# connection has failed, which a quiet loopback never reproduces.)
|
|
441
446
|
if stream.status is None: # woken by a reset/failure, not a real response head
|
|
442
|
-
|
|
447
|
+
src = stream.error or self._conn.error
|
|
448
|
+
if src is not None:
|
|
449
|
+
raise fresh_exc(src) from src
|
|
450
|
+
raise ConnectionClosedError("connection closed before response")
|
|
443
451
|
return Response(stream.status, stream.headers, H2ResponseBody(stream, self._conn.streams))
|
|
444
452
|
|
|
445
453
|
def _resolve(self, target):
|
|
@@ -34,7 +34,7 @@ from .._httpunk import (
|
|
|
34
34
|
H2FrameWindowUpdate as WindowUpdate,
|
|
35
35
|
H2StreamError,
|
|
36
36
|
)
|
|
37
|
-
from ..exceptions import ConnectionClosedError, GoAwayError, H2Error, H2Reason
|
|
37
|
+
from ..exceptions import ConnectionClosedError, GoAwayError, H2Error, H2Reason, fresh_exc
|
|
38
38
|
from .settings import Action
|
|
39
39
|
from .streams import _StreamError
|
|
40
40
|
|
|
@@ -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:
|
|
@@ -459,6 +511,12 @@ class H2ConnectionBase:
|
|
|
459
511
|
def _fail(self, exc):
|
|
460
512
|
# h2: proto/connection.rs `handle_poll2_result` (L430) -> the connection
|
|
461
513
|
# error fans out to every stream via streams.rs `Streams::handle_error` (L362).
|
|
514
|
+
# Store/fan out a stripped COPY (h2 clones an Arc'd error the same way):
|
|
515
|
+
# some callers keep propagating the caught instance after this, which would
|
|
516
|
+
# accumulate their frames onto a stored traceback (exceptions.fresh_exc).
|
|
517
|
+
# Sharing the one copy across conn + streams is fine — raise sites raise
|
|
518
|
+
# copies of it, so its traceback never grows.
|
|
519
|
+
exc = fresh_exc(exc)
|
|
462
520
|
self.error = exc
|
|
463
521
|
self.streams.fail_all(exc)
|
|
464
522
|
self._signal_ready() # unblock connect() if the handshake never completed (client); no-op (server)
|
|
@@ -37,6 +37,7 @@ from ..exceptions import (
|
|
|
37
37
|
ConnectionClosedError,
|
|
38
38
|
H2ProtocolError,
|
|
39
39
|
H2Reason,
|
|
40
|
+
fresh_exc,
|
|
40
41
|
)
|
|
41
42
|
from ..http import HeaderMap
|
|
42
43
|
from .connection import PREFACE, H2ConnectionBase
|
|
@@ -105,7 +106,7 @@ class ServerRequest:
|
|
|
105
106
|
await self._manager.release_capacity(self._stream, len(chunk))
|
|
106
107
|
yield chunk
|
|
107
108
|
if self._stream.error is not None:
|
|
108
|
-
raise self._stream.error
|
|
109
|
+
raise fresh_exc(self._stream.error) from self._stream.error # a copy per raise (exceptions.fresh_exc)
|
|
109
110
|
|
|
110
111
|
def read(self) -> Awaitable[bytes]:
|
|
111
112
|
return read_all(self.aiter_bytes())
|
|
@@ -191,7 +192,18 @@ class ServerStreamManager(StreamManager):
|
|
|
191
192
|
if st is not None:
|
|
192
193
|
if st.state.is_local_error():
|
|
193
194
|
return None # locally reset: swallow late frames "for some time"
|
|
194
|
-
|
|
195
|
+
if st.state.is_closed():
|
|
196
|
+
# Completed normally but still stored — the inline-write window
|
|
197
|
+
# (`StreamManager._drop_closed_stored`): drop it and classify
|
|
198
|
+
# below exactly as if already forgotten. For a server HEADERS
|
|
199
|
+
# target the deterministic post-release answer is the
|
|
200
|
+
# decreased-id connection error (h2 recv.rs `open` L127) — a
|
|
201
|
+
# peer sending HEADERS after its own END_STREAM violates either
|
|
202
|
+
# way, and the outcome must not depend on which side of the
|
|
203
|
+
# window the frame lands on.
|
|
204
|
+
self._drop_closed_stored(st)
|
|
205
|
+
else:
|
|
206
|
+
return st # existing stream -> the shared trailers path
|
|
195
207
|
reset_at = self._reset_streams.get(sid)
|
|
196
208
|
if reset_at is not None:
|
|
197
209
|
if self._conn.backend.monotonic() - reset_at <= _RESET_STREAM_SECS:
|
|
@@ -10,6 +10,7 @@ Cross-reference: `h2 ...` comments cite hyperium/h2 v0.4.15.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
from .._httpunk import H2Reason
|
|
13
|
+
from ..exceptions import fresh_exc
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class H2ResponseBody:
|
|
@@ -44,7 +45,10 @@ class H2ResponseBody:
|
|
|
44
45
|
await self._manager.release_capacity(self._stream, len(chunk))
|
|
45
46
|
yield chunk
|
|
46
47
|
if self._stream.error is not None:
|
|
47
|
-
raise
|
|
48
|
+
# A copy per raise — many readers may surface the one stored stream/conn
|
|
49
|
+
# error, and a shared raised instance would accumulate every consumer's
|
|
50
|
+
# frames onto its traceback (exceptions.fresh_exc).
|
|
51
|
+
raise fresh_exc(self._stream.error) from self._stream.error
|
|
48
52
|
|
|
49
53
|
async def aclose(self):
|
|
50
54
|
"""Cancel the stream if its body wasn't fully read (sends RST_STREAM).
|
|
@@ -26,7 +26,7 @@ import threading
|
|
|
26
26
|
|
|
27
27
|
from .._common import aiter_body
|
|
28
28
|
from .._httpunk import H2FlowControl
|
|
29
|
-
from ..exceptions import H2FlowControlError, H2ProtocolError, H2Reason, StreamResetError
|
|
29
|
+
from ..exceptions import H2FlowControlError, H2ProtocolError, H2Reason, StreamResetError, fresh_exc
|
|
30
30
|
from .settings import PeerSettings
|
|
31
31
|
|
|
32
32
|
|
|
@@ -314,9 +314,9 @@ class StreamManager:
|
|
|
314
314
|
# error to the parked sender). `_abort_stream`/`reset_stream`/
|
|
315
315
|
# `recv_reset` set window_evt to wake us for this re-check.
|
|
316
316
|
if st.error is not None:
|
|
317
|
-
raise st.error
|
|
317
|
+
raise fresh_exc(st.error) from st.error # a copy per raise (exceptions.fresh_exc)
|
|
318
318
|
if self._conn.error is not None:
|
|
319
|
-
raise self._conn.error
|
|
319
|
+
raise fresh_exc(self._conn.error) from self._conn.error
|
|
320
320
|
if st.state.is_closed():
|
|
321
321
|
raise StreamResetError(st.id, int(H2Reason.CANCEL))
|
|
322
322
|
with self._send_window_lock:
|
|
@@ -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:
|