httpunk 0.1.4__tar.gz → 0.2.0__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.1.4 → httpunk-0.2.0}/Cargo.lock +3 -3
  2. {httpunk-0.1.4 → httpunk-0.2.0}/Cargo.toml +2 -2
  3. {httpunk-0.1.4 → httpunk-0.2.0}/PKG-INFO +5 -4
  4. {httpunk-0.1.4 → httpunk-0.2.0}/README.md +1 -1
  5. httpunk-0.2.0/crates/vendor-h2/UPSTREAM_VERSION +1 -0
  6. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/hpack/encoder.rs +35 -0
  7. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/_backend/asyncio.py +18 -0
  8. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/_backend/tonio.py +22 -1
  9. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/exceptions.py +9 -0
  10. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h1/client.py +250 -26
  11. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h1/connection.py +1 -1
  12. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h1/server.py +13 -0
  13. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h2/client.py +21 -2
  14. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h2/connection.py +134 -61
  15. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h2/server.py +14 -4
  16. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h2/share.py +25 -4
  17. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h2/stream.py +10 -0
  18. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h2/streams.py +118 -38
  19. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/util/pool.py +12 -6
  20. {httpunk-0.1.4 → httpunk-0.2.0}/pyproject.toml +3 -2
  21. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_asyncio_e2e.py +41 -0
  22. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_asyncio_protocol.py +30 -0
  23. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h1_conn.py +99 -5
  24. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h1_server.py +35 -0
  25. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h2_errors.py +67 -0
  26. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h2_flow.py +113 -14
  27. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h2_server.py +149 -0
  28. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_util_tls.py +11 -1
  29. httpunk-0.1.4/crates/vendor-h2/UPSTREAM_VERSION +0 -1
  30. {httpunk-0.1.4 → httpunk-0.2.0}/LICENSE +0 -0
  31. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/Cargo.toml +0 -0
  32. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/LICENSE +0 -0
  33. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/codec/error.rs +0 -0
  34. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/codec/mod.rs +0 -0
  35. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/ext.rs +0 -0
  36. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/data.rs +0 -0
  37. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/go_away.rs +0 -0
  38. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/head.rs +0 -0
  39. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/headers.rs +0 -0
  40. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/mod.rs +0 -0
  41. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/ping.rs +0 -0
  42. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/priority.rs +0 -0
  43. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/reason.rs +0 -0
  44. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/reset.rs +0 -0
  45. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/settings.rs +0 -0
  46. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
  47. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/util.rs +0 -0
  48. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/frame/window_update.rs +0 -0
  49. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
  50. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/hpack/header.rs +0 -0
  51. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
  52. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
  53. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/hpack/mod.rs +0 -0
  54. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/hpack/table.rs +0 -0
  55. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/lib.rs +0 -0
  56. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/proto/error.rs +0 -0
  57. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/proto/mod.rs +0 -0
  58. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
  59. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
  60. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
  61. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/Cargo.toml +0 -0
  62. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/LICENSE +0 -0
  63. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
  64. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/body/length.rs +0 -0
  65. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/body/mod.rs +0 -0
  66. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/cfg.rs +0 -0
  67. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/common/date.rs +0 -0
  68. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/common/mod.rs +0 -0
  69. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/error.rs +0 -0
  70. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
  71. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/ext/informational.rs +0 -0
  72. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/ext/mod.rs +0 -0
  73. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/headers.rs +0 -0
  74. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/lib.rs +0 -0
  75. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
  76. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
  77. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
  78. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
  79. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
  80. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
  81. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/proto/mod.rs +0 -0
  82. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper/src/trace.rs +0 -0
  83. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper-util/Cargo.toml +0 -0
  84. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper-util/LICENSE +0 -0
  85. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
  86. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
  87. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
  88. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
  89. {httpunk-0.1.4 → httpunk-0.2.0}/crates/vendor-hyper-util/src/lib.rs +0 -0
  90. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/__init__.py +0 -0
  91. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/_backend/__init__.py +0 -0
  92. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/_common.py +0 -0
  93. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/_httpunk.pyi +0 -0
  94. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/asyncio.py +0 -0
  95. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h1/__init__.py +0 -0
  96. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h1/share.py +0 -0
  97. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h2/__init__.py +0 -0
  98. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/h2/settings.py +0 -0
  99. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/http.py +0 -0
  100. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/py.typed +0 -0
  101. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/types.py +0 -0
  102. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/util/__init__.py +0 -0
  103. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/util/auto.py +0 -0
  104. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/util/client.py +0 -0
  105. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/util/graceful.py +0 -0
  106. {httpunk-0.1.4 → httpunk-0.2.0}/httpunk/util/proxy.py +0 -0
  107. {httpunk-0.1.4 → httpunk-0.2.0}/src/errors.rs +0 -0
  108. {httpunk-0.1.4 → httpunk-0.2.0}/src/h1/codec.rs +0 -0
  109. {httpunk-0.1.4 → httpunk-0.2.0}/src/h1/mod.rs +0 -0
  110. {httpunk-0.1.4 → httpunk-0.2.0}/src/h2/codec.rs +0 -0
  111. {httpunk-0.1.4 → httpunk-0.2.0}/src/h2/mod.rs +0 -0
  112. {httpunk-0.1.4 → httpunk-0.2.0}/src/h2/streams.rs +0 -0
  113. {httpunk-0.1.4 → httpunk-0.2.0}/src/http/mod.rs +0 -0
  114. {httpunk-0.1.4 → httpunk-0.2.0}/src/lib.rs +0 -0
  115. {httpunk-0.1.4 → httpunk-0.2.0}/src/proxy.rs +0 -0
  116. {httpunk-0.1.4 → httpunk-0.2.0}/tests/_client.py +0 -0
  117. {httpunk-0.1.4 → httpunk-0.2.0}/tests/conftest.py +0 -0
  118. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_asyncio_backend.py +0 -0
  119. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_asyncio_client_protocol.py +0 -0
  120. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_backend.py +0 -0
  121. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h1_codec.py +0 -0
  122. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h1_decode.py +0 -0
  123. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h2_codec.py +0 -0
  124. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h2_conn.py +0 -0
  125. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h2_flow_control.py +0 -0
  126. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h2_ready.py +0 -0
  127. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h2_settings.py +0 -0
  128. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_h2_state.py +0 -0
  129. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_headermap.py +0 -0
  130. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_util_auto.py +0 -0
  131. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_util_connect.py +0 -0
  132. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_util_graceful.py +0 -0
  133. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_util_pool.py +0 -0
  134. {httpunk-0.1.4 → httpunk-0.2.0}/tests/test_util_proxy.py +0 -0
