httpunk 0.4.2__tar.gz → 0.4.4__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 (144) hide show
  1. {httpunk-0.4.2 → httpunk-0.4.4}/Cargo.lock +42 -1
  2. {httpunk-0.4.2 → httpunk-0.4.4}/Cargo.toml +4 -7
  3. {httpunk-0.4.2 → httpunk-0.4.4}/PKG-INFO +2 -2
  4. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/_backend/asyncio.py +13 -0
  5. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/_backend/tonio.py +94 -114
  6. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/_common.py +0 -10
  7. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h1/client.py +4 -1
  8. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h1/server.py +8 -7
  9. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h2/connection.py +11 -10
  10. {httpunk-0.4.2 → httpunk-0.4.4}/pyproject.toml +1 -1
  11. {httpunk-0.4.2 → httpunk-0.4.4}/src/lib.rs +4 -0
  12. httpunk-0.4.4/tests/_transport.py +24 -0
  13. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_asyncio_backend.py +40 -0
  14. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_asyncio_e2e.py +4 -3
  15. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_asyncio_protocol.py +2 -1
  16. httpunk-0.4.4/tests/test_backend.py +430 -0
  17. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h1_server.py +2 -3
  18. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h2_errors.py +10 -9
  19. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_util_auto.py +2 -3
  20. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_util_graceful.py +2 -3
  21. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_util_tls.py +42 -0
  22. httpunk-0.4.2/tests/_transport.py +0 -24
  23. httpunk-0.4.2/tests/test_backend.py +0 -270
  24. {httpunk-0.4.2 → httpunk-0.4.4}/LICENSE +0 -0
  25. {httpunk-0.4.2 → httpunk-0.4.4}/README.md +0 -0
  26. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/Cargo.toml +0 -0
  27. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/LICENSE +0 -0
  28. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
  29. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/codec/error.rs +0 -0
  30. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/codec/mod.rs +0 -0
  31. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/error.rs +0 -0
  32. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/ext.rs +0 -0
  33. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/data.rs +0 -0
  34. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/go_away.rs +0 -0
  35. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/head.rs +0 -0
  36. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/headers.rs +0 -0
  37. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/mod.rs +0 -0
  38. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/ping.rs +0 -0
  39. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/priority.rs +0 -0
  40. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/reason.rs +0 -0
  41. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/reset.rs +0 -0
  42. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/settings.rs +0 -0
  43. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
  44. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/util.rs +0 -0
  45. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/frame/window_update.rs +0 -0
  46. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
  47. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
  48. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/hpack/header.rs +0 -0
  49. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
  50. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
  51. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/hpack/mod.rs +0 -0
  52. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/hpack/table.rs +0 -0
  53. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/lib.rs +0 -0
  54. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/proto/error.rs +0 -0
  55. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/proto/mod.rs +0 -0
  56. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
  57. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
  58. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
  59. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/Cargo.toml +0 -0
  60. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/LICENSE +0 -0
  61. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
  62. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/body/length.rs +0 -0
  63. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/body/mod.rs +0 -0
  64. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/cfg.rs +0 -0
  65. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/common/date.rs +0 -0
  66. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/common/mod.rs +0 -0
  67. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/error.rs +0 -0
  68. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
  69. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/ext/informational.rs +0 -0
  70. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/ext/mod.rs +0 -0
  71. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/headers.rs +0 -0
  72. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/lib.rs +0 -0
  73. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
  74. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
  75. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
  76. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
  77. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
  78. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
  79. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/proto/mod.rs +0 -0
  80. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper/src/trace.rs +0 -0
  81. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper-util/Cargo.toml +0 -0
  82. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper-util/LICENSE +0 -0
  83. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
  84. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
  85. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
  86. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
  87. {httpunk-0.4.2 → httpunk-0.4.4}/crates/vendor-hyper-util/src/lib.rs +0 -0
  88. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/__init__.py +0 -0
  89. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/_backend/__init__.py +0 -0
  90. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/_httpunk.pyi +0 -0
  91. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/asyncio.py +0 -0
  92. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/exceptions.py +0 -0
  93. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h1/__init__.py +0 -0
  94. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h1/connection.py +0 -0
  95. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h1/share.py +0 -0
  96. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h2/__init__.py +0 -0
  97. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h2/client.py +0 -0
  98. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h2/server.py +0 -0
  99. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h2/share.py +0 -0
  100. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/h2/stream.py +0 -0
  101. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/http.py +0 -0
  102. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/py.typed +0 -0
  103. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/types.py +0 -0
  104. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/util/__init__.py +0 -0
  105. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/util/auto.py +0 -0
  106. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/util/client.py +0 -0
  107. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/util/graceful.py +0 -0
  108. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/util/pool.py +0 -0
  109. {httpunk-0.4.2 → httpunk-0.4.4}/httpunk/util/proxy.py +0 -0
  110. {httpunk-0.4.2 → httpunk-0.4.4}/src/errors.rs +0 -0
  111. {httpunk-0.4.2 → httpunk-0.4.4}/src/h1/codec.rs +0 -0
  112. {httpunk-0.4.2 → httpunk-0.4.4}/src/h1/conn.rs +0 -0
  113. {httpunk-0.4.2 → httpunk-0.4.4}/src/h1/errors.rs +0 -0
  114. {httpunk-0.4.2 → httpunk-0.4.4}/src/h1/mod.rs +0 -0
  115. {httpunk-0.4.2 → httpunk-0.4.4}/src/h2/codec.rs +0 -0
  116. {httpunk-0.4.2 → httpunk-0.4.4}/src/h2/conn.rs +0 -0
  117. {httpunk-0.4.2 → httpunk-0.4.4}/src/h2/errors.rs +0 -0
  118. {httpunk-0.4.2 → httpunk-0.4.4}/src/h2/mod.rs +0 -0
  119. {httpunk-0.4.2 → httpunk-0.4.4}/src/h2/reason.rs +0 -0
  120. {httpunk-0.4.2 → httpunk-0.4.4}/src/h2/settings.rs +0 -0
  121. {httpunk-0.4.2 → httpunk-0.4.4}/src/h2/streams.rs +0 -0
  122. {httpunk-0.4.2 → httpunk-0.4.4}/src/http/mod.rs +0 -0
  123. {httpunk-0.4.2 → httpunk-0.4.4}/src/proxy.rs +0 -0
  124. {httpunk-0.4.2 → httpunk-0.4.4}/src/py.rs +0 -0
  125. {httpunk-0.4.2 → httpunk-0.4.4}/tests/_client.py +0 -0
  126. {httpunk-0.4.2 → httpunk-0.4.4}/tests/conftest.py +0 -0
  127. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_asyncio_client_protocol.py +0 -0
  128. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_error_retention.py +0 -0
  129. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h1_codec.py +0 -0
  130. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h1_conn.py +0 -0
  131. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h1_decode.py +0 -0
  132. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h2_codec.py +0 -0
  133. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h2_conn.py +0 -0
  134. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h2_flow.py +0 -0
  135. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h2_flow_control.py +0 -0
  136. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h2_ready.py +0 -0
  137. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h2_server.py +0 -0
  138. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h2_settings.py +0 -0
  139. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h2_state.py +0 -0
  140. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_h2_write_batch.py +0 -0
  141. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_headermap.py +0 -0
  142. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_util_connect.py +0 -0
  143. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_util_pool.py +0 -0
  144. {httpunk-0.4.2 → httpunk-0.4.4}/tests/test_util_proxy.py +0 -0
