httpunk 0.2.1__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.1 → httpunk-0.2.2}/Cargo.lock +1 -1
- {httpunk-0.2.1 → httpunk-0.2.2}/Cargo.toml +1 -1
- {httpunk-0.2.1 → httpunk-0.2.2}/PKG-INFO +1 -1
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/_backend/asyncio.py +16 -5
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/exceptions.py +39 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h1/client.py +28 -14
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/client.py +19 -11
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/connection.py +7 -1
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/server.py +2 -1
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/share.py +5 -1
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/streams.py +3 -3
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/pool.py +4 -1
- httpunk-0.2.2/tests/test_error_retention.py +183 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h1_server.py +3 -1
- {httpunk-0.2.1 → httpunk-0.2.2}/LICENSE +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/README.md +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/Cargo.toml +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/LICENSE +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/codec/error.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/codec/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/ext.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/data.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/go_away.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/head.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/headers.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/ping.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/priority.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/reason.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/reset.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/settings.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/util.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/window_update.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/header.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/table.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/lib.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/proto/error.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/proto/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/Cargo.toml +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/LICENSE +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/body/length.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/body/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/cfg.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/common/date.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/common/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/error.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/informational.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/headers.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/lib.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/trace.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/Cargo.toml +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/LICENSE +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/src/lib.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/__init__.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/_backend/__init__.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/_backend/tonio.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/_common.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/_httpunk.pyi +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/asyncio.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h1/__init__.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h1/connection.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h1/server.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h1/share.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/__init__.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/settings.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/stream.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/http.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/py.typed +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/types.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/__init__.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/auto.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/client.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/graceful.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/proxy.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/pyproject.toml +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/src/errors.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/src/h1/codec.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/src/h1/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/src/h2/codec.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/src/h2/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/src/h2/streams.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/src/http/mod.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/src/lib.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/src/proxy.rs +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/_client.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/conftest.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_asyncio_backend.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_asyncio_client_protocol.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_asyncio_e2e.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_asyncio_protocol.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_backend.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h1_codec.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h1_conn.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h1_decode.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_codec.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_conn.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_errors.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_flow.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_flow_control.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_ready.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_server.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_settings.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_state.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_headermap.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_auto.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_connect.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_graceful.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_pool.py +0 -0
- {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_proxy.py +0 -0
- {httpunk-0.2.1 → 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
|
|
|
@@ -511,6 +511,12 @@ class H2ConnectionBase:
|
|
|
511
511
|
def _fail(self, exc):
|
|
512
512
|
# h2: proto/connection.rs `handle_poll2_result` (L430) -> the connection
|
|
513
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)
|
|
514
520
|
self.error = exc
|
|
515
521
|
self.streams.fail_all(exc)
|
|
516
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())
|
|
@@ -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:
|
|
@@ -31,6 +31,7 @@ from typing import Any
|
|
|
31
31
|
from urllib.parse import urlsplit
|
|
32
32
|
|
|
33
33
|
from .. import _backend
|
|
34
|
+
from ..exceptions import fresh_exc
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
class Canceled(Exception): # noqa: N818 - `Canceled` is hyper-util's exact name (singleton.rs)
|
|
@@ -89,7 +90,9 @@ class Singleton:
|
|
|
89
90
|
except BaseException as exc:
|
|
90
91
|
with self._lock:
|
|
91
92
|
self._state = "empty" # ditch the round so the next get() retries
|
|
92
|
-
|
|
93
|
+
# A stripped copy — the caught instance keeps propagating below
|
|
94
|
+
# and this pool object is long-lived (exceptions.fresh_exc).
|
|
95
|
+
self._error = fresh_exc(exc)
|
|
93
96
|
ready.set()
|
|
94
97
|
raise
|
|
95
98
|
with self._lock:
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""The stored-error hygiene rules (`exceptions.fresh_exc`): saved connection/
|
|
2
|
+
stream errors are traceback-free copies, and every raise of a saved error is a
|
|
3
|
+
fresh copy chained to it — so no stored instance ever accumulates a consumer
|
|
4
|
+
stack's frames into a refcount-invisible cycle (the class of bug where a failed
|
|
5
|
+
connection's error pinned live transports until a gen-2 cyclic GC)."""
|
|
6
|
+
|
|
7
|
+
import gc
|
|
8
|
+
import platform
|
|
9
|
+
import weakref
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from httpunk._backend.asyncio import AsyncioBackend, _AsyncioStream
|
|
14
|
+
from httpunk.exceptions import GoAwayError, H2Reason, StreamResetError, fresh_exc
|
|
15
|
+
from httpunk.h1.client import Connection as H1Driver
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ----- fresh_exc -----
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _caught(exc):
|
|
22
|
+
"""Raise + catch `exc` so it carries a traceback, like any stored real error."""
|
|
23
|
+
try:
|
|
24
|
+
raise exc
|
|
25
|
+
except type(exc) as e:
|
|
26
|
+
return e
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_fresh_exc_copies_oserror_family():
|
|
30
|
+
err = _caught(ConnectionResetError(54, "Connection reset by peer"))
|
|
31
|
+
cp = fresh_exc(err)
|
|
32
|
+
assert cp is not err
|
|
33
|
+
assert type(cp) is ConnectionResetError
|
|
34
|
+
assert cp.args == err.args
|
|
35
|
+
assert (cp.errno, cp.strerror) == (54, "Connection reset by peer")
|
|
36
|
+
assert cp.__traceback__ is None # the copy starts frame-free
|
|
37
|
+
assert err.__traceback__ is not None # the original is untouched
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_fresh_exc_carries_dict_extras():
|
|
41
|
+
err = ValueError("poisoned")
|
|
42
|
+
err.request_unsent = True
|
|
43
|
+
cp = fresh_exc(err)
|
|
44
|
+
assert cp is not err
|
|
45
|
+
assert cp.request_unsent is True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_fresh_exc_goaway_roundtrip():
|
|
49
|
+
# GoAwayError's args hold the formatted message, so copying needs its
|
|
50
|
+
# __reduce__ — with both a known (enum) and an unknown (raw int) error code.
|
|
51
|
+
for code in (int(H2Reason.ENHANCE_YOUR_CALM), 0x5EAF00D):
|
|
52
|
+
err = _caught(GoAwayError(7, code, b"bye"))
|
|
53
|
+
cp = fresh_exc(err)
|
|
54
|
+
assert cp is not err
|
|
55
|
+
assert type(cp) is GoAwayError
|
|
56
|
+
assert (cp.last_stream_id, cp.error_code, cp.debug_data) == (err.last_stream_id, err.error_code, b"bye")
|
|
57
|
+
assert str(cp) == str(err)
|
|
58
|
+
assert cp.__traceback__ is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_fresh_exc_stream_reset_roundtrip():
|
|
62
|
+
err = _caught(StreamResetError(5, int(H2Reason.CANCEL)))
|
|
63
|
+
cp = fresh_exc(err)
|
|
64
|
+
assert cp is not err
|
|
65
|
+
assert (cp.stream_id, cp.error_code) == (5, H2Reason.CANCEL)
|
|
66
|
+
assert cp.__traceback__ is None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_fresh_exc_degrades_to_shared_instance():
|
|
70
|
+
class OddError(Exception):
|
|
71
|
+
def __init__(self, a, b): # args don't roundtrip (message-only super().__init__)
|
|
72
|
+
super().__init__(f"{a}-{b}")
|
|
73
|
+
|
|
74
|
+
err = OddError(1, 2)
|
|
75
|
+
assert fresh_exc(err) is err # uncopyable -> degraded but functional
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ----- the asyncio backend stream -----
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.asyncio
|
|
82
|
+
async def test_connection_lost_strips_stored_traceback():
|
|
83
|
+
s = _AsyncioStream()
|
|
84
|
+
s.connection_made(None)
|
|
85
|
+
s.connection_lost(_caught(ConnectionResetError(54, "peer reset")))
|
|
86
|
+
assert s._error.__traceback__ is None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_receive_some_raises_a_fresh_copy():
|
|
91
|
+
s = _AsyncioStream()
|
|
92
|
+
s.connection_made(None)
|
|
93
|
+
s.connection_lost(ConnectionResetError(54, "peer reset"))
|
|
94
|
+
with pytest.raises(ConnectionResetError) as ei:
|
|
95
|
+
await s.receive_some()
|
|
96
|
+
assert ei.value is not s._error # never the stored instance
|
|
97
|
+
assert ei.value.__cause__ is s._error # chained for debuggability
|
|
98
|
+
assert ei.value.args == s._error.args
|
|
99
|
+
assert s._error.__traceback__ is None # the raise left no frames on the stored error
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.asyncio
|
|
103
|
+
async def test_send_all_raises_a_fresh_copy():
|
|
104
|
+
s = _AsyncioStream()
|
|
105
|
+
s.connection_made(None)
|
|
106
|
+
s.connection_lost(ConnectionResetError(54, "peer reset"))
|
|
107
|
+
with pytest.raises(ConnectionResetError) as ei:
|
|
108
|
+
await s.send_all(b"data")
|
|
109
|
+
assert ei.value is not s._error
|
|
110
|
+
assert s._error.__traceback__ is None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _Sentinel:
|
|
114
|
+
"""Stands in for everything a consumer frame holds (a server's response
|
|
115
|
+
transport, a relay generator, ...)."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.skipif(
|
|
119
|
+
platform.python_implementation() != "CPython",
|
|
120
|
+
reason="asserts prompt refcount finalization — CPython semantics; on PyPy everything waits for GC regardless",
|
|
121
|
+
)
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_failed_stream_frees_consumer_state_without_cyclic_gc():
|
|
124
|
+
# The end-to-end property (the "s04" regression): after a consumer stack
|
|
125
|
+
# catches the read error of a failed connection, everything — the consumer's
|
|
126
|
+
# state AND the stream itself — must be freed by refcounting alone. Before
|
|
127
|
+
# the fresh_exc rules, the stored error's traceback closed a cycle through
|
|
128
|
+
# the stream and pinned the whole graph until a gen-2 collection.
|
|
129
|
+
s = _AsyncioStream()
|
|
130
|
+
s.connection_made(None)
|
|
131
|
+
s.connection_lost(ConnectionResetError(54, "peer reset"))
|
|
132
|
+
stream_ref = weakref.ref(s)
|
|
133
|
+
sentinel = _Sentinel()
|
|
134
|
+
sentinel_ref = weakref.ref(sentinel)
|
|
135
|
+
|
|
136
|
+
async def relay(stream, held):
|
|
137
|
+
try:
|
|
138
|
+
while True:
|
|
139
|
+
await stream.receive_some()
|
|
140
|
+
except ConnectionResetError:
|
|
141
|
+
pass # swallowed, like a server relaying this body would
|
|
142
|
+
|
|
143
|
+
gc.disable()
|
|
144
|
+
try:
|
|
145
|
+
await relay(s, sentinel)
|
|
146
|
+
del sentinel
|
|
147
|
+
assert sentinel_ref() is None # consumer state: freed by refcount
|
|
148
|
+
del s
|
|
149
|
+
assert stream_ref() is None # the stream: no self-pinning cycle either
|
|
150
|
+
finally:
|
|
151
|
+
gc.enable()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ----- the h1 driver's saved connection error -----
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _DummyTransport:
|
|
158
|
+
def close(self):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.mark.asyncio
|
|
163
|
+
async def test_h1_fail_stores_a_stripped_copy():
|
|
164
|
+
# `_fail` runs while the caught instance is still propagating to the caller
|
|
165
|
+
# (send_request stores, then re-raises) — the saved error must be a copy so
|
|
166
|
+
# the continuing propagation can't grow a stored traceback.
|
|
167
|
+
conn = H1Driver(_DummyTransport(), backend=AsyncioBackend())
|
|
168
|
+
err = _caught(ValueError("mid-exchange failure"))
|
|
169
|
+
conn._fail(err)
|
|
170
|
+
assert conn.error is not err
|
|
171
|
+
assert conn.error.args == err.args
|
|
172
|
+
assert conn.error.__traceback__ is None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.asyncio
|
|
176
|
+
async def test_h1_wait_idle_raises_a_fresh_copy():
|
|
177
|
+
conn = H1Driver(_DummyTransport(), backend=AsyncioBackend())
|
|
178
|
+
conn._fail(_caught(ValueError("dead")))
|
|
179
|
+
with pytest.raises(ValueError) as ei:
|
|
180
|
+
await conn.wait_idle()
|
|
181
|
+
assert ei.value is not conn.error
|
|
182
|
+
assert ei.value.__cause__ is conn.error
|
|
183
|
+
assert conn.error.__traceback__ is None # this raise added nothing to the stored error
|
|
@@ -502,7 +502,9 @@ async def test_server_upgrade_tunnel():
|
|
|
502
502
|
s.spawn(serve())
|
|
503
503
|
transport = await _raw_client(host, port)
|
|
504
504
|
try:
|
|
505
|
-
await transport.send_all(
|
|
505
|
+
await transport.send_all(
|
|
506
|
+
b"GET /chat HTTP/1.1\r\nhost: x\r\nconnection: upgrade\r\nupgrade: myproto\r\n\r\n"
|
|
507
|
+
)
|
|
506
508
|
head = await _read_until(transport, b"\r\n\r\n")
|
|
507
509
|
assert head.startswith(b"HTTP/1.1 101")
|
|
508
510
|
await transport.send_all(b"ping")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|