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.
Files changed (134) hide show
  1. {httpunk-0.2.0 → httpunk-0.2.2}/Cargo.lock +1 -1
  2. {httpunk-0.2.0 → httpunk-0.2.2}/Cargo.toml +1 -1
  3. {httpunk-0.2.0 → httpunk-0.2.2}/PKG-INFO +1 -1
  4. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/_backend/asyncio.py +16 -5
  5. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/exceptions.py +39 -0
  6. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h1/client.py +28 -14
  7. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/client.py +19 -11
  8. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/connection.py +79 -21
  9. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/server.py +14 -2
  10. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/share.py +5 -1
  11. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/streams.py +40 -3
  12. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/pool.py +4 -1
  13. httpunk-0.2.2/tests/test_error_retention.py +183 -0
  14. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h1_server.py +105 -63
  15. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_server.py +175 -138
  16. {httpunk-0.2.0 → httpunk-0.2.2}/LICENSE +0 -0
  17. {httpunk-0.2.0 → httpunk-0.2.2}/README.md +0 -0
  18. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/Cargo.toml +0 -0
  19. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/LICENSE +0 -0
  20. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
  21. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/codec/error.rs +0 -0
  22. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/codec/mod.rs +0 -0
  23. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/ext.rs +0 -0
  24. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/data.rs +0 -0
  25. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/go_away.rs +0 -0
  26. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/head.rs +0 -0
  27. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/headers.rs +0 -0
  28. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/mod.rs +0 -0
  29. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/ping.rs +0 -0
  30. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/priority.rs +0 -0
  31. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/reason.rs +0 -0
  32. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/reset.rs +0 -0
  33. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/settings.rs +0 -0
  34. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
  35. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/util.rs +0 -0
  36. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/frame/window_update.rs +0 -0
  37. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
  38. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
  39. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/header.rs +0 -0
  40. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
  41. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
  42. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/mod.rs +0 -0
  43. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/table.rs +0 -0
  44. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/lib.rs +0 -0
  45. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/proto/error.rs +0 -0
  46. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/proto/mod.rs +0 -0
  47. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
  48. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
  49. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
  50. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/Cargo.toml +0 -0
  51. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/LICENSE +0 -0
  52. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
  53. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/body/length.rs +0 -0
  54. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/body/mod.rs +0 -0
  55. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/cfg.rs +0 -0
  56. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/common/date.rs +0 -0
  57. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/common/mod.rs +0 -0
  58. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/error.rs +0 -0
  59. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
  60. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/informational.rs +0 -0
  61. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/mod.rs +0 -0
  62. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/headers.rs +0 -0
  63. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/lib.rs +0 -0
  64. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
  65. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
  66. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
  67. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
  68. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
  69. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
  70. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/mod.rs +0 -0
  71. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper/src/trace.rs +0 -0
  72. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/Cargo.toml +0 -0
  73. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/LICENSE +0 -0
  74. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
  75. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
  76. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
  77. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
  78. {httpunk-0.2.0 → httpunk-0.2.2}/crates/vendor-hyper-util/src/lib.rs +0 -0
  79. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/__init__.py +0 -0
  80. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/_backend/__init__.py +0 -0
  81. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/_backend/tonio.py +0 -0
  82. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/_common.py +0 -0
  83. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/_httpunk.pyi +0 -0
  84. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/asyncio.py +0 -0
  85. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h1/__init__.py +0 -0
  86. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h1/connection.py +0 -0
  87. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h1/server.py +0 -0
  88. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h1/share.py +0 -0
  89. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/__init__.py +0 -0
  90. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/settings.py +0 -0
  91. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/h2/stream.py +0 -0
  92. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/http.py +0 -0
  93. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/py.typed +0 -0
  94. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/types.py +0 -0
  95. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/__init__.py +0 -0
  96. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/auto.py +0 -0
  97. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/client.py +0 -0
  98. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/graceful.py +0 -0
  99. {httpunk-0.2.0 → httpunk-0.2.2}/httpunk/util/proxy.py +0 -0
  100. {httpunk-0.2.0 → httpunk-0.2.2}/pyproject.toml +0 -0
  101. {httpunk-0.2.0 → httpunk-0.2.2}/src/errors.rs +0 -0
  102. {httpunk-0.2.0 → httpunk-0.2.2}/src/h1/codec.rs +0 -0
  103. {httpunk-0.2.0 → httpunk-0.2.2}/src/h1/mod.rs +0 -0
  104. {httpunk-0.2.0 → httpunk-0.2.2}/src/h2/codec.rs +0 -0
  105. {httpunk-0.2.0 → httpunk-0.2.2}/src/h2/mod.rs +0 -0
  106. {httpunk-0.2.0 → httpunk-0.2.2}/src/h2/streams.rs +0 -0
  107. {httpunk-0.2.0 → httpunk-0.2.2}/src/http/mod.rs +0 -0
  108. {httpunk-0.2.0 → httpunk-0.2.2}/src/lib.rs +0 -0
  109. {httpunk-0.2.0 → httpunk-0.2.2}/src/proxy.rs +0 -0
  110. {httpunk-0.2.0 → httpunk-0.2.2}/tests/_client.py +0 -0
  111. {httpunk-0.2.0 → httpunk-0.2.2}/tests/conftest.py +0 -0
  112. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_asyncio_backend.py +0 -0
  113. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_asyncio_client_protocol.py +0 -0
  114. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_asyncio_e2e.py +0 -0
  115. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_asyncio_protocol.py +0 -0
  116. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_backend.py +0 -0
  117. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h1_codec.py +0 -0
  118. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h1_conn.py +0 -0
  119. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h1_decode.py +0 -0
  120. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_codec.py +0 -0
  121. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_conn.py +0 -0
  122. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_errors.py +0 -0
  123. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_flow.py +0 -0
  124. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_flow_control.py +0 -0
  125. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_ready.py +0 -0
  126. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_settings.py +0 -0
  127. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_h2_state.py +0 -0
  128. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_headermap.py +0 -0
  129. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_auto.py +0 -0
  130. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_connect.py +0 -0
  131. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_graceful.py +0 -0
  132. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_pool.py +0 -0
  133. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_proxy.py +0 -0
  134. {httpunk-0.2.0 → httpunk-0.2.2}/tests/test_util_tls.py +0 -0