@@ -20,12 +20,28 @@ version = "1.12.1"
20
20
  source = "registry+https://github.com/rust-lang/crates.io-index"
21
21
  checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
22
22
 
23
+ [[package]]
24
+ name = "cc"
25
+ version = "1.4.7"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
28
+ dependencies = [
29
+ "find-msvc-tools",
30
+ "shlex",
31
+ ]
32
+
23
33
  [[package]]
24
34
  name = "equivalent"
25
35
  version = "1.0.2"
26
36
  source = "registry+https://github.com/rust-lang/crates.io-index"
27
37
  checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
28
38
 
39
+ [[package]]
40
+ name = "find-msvc-tools"
41
+ version = "0.1.13"
42
+ source = "registry+https://github.com/rust-lang/crates.io-index"
43
+ checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
44
+
29
45
  [[package]]
30
46
  name = "fnv"
31
47
  version = "1.0.7"
@@ -118,12 +134,13 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
118
134
 
119
135
  [[package]]
120
136
  name = "httpunk"
121
- version = "0.4.2"
137
+ version = "0.4.4"
122
138
  dependencies = [
123
139
  "bytes",
124
140
  "h2",
125
141
  "http",
126
142
  "hyper",
143
+ "mimalloc",
127
144
  "pyo3",
128
145
  "vendor-h2",
129
146
  "vendor-hyper",
@@ -179,6 +196,24 @@ version = "0.2.189"
179
196
  source = "registry+https://github.com/rust-lang/crates.io-index"
180
197
  checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
181
198
 
199
+ [[package]]
200
+ name = "libmimalloc-sys"
201
+ version = "0.1.49"
202
+ source = "registry+https://github.com/rust-lang/crates.io-index"
203
+ checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
204
+ dependencies = [
205
+ "cc",
206
+ ]
207
+
208
+ [[package]]
209
+ name = "mimalloc"
210
+ version = "0.1.52"
211
+ source = "registry+https://github.com/rust-lang/crates.io-index"
212
+ checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
213
+ dependencies = [
214
+ "libmimalloc-sys",
215
+ ]
216
+
182
217
  [[package]]
183
218
  name = "once_cell"
184
219
  version = "1.21.4"
@@ -278,6 +313,12 @@ dependencies = [
278
313
  "proc-macro2",
279
314
  ]
280
315
 
316
+ [[package]]
317
+ name = "shlex"
318
+ version = "2.0.1"
319
+ source = "registry+https://github.com/rust-lang/crates.io-index"
320
+ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
321
+
281
322
  [[package]]
282
323
  name = "slab"
283
324
  version = "0.4.12"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "httpunk"
3
- version = "0.4.2"
3
+ version = "0.4.4"
4
4
  description = "The Rust HTTP library for Python"
5
5
  authors = [
6
6
  "Giovanni Barillari <g@baro.dev>",
@@ -39,22 +39,19 @@ name = "_httpunk"
39
39
  crate-type = ["cdylib"]
40
40
 
41
41
  [features]
42
+ mimalloc = ["dep:mimalloc"]
42
43
  # Never enabled. Exists only so the optional crates.io `h2`/`hyper` deps are
43
44
  # referenced (no "unused optional dependency"), keeping them in Cargo.lock for
44
45
  # advisory scanning while they stay uncompiled. See the dependency comments.
45
46
  _audit = ["dep:h2", "dep:hyper"]
46
47
 
47
48
  [dependencies]
48
- pyo3 = { version = "=0.29", features = ["extension-module", "generate-import-lib"] }
49
- # Used by the PyO3 layer (src/py): `http::HeaderMap` (the HeaderMap wrapper) and
50
- # `bytes` buffers in the codecs.
51
49
  bytes = "1"
52
50
  http = "1.1"
53
- # The vendored crates, as workspace members (own their crate roots → byte-identical
54
- # to upstream, and fmt/clippy-excludable via `cargo fmt -p _httpunk`).
51
+ mimalloc = { version = "0.1.49", default-features = false, features = ["local_dynamic_tls"], optional = true }
52
+ pyo3 = { version = "=0.29", features = ["extension-module", "generate-import-lib"] }
55
53
  vendor-h2 = { path = "crates/vendor-h2" }
56
54
  vendor-hyper = { path = "crates/vendor-hyper", features = ["client", "server", "http1"] }
57
- # Vendored hyper-util proxy matcher (pure sans-IO selection logic; `from_system` off).
58
55
  vendor-hyper-util = { path = "crates/vendor-hyper-util" }
59
56
  # NOT compiled: the crates.io `h2`/`hyper` — vendored into crates/vendor-*/ (see
60
57
  # THIRD-PARTY.md). Optional + gated behind the never-enabled `_audit` feature so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpunk
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: BSD License
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Classifier: Programming Language :: Python
21
21
  Classifier: Programming Language :: Rust
22
22
  Classifier: Topic :: Internet :: WWW/HTTP
23
- Requires-Dist: tonio~=0.9.17 ; python_full_version >= '3.14' and extra == 'tonio'
23
+ Requires-Dist: tonio~=0.10.0 ; python_full_version >= '3.14' and extra == 'tonio'
24
24
  Provides-Extra: tonio
25
25
  License-File: LICENSE
26
26
  Summary: The Rust HTTP library for Python
@@ -445,6 +445,19 @@ class AsyncioBackend:
445
445
  await asyncio.gather(*losers, return_exceptions=True)
446
446
  return winner.result()
447
447
 
448
+ async def select_events(self, *events):
449
+ """Resume once ANY of `events` is set (see the tonio twin): one `asyncio.wait`
450
+ over the events' `wait()` futures, the losers cancelled and drained on the way
451
+ out — also when this wait is itself cancelled. No verdict: the caller reads the
452
+ events' flags."""
453
+ waits = [asyncio.ensure_future(e.wait()) for e in events]
454
+ try:
455
+ await asyncio.wait(waits, return_when=asyncio.FIRST_COMPLETED)
456
+ finally:
457
+ for w in waits:
458
+ w.cancel()
459
+ await asyncio.gather(*waits, return_exceptions=True)
460
+
448
461
  def queue(self):
449
462
  """An unbounded queue as `(sender, receiver)`: `sender.send(x)` is sync;
450
463
  `await receiver.receive()` yields items in order."""
@@ -7,8 +7,8 @@ across coroutines — so a future asyncio/trio backend is a drop-in replacement.
7
7
 
8
8
  import ssl as _ssl
9
9
 
10
- from tonio import colored as _colored
11
- from tonio._net._tls import _is_eof # private: the TLS clean-EOF test `TLSStream.receive_some` applies
10
+ from tonio import Waiter as _Waiter, colored as _colored
11
+ from tonio._tonio import get_runtime as _get_runtime # private: the runtime's µs clock (`_clock`), see `bounded_reader`
12
12
  from tonio.colored.net import SocketStream as _SocketStream, open_tcp_stream as _open_tcp_stream
13
13
  from tonio.colored.net.tls import TLSStream as _TLSStream, open_tls_over_tcp_stream as _open_tls_over_tcp_stream
14
14
  from tonio.colored.sync import Lock as _Lock, Semaphore as _Semaphore
@@ -100,140 +100,114 @@ class TonioBackend:
100
100
  return tls, tls._ssl.selected_alpn_protocol()
101
101
 
102
102
  def receive_nowait(self, transport, max_bytes=65536):
103
- """A synchronous, non-blocking read: whatever bytes are immediately
104
- available without suspending, `b""` at EOF, or `None` if nothing is ready
105
- right now. The readiness primitive hyper's server drain (`poll_read_body`
106
- inside `poll_drain_or_close_read`) relies on. EOF and not-ready are distinct.
107
-
108
- - **Plain socket**: tonio's sockets are non-blocking under the hood (its own
109
- `recv` does exactly this `_sock.recv` inline before ever suspending), so
110
- we read the raw socket directly rather than route through the timer-backed
111
- `timeout(..., 0)`, which cannot express an instantaneous peek.
112
- - **TLS (`TLSStream`)**: the raw socket carries *ciphertext*, so reading it
113
- would bypass decryption (and a `TLSStream` has no `.socket` anyway). The
114
- non-blocking-plaintext equivalent is the SSLObject's already-decrypted
115
- buffer: `pending()` bytes can be `read()` without touching the BIO/socket.
116
- tonio's `_SSLProxy` serialises EVERY use of its `SSLObject` — the parked
117
- reader's `_read` included — under one `threading.Lock`; the peek takes that
118
- same lock (private API, tonio and httpunk share an author) so its
119
- pending-then-read is ONE step against a reader decrypting on another thread,
120
- never a `read()` of bytes the reader just consumed.
121
- (Necessarily conservative — tonio exposes no non-blocking "decrypt more",
122
- so unread ciphertext on the socket reads as "nothing ready"; the drain
123
- then closes rather than reuses, which matches hyper's cheap-drain-or-close.
124
- EOF is never reported for TLS either — it is only knowable by decrypting.)
125
- - A peek beside a parked plain-socket reader is safe: both are one `recv`
126
- syscall on the same non-blocking socket, the bytes go to exactly one of
127
- them, and the loser's `EAGAIN` sends it back to waiting (tonio `recv`)."""
128
- if isinstance(transport, _TLSStream): # peek only already-decrypted plaintext
129
- ssl_obj = transport._ssl
130
- with ssl_obj._lock: # tonio `_SSLProxy._lock`: the SSLObject's one lock
131
- pending = ssl_obj._inner.pending()
132
- return ssl_obj._inner.read(min(max_bytes, pending)) if pending else None
133
- try:
134
- return transport.socket._sock.recv(max_bytes) # b"" only at EOF
135
- except (BlockingIOError, InterruptedError):
136
- return None
103
+ """A synchronous, non-blocking read: whatever bytes are immediately available
104
+ without suspending, `b""` at EOF, or `None` if nothing is ready right now. The
105
+ readiness primitive hyper's server drain (`poll_read_body` inside
106
+ `poll_drain_or_close_read`) relies on. EOF and not-ready are distinct.
107
+
108
+ tonio's own no-wait read on both streams (`receive_some_nowait`: `NotReady` is
109
+ this seam's `None`). On a plain socket it is one `recv` (EAGAIN clears the
110
+ readiness bits, as tonio's `recv` does). Over TLS it decrypts what the socket
111
+ already holds — feeds the ingress BIO without suspending — the way hyper's read
112
+ over rustls does, so the server's drain finds a small unread body instead of
113
+ closing; a close_notify already in the socket is `b""`, an abrupt close raises
114
+ `ResourceBroken` (a `broken_transport_errors` shape, like the plain socket's
115
+ `ConnectionResetError`). Beside a parked reader: on a plain socket both are one
116
+ `recv` on the same non-blocking socket and the bytes go to exactly one of them;
117
+ over TLS the parked reader holds the receive lock, so the peek answers `None`
118
+ whatever the socket holds — hyper's `require_empty_read` looks at its `read_buf`
119
+ the same way, and ciphertext not yet decrypted is invisible to it too."""
120
+ data = transport.receive_some_nowait(max_bytes)
121
+ return None if data is transport.NotReady else data
137
122
 
138
123
  def bounded_reader(self, transport):
139
124
  """Chosen ONCE per connection: how `transport`'s reads are bounded by a deadline —
140
125
  the h1 server's head-read deadline (hyper `header_read_timeout`) riding the read
141
126
  itself, so no task is spawned per head and no parked read is ever cancelled.
142
127
  Returns `async read(max_bytes, deadline) -> bytes | b"" (EOF) | None (expired)`;
143
- `deadline` is an instant on this backend's `monotonic` clock. The transport's kind
144
- is fixed for the connection's life, so the dispatch happens here, never per read,
145
- and the plain-socket reader is one coroutine over bound methods resolved here —
146
- the cost of tonio's own `receive_some`, plus one arm on the (cold) timer path.
128
+ `deadline` is an instant on this backend's `monotonic` clock (seconds, the seam's
129
+ unit). The transport's kind is fixed for the connection's life, so the dispatch
130
+ happens here, never per read, over bound methods resolved here.
131
+
132
+ Both readers are tonio's own `wait_readable(timeout)` loop inlined over the
133
+ stream's no-wait read and its readiness waiter — the same primitives, without the
134
+ coroutine frame per park and without the seconds -> µs -> seconds round trip: the
135
+ deadline is converted once per read, and each park compares it with the runtime's
136
+ µs clock (`_clock`, read off the runtime cached at bind time rather than through
137
+ `get_runtime()` on every pass; private API — tonio and httpunk share an author).
147
138
 
148
139
  Bytes first, hyper's order (`poll_read_head`: `parse` -> `poll_read`, and the timer
149
- is polled only once that returned Pending): every pass tries the syscall before it
150
- looks at the clock, and a wake that finds bytes returns them, whatever the timer did.
151
-
152
- - **Plain socket**: the syscall comes first — the readiness bits are stale-set after
153
- any successful read (nothing but a failed syscall clears them), exactly as tokio's
154
- readiness word is when hyper's `poll_read` runs, so both pay one `EAGAIN` probe
155
- per head; only on `EAGAIN` (`clear_r`, as tonio's own `recv`) is the remaining time
156
- computed. Past the deadline that is the answer at once: hyper's Pending followed
157
- by a ready timer poll (`new_header_timeout`) — no zero-length timer is ever
158
- armed. Otherwise the socket's own arm (`_io_arm_r(timeout)`) puts the readiness
159
- wait and the timer on ONE suspension (a `Waiter` with a timer). Both wakes resume
160
- with `None`; the readiness word tells them apart: a readiness wake set bits
161
- before waking (`set_readiness` precedes `wake`), a timer wake set none — so a
162
- bare `_io_arm_r()` after the wake asks "readable?": `None` = bits set (read now),
163
- a waiter = the timer fired. That waiter is never awaited: a stale reader slot,
164
- overwritten by the next arm, harmless by tonio's contract. One reader per socket,
165
- and `clear_r` runs only on this reader's failed syscall, so nothing consumes the
166
- bits in between. Every arm is computed from the absolute deadline, so a spurious
167
- readiness wake (`EAGAIN` after a wake) re-arms for the remaining time.
168
- - **TLS (`TLSStream`)**: `TLSStream.receive_some` is `_ssl_dance(_read)` with
169
- `_recv` feeding the ingress BIO from the raw socket; this is that dance with the
170
- raw read bounded (private API — tonio and httpunk share an author; keep in step
171
- with `tonio/_colored/_net/_tls.py`).
140
+ is polled only once that returned Pending): every pass tries the read before it
141
+ looks at the clock, so a wake that finds bytes returns them, whatever the timer
142
+ did. The readiness bits are stale-set after any successful read (nothing but a
143
+ failed syscall clears them), exactly as tokio's readiness word is when hyper's
144
+ `poll_read` runs, so both pay one `EAGAIN` probe per head; only on `NotReady`
145
+ (which cleared the bits) is the remaining time computed. Past the deadline that is
146
+ the answer at once — hyper's Pending followed by a ready timer poll
147
+ (`new_header_timeout`) — and no zero-length timer is ever armed. Otherwise the
148
+ waiter puts the readiness wait and the timer on ONE suspension, and after the wake
149
+ the readiness question is asked BEFORE the next read: `None` = the bits are set
150
+ (read now), a waiter = none are, so the timer fired (a stale reader slot, overwritten
151
+ by the next arm, harmless by tonio's contract). That question is not optional: the
152
+ socket's `_io_clear_r` is tick-guarded — it clears the bits only if no readiness
153
+ edge landed since an arm last SAW them set — so a wake followed straight by a read
154
+ leaves the edge unobserved, the next head's `EAGAIN` clear is ignored, and that head
155
+ pays a second syscall (measured: 3 reads and 2 arms per request instead of 2 and 1).
156
+ Every arm is computed from the absolute deadline, so a wake that finds nothing to
157
+ read re-arms for what is left.
158
+
159
+ - **Plain socket**: `waiter_readable(µs)` is the socket's own arm: `None` when the
160
+ bits landed between the probe and the arm (read now), else the waiter; bare, it
161
+ is the readiness question.
162
+ - **TLS (`TLSStream`)**: `watch_readable()` holds the receive lock across the arm
163
+ and the park (as tonio's `wait_readable` does); its waiter is `None` when
164
+ plaintext is already decoded, the raw socket's arm otherwise, and the lock's own
165
+ hand-off when a parked reader holds it; `ready()` is the question (plaintext
166
+ pending, or the socket's bare arm). A "not ready" wake loops back to the clock
167
+ rather than returning: after a lock hand-off nothing was armed, so it is not a
168
+ verdict there (that hand-off never happens in httpunk — one reader per connection
169
+ — but the loop stays exact). `receive_some_nowait` decrypts.
172
170
  A transport is one of tonio's two streams — a `TLSStream` or a `SocketStream` —
173
171
  and nothing else; anything else fails here, at setup, not on a read."""
172
+ runtime = _get_runtime() # one runtime per process, bound once per connection
174
173
  if isinstance(transport, _TLSStream):
175
- return self._tls_bounded_reader(transport)
176
- return self._socket_bounded_reader(transport.socket)
174
+ return self._tls_bounded_reader(transport, runtime)
175
+ return self._socket_bounded_reader(transport, runtime)
177
176
 
178
177
  @staticmethod
179
- def _socket_bounded_reader(sock):
180
- arm, clear, recv = sock._io_arm_r, sock._io_clear_r, sock._sock.recv
178
+ def _socket_bounded_reader(stream, runtime):
179
+ nowait, arm, not_ready = stream.receive_some_nowait, stream.waiter_readable, stream.NotReady
181
180
 
182
181
  async def read(max_bytes, deadline):
183
- while True:
184
- try:
185
- return recv(max_bytes) # bytes first: hyper's `poll_read` before its timer poll
186
- except InterruptedError:
187
- continue
188
- except BlockingIOError:
189
- clear() # EAGAIN: the bits were stale (or a spurious wake) — now the timer
190
- micros = round((deadline - _now()) * 1_000_000)
191
- if micros <= 0:
182
+ deadline_micros = int(deadline * 1_000_000) # the seam's seconds -> the runtime clock's µs, once per read
183
+ while (data := nowait(max_bytes)) is not_ready: # bytes first; EAGAIN clears the bits
184
+ remaining = deadline_micros - runtime._clock
185
+ if remaining <= 0:
192
186
  return None # nothing readable and the deadline passed: hyper's timer poll is ready
193
- waiter = arm(micros)
194
- if waiter is None:
195
- continue # bits landed between the EAGAIN and the arm: read now
196
- await waiter
197
- if arm() is not None: # no readiness bits: the timer woke us
198
- return None
187
+ if (waiter := arm(remaining)) is not None: # None: bits landed between the probe and the arm
188
+ await waiter
189
+ if arm() is not None: # the readiness question: no bits = the timer woke us
190
+ return None
191
+ return data
199
192
 
200
193
  return read
201
194
 
202
195
  @staticmethod
203
- def _tls_bounded_reader(stream):
204
- ssl_obj = stream._ssl
205
- raw_read = TonioBackend._socket_bounded_reader(stream.transport.socket)
196
+ def _tls_bounded_reader(stream, runtime):
197
+ nowait, watch, not_ready = stream.receive_some_nowait, stream.watch_readable, stream.NotReady
206
198
 
207
199
  async def read(max_bytes, deadline):
208
- stream._check_ready()
209
- try:
210
- while True:
211
- try:
212
- ret, want_read, to_send = ssl_obj._read(max_bytes)
213
- except (_ssl.SSLError, _ssl.CertificateError) as exc:
214
- stream._set_broken()
215
- raise ResourceBroken from exc
216
- if to_send:
217
- await stream._send(to_send)
218
- elif want_read:
219
- recv_count = stream._recv_count
220
- async with stream._lock_recv:
221
- if recv_count == stream._recv_count: # nobody fed the BIO meanwhile
222
- data = await raw_read(65536, deadline)
223
- if data is None:
224
- return None
225
- if not data:
226
- ssl_obj._ingress_write_eof()
227
- else:
228
- stream._recv_est_size = max(stream._recv_est_size, len(data))
229
- ssl_obj._ingress_write(data)
230
- stream._recv_count += 1
231
- if not want_read:
232
- return ret
233
- except ResourceBroken as exc:
234
- if stream._compat_https and _is_eof(exc.__cause__):
235
- return b""
236
- raise
200
+ deadline_micros = int(deadline * 1_000_000)
201
+ while (data := nowait(max_bytes)) is not_ready:
202
+ remaining = deadline_micros - runtime._clock
203
+ if remaining <= 0:
204
+ return None
205
+ with watch() as watcher: # the receive lock is held across the park, as tonio's own wait
206
+ if (waiter := watcher.waiter(remaining)) is not None:
207
+ await waiter
208
+ if not watcher.ready(): # the readiness question: nothing readable, back to the clock
209
+ continue
210
+ return data
237
211
 
238
212
  return read
239
213
 
@@ -294,6 +268,12 @@ class TonioBackend:
294
268
  spawn_without_results = staticmethod(_colored.spawn.without_results)
295
269
 
296
270
  select = staticmethod(_colored.select)
271
+ # `await select_events(*events)`: resume once ANY of the events is set — ONE suspension
272
+ # of the calling task (tonio's merged waiter), no wrapper tasks, no scope, nothing to
273
+ # cancel; returns no verdict, the caller reads the events' flags. The race of "pump
274
+ # done" against "peer gone" in `_send_async_body` (h1 and h2): hyper's
275
+ # `PipeToSendStream` polling `poll_reset` beside the body future, in one task.
276
+ select_events = staticmethod(_Waiter.any)
297
277
  scope = staticmethod(_colored.scope)
298
278
  lock = _Lock
299
279
  event = _colored.Event
@@ -44,16 +44,6 @@ async def read_all(aiter):
44
44
  return b"".join([chunk async for chunk in aiter])
45
45
 
46
46
 
47
- PUMP_DONE = object() # sentinel: the async-body pump finished (`event_result`)
48
- PUMP_ABANDONED = object() # sentinel: the peer abandoned the exchange first (`event_result`)
49
-
50
-
51
- async def event_result(evt, value):
52
- """Await `evt`, then return `value` — a labelled racer for a one-shot `select`."""
53
- await evt.wait()
54
- return value
55
-
56
-
57
47
  async def aclose_body(body):
58
48
  """Deterministically close an async body once its pump is done with it — tonio
59
49
  finalizes nothing, so an async generator left unclosed never runs its cleanup."""
@@ -276,7 +276,10 @@ class Connection(H1Framing, H1ClientState):
276
276
  # gets bytes delivered before the request treats them as the violation they
277
277
  # are — the watcher through the idle rules, since the exchange is not yet
278
278
  # active — so the outcome is the same either way; a plain-socket `recv` is
279
- # one syscall. The TLS peek reads only already-decrypted plaintext.)
279
+ # one syscall. Over TLS the parked watcher holds the receive lock, so the peek
280
+ # sees nothing while it is parked: bytes still ciphertext in the socket reach
281
+ # the watcher's read instead — hyper's `read_buf` over rustls holds decrypted
282
+ # bytes only, and its next `poll_read_head` parses the rest as the response too.)
280
283
  transport = self.transport_ref()
281
284
  pending = self.backend.receive_nowait(transport, _READ_SIZE) if transport is not None else b""
282
285
  if pending:
@@ -38,7 +38,7 @@ from collections.abc import AsyncIterator, Awaitable
38
38
  from typing import TYPE_CHECKING, Any
39
39
 
40
40
  from .. import _backend
41
- from .._common import PUMP_ABANDONED, PUMP_DONE, BaseServer, aclose_body, event_result, read_all
41
+ from .._common import BaseServer, aclose_body, read_all
42
42
  from .._httpunk import (
43
43
  H1_NEXT_CLOSE,
44
44
  H1_NEXT_DRAIN,
@@ -623,25 +623,26 @@ class ServerConnection(H1Framing, H1ServerState):
623
623
  connection (so `mid_message_detect_eof`) while the body's next chunk is pending,
624
624
  so a parked producer (SSE, long poll) does not hold a dead exchange. Same shape as
625
625
  the h2 driver's `_send_async_body`: ONE pump task per response; this caller waits
626
- once for "pump done" or "peer closed". On the FIN the pump is CANCELLED (hyper
626
+ ONCE, one suspension on both events (`select_events`: hyper's `PipeToSendStream`
627
+ polls `poll_reset` beside the body future in one task — no racer tasks, no scope
628
+ to cancel; a `select` here measured 5 µs and a 100 ms p99 tail per response), and
629
+ the flags afterwards are the verdict. On the FIN the pump is CANCELLED (hyper
627
630
  drops the body future) at its suspension — the app's `await`, or, h1 having no
628
631
  write pump, possibly inside a chunk write: harmless, this connection is closed
629
632
  right below and a truncated body is what the client gets regardless (hyper:
630
633
  `IncompleteMessage`, io dropped). Also cancelled if this task is cancelled or
631
634
  unwinds: neither backend cancels scope children on a body exception, and joining
632
- a parked pump would hang. (A per-chunk `select` measured 4-5x slower.)"""
635
+ a parked pump would hang. (A per-chunk wait measured 4-5x slower.)"""
633
636
  await self._arm_watcher(req) # the FIN is consumable now: make sure a read is parked
634
637
  done, box = self.backend.event(), []
635
638
  abandoned = False
636
639
  async with self.backend.scope() as scope:
637
640
  scope.spawn(self._pump_async_body(body, trailers, done, box))
638
641
  try:
639
- winner = await self.backend.select(
640
- event_result(done, PUMP_DONE), event_result(req._peer_closed_evt, PUMP_ABANDONED)
641
- )
642
+ await self.backend.select_events(done, req._peer_closed_evt)
642
643
  # The event also marks the window's close (`_close_window`), which cannot
643
644
  # precede this response's own completion — the state's flag is the verdict.
644
- abandoned = winner is PUMP_ABANDONED and self.peer_closed_flag(req._seq) and not done.is_set()
645
+ abandoned = not done.is_set() and self.peer_closed_flag(req._seq)
645
646
  finally:
646
647
  if not done.is_set():
647
648
  scope.cancel() # leave with the pump gone, whatever ended the wait
@@ -25,7 +25,7 @@ crates/vendor-h2), paths relative to its `src/`.
25
25
  import contextlib
26
26
 
27
27
  from .. import _backend
28
- from .._common import PUMP_ABANDONED, PUMP_DONE, aclose_body, aiter_body, event_result
28
+ from .._common import aclose_body, aiter_body
29
29
  from .._httpunk import (
30
30
  H2_FLAG_CAPACITY,
31
31
  H2_FLAG_CONN_DONE,
@@ -433,21 +433,22 @@ class H2ConnectionBase(H2Streams):
433
433
  async def _send_async_body(self, st, body, trailers):
434
434
  """Stream an ASYNC body and fail fast on a peer reset — hyper's `PipeToSendStream`
435
435
  (proto/h2/mod.rs), which polls `poll_reset` while it waits for the body's next
436
- chunk. ONE pump task per response queues each chunk; this caller waits once for
437
- "pump done" or "stream reset". On a reset the pump is CANCELLED (hyper dropping
438
- the body future) at the app's own `await` or the flow-control wait — DATA is
439
- queued for the write pump, never written by this task, so no write is
440
- interrupted. The scope exit does not wait for a CANCELLED child to unwind, so
441
- wait for the pump's own `done` (set in its `finally`, after the producer's
442
- cleanup ran)."""
436
+ chunk — in ONE task. ONE pump task per response queues each chunk; this caller
437
+ waits ONCE, one suspension on both events (`select_events`: no racer tasks, no
438
+ scope to cancel), for "pump done" or "stream reset", and the flags afterwards
439
+ are the verdict. On a reset the pump is CANCELLED (hyper dropping the body
440
+ future) at the app's own `await` or the flow-control wait — DATA is queued for
441
+ the write pump, never written by this task, so no write is interrupted. The
442
+ scope exit does not wait for a CANCELLED child to unwind, so wait for the pump's
443
+ own `done` (set in its `finally`, after the producer's cleanup ran)."""
443
444
  backend = self.backend
444
445
  done, box = backend.event(), []
445
446
  abandoned = False
446
447
  async with backend.scope() as scope:
447
448
  scope.spawn(self._pump_async_body(st, body, trailers, done, box))
448
449
  try:
449
- winner = await backend.select(event_result(done, PUMP_DONE), event_result(st.reset_evt, PUMP_ABANDONED))
450
- abandoned = winner is PUMP_ABANDONED and not done.is_set()
450
+ await backend.select_events(done, st.reset_evt)
451
+ abandoned = not done.is_set() # the wait ended, the pump did not: the reset did
451
452
  finally:
452
453
  if not done.is_set():
453
454
  scope.cancel() # leave with the pump gone, whatever ended the wait
@@ -37,7 +37,7 @@ requires-python = ">=3.10"
37
37
  dependencies = []
38
38
 
39
39
  [project.optional-dependencies]
40
- tonio = ["tonio~=0.9.17; python_version >= '3.14'"]
40
+ tonio = ["tonio~=0.10.0; python_version >= '3.14'"]
41
41
 
42
42
  [dependency-groups]
43
43
  build = [
@@ -1,3 +1,7 @@
1
+ #[cfg(feature = "mimalloc")]
2
+ #[global_allocator]
3
+ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
4
+
1
5
  use pyo3::prelude::*;
2
6
  use std::sync::OnceLock;
3
7
 
@@ -0,0 +1,24 @@
1
+ """Test-side stand-in for the piece of tonio's `SocketStream` the backend's bounded
2
+ reader binds at connection setup (`TonioBackend.bounded_reader`): the no-wait read
3
+ `receive_some_nowait(n)` -> bytes | b"" | `NotReady`, and `waiter_readable(micros)` ->
4
+ None (readable now) or a waiter to park on. A stub transport mixes this in and plugs its
5
+ buffer in through three sync hooks: `_readable()`, `_recv_now(n)` (raises
6
+ `BlockingIOError` when nothing is there), and `_park(timeout_micros)` -> an awaitable
7
+ that resumes when bytes/EOF may have landed or the timer fired (never called by a stub
8
+ that is always readable)."""
9
+
10
+
11
+ class StubStream:
12
+ class NotReady: # the stream's own sentinel, compared by identity (`transport.NotReady`)
13
+ pass
14
+
15
+ def receive_some_nowait(self, max_bytes=65536):
16
+ try:
17
+ return self._recv_now(max_bytes)
18
+ except BlockingIOError:
19
+ return self.NotReady
20
+
21
+ def waiter_readable(self, timeout=None):
22
+ if self._readable():
23
+ return None
24
+ return self._park(timeout)
@@ -246,6 +246,46 @@ async def test_select_cancels_racers_when_itself_cancelled():
246
246
  assert set(cancelled) == {"x", "y"}
247
247
 
248
248
 
249
+ @pytest.mark.asyncio
250
+ async def test_select_events_resumes_on_either_event_without_a_verdict():
251
+ # One wait on both events (the `_send_async_body` race): resumes once ANY is set,
252
+ # at once when one already is, returns nothing — the flags are the answer.
253
+ backend = AsyncioBackend()
254
+ a, b = backend.event(), backend.event()
255
+ a.set()
256
+ assert await backend.select_events(a, b) is None
257
+ a, b = backend.event(), backend.event()
258
+ loop = asyncio.get_running_loop()
259
+ loop.call_soon(b.set)
260
+ await backend.select_events(a, b)
261
+ assert not a.is_set() and b.is_set()
262
+ b.clear()
263
+ loop.call_soon(b.set) # the events are reusable after a clear
264
+ await backend.select_events(a, b)
265
+ assert b.is_set()
266
+
267
+
268
+ @pytest.mark.asyncio
269
+ async def test_select_events_leaves_no_waiter_behind():
270
+ # The loser's wait is cancelled and drained: no task is left parked on the event, and
271
+ # cancelling the wait itself drains both.
272
+ backend = AsyncioBackend()
273
+ a, b = backend.event(), backend.event()
274
+ before = len(asyncio.all_tasks())
275
+ asyncio.get_running_loop().call_soon(a.set)
276
+ await backend.select_events(a, b)
277
+ assert len(asyncio.all_tasks()) == before # the loser's wait task is gone
278
+ a, b = backend.event(), backend.event()
279
+ sel = asyncio.ensure_future(backend.select_events(a, b))
280
+ for _ in range(3):
281
+ await asyncio.sleep(0) # both waits parked
282
+ sel.cancel()
283
+ with pytest.raises(asyncio.CancelledError):
284
+ await sel
285
+ await asyncio.sleep(0)
286
+ assert len(asyncio.all_tasks()) == before
287
+
288
+
249
289
  @pytest.mark.asyncio
250
290
  async def test_scope_spawn_cancel_and_join():
251
291
  backend = AsyncioBackend()