@@ -55,9 +55,9 @@ checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
55
55
 
56
56
  [[package]]
57
57
  name = "h2"
58
- version = "0.4.16"
58
+ version = "0.4.19"
59
59
  source = "registry+https://github.com/rust-lang/crates.io-index"
60
- checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
60
+ checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
61
61
  dependencies = [
62
62
  "atomic-waker",
63
63
  "bytes",
@@ -118,7 +118,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
118
118
 
119
119
  [[package]]
120
120
  name = "httpunk"
121
- version = "0.1.4"
121
+ version = "0.2.0"
122
122
  dependencies = [
123
123
  "bytes",
124
124
  "h2",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "httpunk"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = "The Rust HTTP library for Python"
5
5
  authors = [
6
6
  "Giovanni Barillari <g@baro.dev>",
@@ -60,7 +60,7 @@ vendor-hyper-util = { path = "crates/vendor-hyper-util" }
60
60
  # THIRD-PARTY.md). Optional + gated behind the never-enabled `_audit` feature so
61
61
  # the pinned versions land in Cargo.lock for `cargo audit` / Dependabot advisory
62
62
  # coverage without pulling them (and tokio) into the build.
63
- h2 = { version = "=0.4.16", optional = true }
63
+ h2 = { version = "=0.4.19", optional = true }
64
64
  hyper = { version = "=1.11.0", optional = true, default-features = false, features = ["http1", "client", "server"] }
65
65
 
66
66
  [profile.release]
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpunk
3
- Version: 0.1.4
4
- Classifier: Development Status :: 3 - Alpha
3
+ Version: 0.2.0
4
+ Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: BSD License
7
7
  Classifier: Operating System :: MacOS
@@ -13,13 +13,14 @@ Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: 3.15
16
17
  Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
17
18
  Classifier: Programming Language :: Python :: Implementation :: CPython
18
19
  Classifier: Programming Language :: Python :: Implementation :: PyPy
19
20
  Classifier: Programming Language :: Python
20
21
  Classifier: Programming Language :: Rust
21
22
  Classifier: Topic :: Internet :: WWW/HTTP
22
- Requires-Dist: tonio~=0.9.7 ; python_full_version >= '3.14' and extra == 'tonio'
23
+ Requires-Dist: tonio~=0.9.12 ; python_full_version >= '3.14' and extra == 'tonio'
23
24
  Provides-Extra: tonio
24
25
  License-File: LICENSE
25
26
  Summary: The Rust HTTP library for Python
@@ -44,7 +45,7 @@ base for building HTTP clients and servers on top of.
44
45
 
45
46
  httpunk's API mirrors hyper's wherever possible.
46
47
 
47
- > **Note:** httpunk is in an early, alpha stage.
48
+ > **Warning:** httpunk is in an early stage and still work in progress.
48
49
 
49
50
  > **Note:** httpunk was built with substantial help from LLMs, under human supervision.
50
51
 
@@ -9,7 +9,7 @@ base for building HTTP clients and servers on top of.
9
9
 
10
10
  httpunk's API mirrors hyper's wherever possible.
11
11
 
12
- > **Note:** httpunk is in an early, alpha stage.
12
+ > **Warning:** httpunk is in an early stage and still work in progress.
13
13
 
14
14
  > **Note:** httpunk was built with substantial help from LLMs, under human supervision.
15
15
 
@@ -0,0 +1 @@
1
+ 0.4.19
@@ -4,9 +4,12 @@ use super::{huffman, Header};
4
4
  use bytes::{BufMut, BytesMut};
5
5
  use http::header::{HeaderName, HeaderValue};
6
6
 
7
+ const DEFAULT_MAX_ALLOWED_SIZE: usize = 4 * 1024;
8
+
7
9
  #[derive(Debug)]
8
10
  pub struct Encoder {
9
11
  table: Table,
12
+ max_allowed_size: usize,
10
13
  size_update: Option<SizeUpdate>,
11
14
  /// Reusable buffer for the encoded header block of a single frame.
12
15
  ///
@@ -25,13 +28,25 @@ enum SizeUpdate {
25
28
 
26
29
  impl Encoder {
27
30
  pub fn new(max_size: usize, capacity: usize) -> Encoder {
31
+ let max_size = max_size.min(DEFAULT_MAX_ALLOWED_SIZE);
32
+
28
33
  Encoder {
29
34
  table: Table::new(max_size, capacity),
35
+ max_allowed_size: DEFAULT_MAX_ALLOWED_SIZE,
30
36
  size_update: None,
31
37
  scratch: BytesMut::new(),
32
38
  }
33
39
  }
34
40
 
41
+ #[cfg(test)]
42
+ fn set_max_allowed_size(&mut self, max: usize) {
43
+ self.max_allowed_size = max;
44
+
45
+ if self.table.max_size() > max {
46
+ self.update_max_size(max);
47
+ }
48
+ }
49
+
35
50
  /// Takes the reusable buffer for one header-block encode.
36
51
  ///
37
52
  /// The buffer is returned by `return_scratch` once the encoded block has
@@ -51,6 +66,8 @@ impl Encoder {
51
66
  ///
52
67
  /// The next call to `encode` will include a dynamic size update frame.
53
68
  pub fn update_max_size(&mut self, val: usize) {
69
+ let val = val.min(self.max_allowed_size);
70
+
54
71
  match self.size_update {
55
72
  Some(SizeUpdate::One(old)) => {
56
73
  if val > old {
@@ -614,6 +631,7 @@ mod test {
614
631
  assert_eq!(Some(SizeUpdate::Two(0, 100)), encoder.size_update);
615
632
 
616
633
  let mut encoder = Encoder::default();
634
+ encoder.set_max_allowed_size(8000);
617
635
  encoder.update_max_size(8000);
618
636
  assert_eq!(Some(SizeUpdate::One(8000)), encoder.size_update);
619
637
 
@@ -701,6 +719,7 @@ mod test {
701
719
  #[test]
702
720
  fn test_large_size_update() {
703
721
  let mut encoder = Encoder::default();
722
+ encoder.set_max_allowed_size(usize::MAX);
704
723
 
705
724
  encoder.update_max_size(1912930560);
706
725
  assert_eq!(Some(SizeUpdate::One(1912930560)), encoder.size_update);
@@ -710,6 +729,22 @@ mod test {
710
729
  assert_eq!([63, 225, 129, 148, 144, 7], &dst[..]);
711
730
  }
712
731
 
732
+ #[test]
733
+ fn test_large_size_update_is_capped() {
734
+ let mut encoder = Encoder::new(0, 0);
735
+
736
+ encoder.update_max_size(1912930560);
737
+ assert_eq!(
738
+ Some(SizeUpdate::One(DEFAULT_MAX_ALLOWED_SIZE)),
739
+ encoder.size_update
740
+ );
741
+
742
+ let mut dst = BytesMut::with_capacity(3);
743
+ encoder.encode_size_updates(&mut dst);
744
+ assert_eq!([63, 225, 31], &dst[..]);
745
+ assert_eq!(DEFAULT_MAX_ALLOWED_SIZE, encoder.table.max_size());
746
+ }
747
+
713
748
  #[test]
714
749
  #[ignore]
715
750
  fn test_evicted_overflow() {
@@ -257,6 +257,17 @@ class AsyncioBackend:
257
257
  drives the `close_notify`), so no plain/TLS split like tonio."""
258
258
  transport.close()
259
259
 
260
+ def spawn_without_results(self, *coros):
261
+ """Spawn task(s) NOW, discarding their results; returns the join handle
262
+ (see the tonio backend's twin for the full contract: await-once by a
263
+ single owner, no cancellation surface, coroutines route their own
264
+ errors). One coro -> its Task; several -> a gather with
265
+ `return_exceptions=True`, so the join waits for ALL of them exactly
266
+ like tonio's barrier does."""
267
+ if len(coros) == 1:
268
+ return asyncio.ensure_future(coros[0])
269
+ return asyncio.gather(*coros, return_exceptions=True)
270
+
260
271
  async def select(self, *coros):
261
272
  """Race `coros`; return the first-ready one's result, cancelling the losers.
262
273
  Matches tonio's `select` (`_ctl.select`: spawn all in argument order, keep the
@@ -294,6 +305,13 @@ class AsyncioBackend:
294
305
  q = asyncio.Queue()
295
306
  return _QueueSender(q), _QueueReceiver(q)
296
307
 
308
+ # Abrupt-peer-teardown exceptions (see the tonio backend's twin attribute):
309
+ # an RST surfaces as `ConnectionError` from `connection_lost`; a TLS close
310
+ # without close_notify as `ssl.SSLEOFError` (asyncio's SSL layer raises it
311
+ # on an unexpected EOF). The h1 server maps these at the request-head
312
+ # boundary to a clean end-of-iteration (F47).
313
+ broken_transport_errors = (ConnectionError, _ssl.SSLEOFError)
314
+
297
315
  # asyncio's Lock/Event/Semaphore already match the seam's neutral contract
298
316
  # (Event: set/wait/clear/is_set; Semaphore: async acquire / sync release — the
299
317
  # shape step 1 normalized the h1 slot to).
@@ -13,7 +13,7 @@ from tonio.colored.net.tls import open_tls_over_tcp_stream as _open_tls_over_tcp
13
13
  from tonio.colored.sync import Lock as _Lock, Semaphore as _Semaphore
14
14
  from tonio.colored.sync.channel import unbounded as _unbounded
15
15
  from tonio.colored.time import time as _now, timeout as _timeout
16
- from tonio.exceptions import CancelledError
16
+ from tonio.exceptions import CancelledError, ResourceBroken
17
17
 
18
18
 
19
19
  class _TonioSemaphore:
@@ -106,6 +106,27 @@ class TonioBackend:
106
106
  else:
107
107
  transport.close()
108
108
 
109
+ # Exceptions `receive_some`/`send_all` raise when the peer tears the
110
+ # transport down ABRUPTLY instead of a clean EOF: an RST on plain TCP
111
+ # (`ConnectionError`: ConnectionResetError/BrokenPipeError), or a TLS close
112
+ # without close_notify (tonio wraps the SSLEOFError in `ResourceBroken`) —
113
+ # which httpunk's own sync `close_transport` produces by design (abortive
114
+ # close, F33a), so any httpunk client hanging up makes the peer see one.
115
+ # The h1 server maps these at the request-head boundary to a clean
116
+ # end-of-iteration (F47).
117
+ broken_transport_errors = (ConnectionError, ResourceBroken)
118
+
119
+ # Spawn task(s) NOW, discarding their results; returns the join handle.
120
+ # `await handle` resolves once ALL of them finished — exactly one owner
121
+ # awaits it, exactly once (tonio's handle is Barrier-based one-shot). NO
122
+ # cancellation surface, by design: this is the primitive for tasks that
123
+ # must never be cancelled (flush/handoff obligations — the h2 write pump,
124
+ # the h1 idle watcher); a group that needs teardown-with-cancel uses
125
+ # `scope`. Spawned coroutines must not let exceptions escape (drivers
126
+ # route errors into connection state); an escaped one surfaces at the
127
+ # join in a backend-specific shape and is a driver bug, not API.
128
+ spawn_without_results = staticmethod(_colored.spawn.without_results)
129
+
109
130
  select = staticmethod(_colored.select)
110
131
  scope = staticmethod(_colored.scope)
111
132
  lock = _Lock
@@ -17,6 +17,15 @@ state-machine / flow-control / API-misuse errors raised by the extension share i
17
17
 
18
18
  `error_code` attributes are `H2Reason` members for known codes (an `IntEnum`, so
19
19
  they compare equal to ints), or a plain int for codes outside the RFC set.
20
+
21
+ An HTTP/1 `send_request` failure raised before the request was handed to the
22
+ writer carries `request_unsent = True` on the exception instance (whatever its
23
+ type — `ConnectionClosedError`, or the unexpected-bytes poison error): nothing
24
+ reached the wire, so the request is safe to retry with any body, streamed
25
+ included. The mirror of hyper's `TrySendError { message: Some(request) }`
26
+ give-back (client/conn/http1.rs L247-263; proto/h1/dispatch.rs L711-733). Read
27
+ it with `getattr(exc, "request_unsent", False)` — it is absent once the write
28
+ may have begun.
20
29
  """
21
30
 
22
31
  from __future__ import annotations
@@ -12,6 +12,7 @@ Cross-reference: hyper `client::conn::http1` (`SendRequest`/`Connection`) +
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import threading
15
16
  from collections.abc import Awaitable
16
17
  from typing import TYPE_CHECKING, Any
17
18
 
@@ -61,6 +62,12 @@ class Connection(H1ConnectionBase):
61
62
  - `Expect: 100-continue` and the request's `Connection: close` are non-gaps for
62
63
  the client: hyper hard-codes `expect_continue: false` (role.rs L1161) and
63
64
  derives reuse solely from the response's keep-alive (conn.rs L294).
65
+ - While reusable-idle, a watcher task holds read interest — the re-expression
66
+ of hyper's always-polled `Connection` future (see `_watch_idle`) — so an
67
+ idle peer close or stray byte flips `closed`/`error` when it happens, and
68
+ a `send_request` failure raised before the request was handed to the
69
+ writer carries `request_unsent = True` (hyper's `TrySendError` message
70
+ give-back, client/conn/http1.rs L247-263).
64
71
  """
65
72
 
66
73
  def __init__(self, transport, *, authority=None, backend=None):
@@ -87,22 +94,151 @@ class Connection(H1ConnectionBase):
87
94
  # (a scope can't be re-armed after `cancel()`), so reuse isn't blocked.
88
95
  self._writer_scope = None
89
96
  self._writer_done = None
97
+ # The idle watcher: a background task parked in ONE read while the
98
+ # connection is reusable-idle — the Python re-expression of hyper's
99
+ # always-polled `Connection` future, which keeps read interest registered
100
+ # for the whole idle period (dispatch.rs L287 falls through to
101
+ # `poll_read_keep_alive`, conn.rs L435-443; `force_io_read` returns
102
+ # Pending, conn.rs L510-520), so a peer FIN or stray byte is observed
103
+ # when it ARRIVES, not at the next send. Python coroutines can't hold
104
+ # read interest without a parked task — the watcher is that task
105
+ # (divergence in mechanism only; behavior matches hyper, see
106
+ # `_watch_idle`). The watcher is NEVER cancelled or signalled: a parked
107
+ # `receive_some` legitimately ends only via data, EOF, or transport
108
+ # close (cancelling one leaves the socket's read registration armed —
109
+ # tonio contract), so its read completes naturally and is dispatched by
110
+ # `_exchange_active`: while idle it enforces the idle rules; once an
111
+ # exchange has started, the bytes that complete it ARE the response's
112
+ # first bytes, handed to `_read_head` via `_watcher_data`/`_watcher_error`
113
+ # — exactly hyper's structure, where one poll loop owns the read across
114
+ # the idle->busy transition. A bare task via `spawn_without_results`
115
+ # (the cancel-free seam primitive — a scope is for groups that DO get
116
+ # cancelled, like the body writer's): the handle IS the per-idle-period
117
+ # join, awaited by `_read_head` (handoff) or a teardown path.
118
+ self._watcher_handle = None
119
+ self._watcher_data = None
120
+ self._watcher_error = None
121
+ self._exchange_active = False
122
+ # Guards the take-ownership pop of the background-task handles
123
+ # (`_writer_scope`, `_watcher_handle`): a `close()` from another thread
124
+ # (tonio is work-stealing) can race the exchange's own join/teardown
125
+ # for the SAME handle — a second `Scope.__aexit__` raises RuntimeError
126
+ # (tonio `_exit`), spawn handles are await-once, and the handoff-field
127
+ # consumption must have one owner — the pop under the lock makes the
128
+ # loser see None. Held only for the sync swap, never across an await.
129
+ # Everything else the watcher shares is lock-free by construction: the
130
+ # handoff fields are published by the join, the handles/flag resets by
131
+ # the slot semaphore, and the `_exchange_active` read by the runtime's
132
+ # causal chain (flag-set -> head write -> peer response -> poller wake).
133
+ self._scopes_lock = threading.Lock()
90
134
 
91
135
  async def connect(self):
92
- pass # HTTP/1 has no connection preface / handshake (unlike h2)
136
+ # HTTP/1 has no connection preface / handshake (unlike h2), but hyper's
137
+ # `Connection` future is polled from the moment it is spawned — the
138
+ # dial-to-first-send window has read interest upstream, so it is watched
139
+ # here too.
140
+ self._start_watcher()
93
141
 
94
142
  async def _teardown_writer(self, *, cancel):
95
143
  """Finish with the in-flight body writer: `cancel=True` aborts it (still
96
144
  running — an early response we didn't wait out), then joins; `cancel=False`
97
- just joins an already-finished writer (instant). Idempotent."""
98
- scope = self._writer_scope
145
+ just joins an already-finished writer (instant). Idempotent, and
146
+ single-owner via the pop under `_scopes_lock`: a `close()` from another
147
+ thread can race the exchange's own teardown for the SAME scope, and a
148
+ second `Scope.__aexit__` raises RuntimeError (tonio `_exit`) — the
149
+ losing popper sees None and returns instead."""
150
+ with self._scopes_lock:
151
+ scope, self._writer_scope, self._writer_done = self._writer_scope, None, None
99
152
  if scope is None:
100
153
  return
101
- self._writer_scope = self._writer_done = None
102
154
  if cancel:
103
155
  scope.cancel()
104
156
  await scope.__aexit__(None, None, None)
105
157
 
158
+ def _start_watcher(self):
159
+ """Arm the idle watcher (see `_watch_idle`) for one idle period — SYNC:
160
+ the bare-task spawn suspends nothing, so callers have no interruption
161
+ window. They guarantee no exchange can be reading: `connect()` runs
162
+ before any request, and `release_slot` starts it BEFORE releasing the
163
+ slot — tonio is work-stealing, so a waiter resumed by the release can
164
+ run in parallel immediately, and the semaphore release/acquire is the
165
+ happens-before edge that guarantees it observes the watcher and takes
166
+ its read as the response's first read (F55 single-reader). No-op if the
167
+ connection can serve no more requests."""
168
+ if self._closed or self.error is not None or self.transport is None:
169
+ return
170
+ self._exchange_active = False
171
+ self._watcher_data = self._watcher_error = None
172
+ self._watcher_handle = self.backend.spawn_without_results(self._watch_idle())
173
+
174
+ async def _join_watcher(self):
175
+ """JOIN the idle watcher (never cancel/signal it — see `_watch_idle`):
176
+ await its handle, which resolves when its single read has completed
177
+ and publishes the handoff fields. Idempotent; instant when none is
178
+ armed or it already finished. On teardown paths the transport is
179
+ closed first, which is what ends the parked read. The handle is taken
180
+ via a pop under `_scopes_lock` so a racing `close()` and exchange join
181
+ have one owner (handles are await-once); the loser sees None and
182
+ returns — the connection state it then observes is force-closed either
183
+ way."""
184
+ with self._scopes_lock:
185
+ handle, self._watcher_handle = self._watcher_handle, None
186
+ if handle is None:
187
+ return
188
+ await handle
189
+
190
+ async def _watch_idle(self):
191
+ """ONE read, dispatched by connection state when it completes; the
192
+ spawn handle `_join_watcher` awaits resolves on exit. The
193
+ watcher is never cancelled or woken by a signal — a parked
194
+ `receive_some` legitimately ends only via data, EOF, or transport close
195
+ (cancellation would leave the socket's read registration armed for a
196
+ dead task, corrupting the next read). This is also hyper's structure:
197
+ the same poll loop owns the read across the idle->busy transition
198
+ (dispatch.rs L287 `poll_read` -> `poll_read_keep_alive` while idle, the
199
+ response read once busy), so there is no reader handoff to cancel.
200
+
201
+ Dispatch of the completed read (`_exchange_active` is set by
202
+ `send_request` before it writes):
203
+
204
+ - exchange active -> these are the response's first bytes (or `b""` =
205
+ EOF before the head, or a transport error): store them in
206
+ `_watcher_data`/`_watcher_error` for `_read_head`, touch nothing else
207
+ — the exchange's own paths handle errors exactly as if it had done
208
+ this read itself.
209
+ - idle EOF -> hyper's CLEAN idle close: "found EOF on idle connection,
210
+ closing" (conn.rs L471-481; `should_error_on_eof` is false when idle —
211
+ conn.rs L201-204). `_closed` only, NO error recorded: a pool discards
212
+ silently. The transport is closed NOW — hyper's connection task winds
213
+ down and drops its io (dispatch.rs L152-160) — so the socket never
214
+ parks in CLOSE_WAIT.
215
+ - idle bytes -> `new_unexpected_message` (conn.rs L484-489): poison +
216
+ close. Bytes racing `send_request`'s flag-set land in the same window
217
+ hyper has between `require_empty_read` and the head write — either
218
+ side of it, behavior matches upstream.
219
+ - idle transport error -> fail (hyper `force_io_read` io error ->
220
+ `state.close()`, conn.rs L510-520) — unless a concurrent `close()`
221
+ already committed the flags (its transport close is what woke us)."""
222
+ transport = self.transport
223
+ if transport is None: # a racing close() nulled it before we ran (F59)
224
+ return
225
+ try:
226
+ data = await transport.receive_some(65536)
227
+ except Exception as exc:
228
+ if self._exchange_active:
229
+ self._watcher_error = exc # surfaced by _read_head's first read
230
+ elif not self._closed:
231
+ self._fail(exc)
232
+ return
233
+ if self._exchange_active:
234
+ self._watcher_data = data # the response's first bytes (b"" = EOF)
235
+ return
236
+ if data:
237
+ self.poison_unexpected(len(data))
238
+ return
239
+ self._closed = True
240
+ self._close_transport()
241
+
106
242
  def _acquire(self):
107
243
  return self._slot.acquire() # blocks until the single in-flight slot is free
108
244
 
@@ -123,27 +259,47 @@ class Connection(H1ConnectionBase):
123
259
  # modeled by the 1-permit slot.
124
260
  await self._acquire()
125
261
  self._busy = True
262
+ # The watcher may have observed EOF/bytes and closed/poisoned the
263
+ # connection while `_acquire` waited — the mirror of
264
+ # `SendRequest::poll_ready` failing once the connection task died
265
+ # (client/conn/http1.rs L156-180). `request_unsent` mirrors hyper
266
+ # handing the request back when the dispatch channel refuses it —
267
+ # `TrySendError { message: Some(req) }`, client/conn/http1.rs L247-263
268
+ # — and a queued-never-taken request canceled on a connection error
269
+ # (the idle-bytes poison, proto/h1/dispatch.rs L711-733): nothing was
270
+ # written, so the caller may safely retry ANY body, streamed included.
126
271
  if self._closed or self.error is not None:
127
272
  self._busy = False
128
273
  self._slot.release()
129
- raise self.error or ConnectionClosedError("connection closed")
130
- # hyper's always-polled Connection validates the read buffer is empty before it
131
- # will send (require_empty_read -> new_unexpected_message, conn.rs L463-465; its
132
- # regression test client_read_bytes_before_writing_request): a server may not
133
- # send anything before the client's next request. Lacking a background read task
134
- # to notice bytes arriving while idle, poll once here, just before writing — any
135
- # bytes waiting are a protocol violation, so poison + fail rather than write a
136
- # request that would misparse them as its response (F31, problem b). (An idle
137
- # peer *close* still can't be told apart from "no data ready" by a non-blocking
138
- # read, so it surfaces only as the write below failing — a known limitation
139
- # short of hyper's always-polled connection task.)
274
+ exc = self.error or ConnectionClosedError("connection closed")
275
+ exc.request_unsent = True
276
+ raise exc
277
+ # `require_empty_read`'s buffered-bytes fast path at send time (conn.rs
278
+ # L463-465; regression test client_read_bytes_before_writing_request):
279
+ # a server may not send anything before the client's next request, and
280
+ # hyper's single poll loop serializes this check ahead of the write even
281
+ # under scheduler starvation — bytes ALREADY DELIVERED when a request is
282
+ # submitted are always a violation, never that request's "response". A
283
+ # parked watcher can't reproduce that ordering (its wake races this
284
+ # task), so the check runs here too, BEFORE `_exchange_active` is set —
285
+ # the ordering that makes it safe beside a parked watcher: if it steals
286
+ # the violation bytes, the watcher's spurious wake (empty read / closed
287
+ # transport) still dispatches through the idle rules against an
288
+ # already-poisoned connection and touches nothing (F31, problem b).
140
289
  pending = self.backend.receive_nowait(self.transport, 65536)
141
290
  if pending:
142
- self.poison_unexpected(len(pending))
143
- self._fail(self.error) # the stream is corrupt — close, don't reuse
291
+ self.poison_unexpected(len(pending)) # records the error AND closes
144
292
  self._busy = False
145
293
  self._slot.release()
294
+ self.error.request_unsent = True
146
295
  raise self.error
296
+ # The exchange starts NOW: the watcher is not stopped (its parked read
297
+ # is never cancelled — see `_watch_idle`); this sync flag redirects its
298
+ # completing read from the idle rules to the exchange, whose `_read_head`
299
+ # joins it and takes the bytes as the response's first read. Bytes
300
+ # arriving from here to the head write are hyper's own race window
301
+ # (require_empty_read ran; the write turn follows).
302
+ self._exchange_active = True
147
303
  try:
148
304
  codec = H1Codec()
149
305
  content_length, chunked = self._body_framing(body)
@@ -199,6 +355,12 @@ class Connection(H1ConnectionBase):
199
355
  write_error = []
200
356
  scope = self.backend.scope()
201
357
  await scope.__aenter__()
358
+ # The `request_unsent` boundary: this spawn is httpunk's analogue of
359
+ # hyper's dispatcher taking the request off the channel (dispatch.rs
360
+ # `poll_msg`). From here on a failure NEVER carries the marker — in
361
+ # hyper, once taken, errors return `message: None` even if no byte
362
+ # reached the wire (an encoded-but-unflushed head is not handed
363
+ # back), and this boundary mirrors that exactly.
202
364
  scope.spawn(self._write_request(codec, head, body, body_done, write_error, body_failed, trailers))
203
365
  self._writer_scope, self._writer_done = scope, body_done
204
366
  try:
@@ -223,6 +385,11 @@ class Connection(H1ConnectionBase):
223
385
  # the caller owns; this driver won't touch the transport again
224
386
  # (hyper `on_upgrade` / `Connection::into_parts`).
225
387
  await self._teardown_writer(cancel=True) # the request-body write is moot
388
+ # No idle watcher can be running here: `_read_head` consumed it
389
+ # (its read delivered this 101 head's first bytes) and one is
390
+ # only re-armed by `release_slot` on a reuse verdict, which the
391
+ # upgrade path never reaches — a detached transport is never
392
+ # watched.
226
393
  upgraded = H1Upgraded(self.transport, codec.take_body())
227
394
  self._detach()
228
395
  self._busy = False
@@ -247,6 +414,10 @@ class Connection(H1ConnectionBase):
247
414
  self._fail(exc) # sync poison BEFORE the teardown suspension (see release_slot)
248
415
  try:
249
416
  await self._teardown_writer(cancel=True)
417
+ # A watcher not yet consumed by `_read_head` (failure before it
418
+ # ran): `_fail` closed the transport, which is what ends its
419
+ # parked read — join it (never cancel).
420
+ await self._join_watcher()
250
421
  finally:
251
422
  self._busy = False
252
423
  self._slot.release()
@@ -283,8 +454,33 @@ class Connection(H1ConnectionBase):
283
454
  # hyper: conn.rs `can_read_head` (L175) + `read_head` -> role.rs
284
455
  # `Client::parse` (L1013), which loops past 1xx informational responses.
285
456
  buffered = 0
457
+ data = None
458
+ if self._watcher_handle is not None:
459
+ # An idle watcher is armed: its parked `receive_some` is the
460
+ # connection's single reader (F55), so the response's FIRST read is
461
+ # its completing read — join it and take the handoff (bytes, `b""`
462
+ # EOF, or a transport error re-raised here, exactly as if this task
463
+ # had done the read). Only after the join does this task own the
464
+ # transport. Hyper needs no join: one poll loop does both reads.
465
+ await self._join_watcher()
466
+ exc, self._watcher_error = self._watcher_error, None
467
+ if exc is not None:
468
+ raise exc
469
+ data, self._watcher_data = self._watcher_data, None
470
+ if data is None:
471
+ # The watcher exited without a handoff: it dispatched through
472
+ # the IDLE rules against this exchange — either a racing
473
+ # close() tore the connection down under us, or bytes/EOF
474
+ # landed concurrently with the exchange start and the watcher
475
+ # saw the flag before this task's write of it was visible
476
+ # (free-threaded: the junk race has no causal ordering, both
477
+ # dispatches are legitimate). Surface the recorded verdict —
478
+ # the unexpected-bytes poison (hyper `new_unexpected_message`)
479
+ # or the clean close — not a generic error that would mask it.
480
+ raise self.error or ConnectionClosedError("connection closed")
286
481
  while True:
287
- data = await self.transport.receive_some(65536)
482
+ if data is None:
483
+ data = await self.transport.receive_some(65536)
288
484
  if not data:
289
485
  # EOF before a full head. If the body write also failed (server
290
486
  # closed both directions), surface that as the cause.
@@ -303,6 +499,7 @@ class Connection(H1ConnectionBase):
303
499
  # just fails the connection (hyper `Parse::TooLarge`).
304
500
  if buffered >= _MAX_HEAD_SIZE:
305
501
  raise ValueError("response head too large (Parse::TooLarge)")
502
+ data = None
306
503
 
307
504
  async def _await_body_failure(self, body_failed, write_error):
308
505
  # Raced against `_read_head` (once per request, in send_request): if the request
@@ -315,13 +512,19 @@ class Connection(H1ConnectionBase):
315
512
  raise write_error[0]
316
513
 
317
514
  def poison_unexpected(self, nbytes):
318
- """The server sent `nbytes` unsolicited bytes past the response body — an
515
+ """The server sent `nbytes` unsolicited bytes outside a response — an
319
516
  HTTP/1 protocol violation (a server may not send anything before the next
320
- request). hyper's client fails the connection here via `require_empty_read`
321
- -> `new_unexpected_message` (conn.rs L463-465); we record the error so the
322
- next `send_request`/`wait_idle` raises it (the slot release closes it)."""
517
+ request). hyper's client fails the connection via `require_empty_read` ->
518
+ `new_unexpected_message` (conn.rs L463-465 buffered, L484-489 read) and
519
+ the errored connection task then winds down, dropping its io
520
+ (dispatch.rs `poll_catch` L123-141) — so record the error AND close, for
521
+ every caller (the idle watcher, the send-time guard, the past-body
522
+ leftover check in share.py). The next `send_request`/`wait_idle` raises
523
+ the recorded error."""
323
524
  if self.error is None:
324
525
  self.error = ValueError(f"received {nbytes} unexpected bytes on an idle HTTP/1 connection")
526
+ self._closed = True
527
+ self._close_transport()
325
528
 
326
529
  async def release_slot(self, resp_keep_alive):
327
530
  """Free the in-flight slot once the caller has finished the response. Reuse
@@ -341,24 +544,38 @@ class Connection(H1ConnectionBase):
341
544
  poisoned, and on reuse the exchange genuinely completed, so freeing the
342
545
  slot is correct even if the writer join was cut short."""
343
546
  fully_sent = self._writer_done is None or self._writer_done.is_set()
344
- if not (resp_keep_alive and fully_sent):
547
+ reuse = resp_keep_alive and fully_sent
548
+ if not reuse:
345
549
  self._closed = True
346
550
  self._close_transport() # sync by design (backend.close_transport)
347
551
  try:
348
552
  await self._teardown_writer(cancel=not fully_sent)
553
+ if reuse:
554
+ # The connection is reusable-idle again: restore hyper's idle
555
+ # read interest. Started BEFORE the slot release below (see
556
+ # `_start_watcher` — the semaphore is the visibility edge for a
557
+ # parallel waiter); `_start_watcher` no-ops if a racing
558
+ # close()/failure landed during the writer join.
559
+ self._start_watcher()
349
560
  finally:
350
561
  self._busy = False
351
562
  self._slot.release()
352
563
 
353
564
  async def close(self):
354
565
  # Commit `_closed` + close the transport FIRST (the base close is sync —
355
- # no suspension), then abort + join a still-running background writer.
356
- # The join can be interrupted (it suspends); a force-close must never
357
- # come away with `closed` still False.
566
+ # no suspension): the transport close is what ends the idle watcher's
567
+ # parked read (it sees `_closed` and exits quietly) — then join it and
568
+ # abort + join a still-running background writer. The joins can be
569
+ # interrupted (they suspend); a force-close must never come away with
570
+ # `closed` still False.
358
571
  await super().close()
572
+ await self._join_watcher()
359
573
  await self._teardown_writer(cancel=True)
360
574
 
361
575
  def _fail(self, exc):
576
+ # Sync poison + close. No live watcher to stop here: `_fail` runs only
577
+ # inside an exchange (slot held, watcher joined at send_request entry)
578
+ # or from the watcher itself.
362
579
  if self.error is None and not isinstance(exc, ConnectionClosedError):
363
580
  self.error = exc
364
581
  self._closed = True
@@ -412,5 +629,12 @@ class H1Connection(BaseClientConnection):
412
629
  absolute-form for a proxy, and an authority (``"host:port"``) is
413
630
  authority-form for CONNECT. The caller supplies the ``Host`` header (we
414
631
  never auto-add it), exactly like hyper's `client::conn::http1`.
632
+
633
+ A failure raised before the request was handed to the writer carries
634
+ ``request_unsent = True`` on the exception: nothing reached the wire, so
635
+ the request — any body, streamed included — is safe to retry. The mirror
636
+ of hyper handing the request back in `TrySendError { message: Some }`
637
+ (client/conn/http1.rs L247-263; proto/h1/dispatch.rs L711-733). Absent
638
+ (falsy) once the write may have begun.
415
639
  """
416
640
  return self._conn.send_request(request.method, request.target, request.headers, request.body, request.trailers)