@@ -118,7 +118,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
118
118
 
119
119
  [[package]]
120
120
  name = "httpunk"
121
- version = "0.2.0"
121
+ version = "0.2.2"
122
122
  dependencies = [
123
123
  "bytes",
124
124
  "h2",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "httpunk"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "The Rust HTTP library for Python"
5
5
  authors = [
6
6
  "Giovanni Barillari <g@baro.dev>",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpunk
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: BSD License
@@ -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
- self._error = exc
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 self._error
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
- raise self._error or BrokenPipeError(_errno.EPIPE, "connection lost")
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
- self._watcher_error = exc # surfaced by _read_head's first read
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
- exc = self.error or ConnectionClosedError("connection closed")
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.request_unsent = True
295
- raise self.error
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
- raise self.error or ConnectionClosedError("connection closed")
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
- self.error = exc
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
- raise self._conn.error or self._goaway
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
- self._abort_stream(st, self._conn.error or self._goaway)
156
- raise self._conn.error or self._goaway
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
- raise stream.error or self._conn.error or ConnectionClosedError("connection closed before response")
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
- with self._write_buf_lock:
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
- await self._transport.send_all(pending)
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
- with self._write_buf_lock:
264
- data, self._write_buf = bytes(self._write_buf), bytearray()
265
- self._write_evt.clear()
266
- stopping = self._write_stop
267
- if data:
268
- try:
269
- async with self._send_lock:
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
- except OSError as exc:
272
- self._fail(ConnectionClosedError(f"connection closed: {exc}"))
273
- break
274
- except Exception as exc:
275
- self._fail(exc)
276
- break
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
- return st # existing stream -> the shared trailers path
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 self._stream.error
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: