rh2 0.0.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.
@@ -0,0 +1,80 @@
1
+ name: publish
2
+
3
+ # Publishes to PyPI via Trusted Publishing (OIDC) - no API token needed.
4
+ # Configure the publisher at https://pypi.org/manage/account/publishing/ with:
5
+ # project: rh2, owner: Kludex, repo: rh2, workflow: publish.yml, environment: pypi
6
+
7
+ on:
8
+ release:
9
+ types: [published]
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ build:
17
+ name: build ${{ matrix.platform.runner }} (${{ matrix.platform.target }})
18
+ runs-on: ${{ matrix.platform.runner }}
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ platform:
23
+ - runner: ubuntu-latest
24
+ target: x86_64
25
+ - runner: ubuntu-latest
26
+ target: aarch64
27
+ - runner: windows-latest
28
+ target: x64
29
+ # macOS Apple Silicon. Intel-mac (macos-13) is omitted: its runners
30
+ # queue for hours on this repo and block the whole release. Intel-mac
31
+ # users can build from the published sdist.
32
+ - runner: macos-14
33
+ target: aarch64
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - uses: actions/setup-python@v5
37
+ with:
38
+ python-version: "3.x"
39
+ # abi3-py39 produces a single wheel per platform that works on CPython >= 3.9.
40
+ - name: Build wheels
41
+ uses: PyO3/maturin-action@v1
42
+ with:
43
+ target: ${{ matrix.platform.target }}
44
+ args: --release --out dist
45
+ sccache: "true"
46
+ manylinux: auto
47
+ - uses: actions/upload-artifact@v4
48
+ with:
49
+ name: wheels-${{ matrix.platform.runner }}-${{ matrix.platform.target }}
50
+ path: dist
51
+
52
+ sdist:
53
+ name: build sdist
54
+ runs-on: ubuntu-latest
55
+ steps:
56
+ - uses: actions/checkout@v4
57
+ - name: Build sdist
58
+ uses: PyO3/maturin-action@v1
59
+ with:
60
+ command: sdist
61
+ args: --out dist
62
+ - uses: actions/upload-artifact@v4
63
+ with:
64
+ name: wheels-sdist
65
+ path: dist
66
+
67
+ release:
68
+ name: publish to PyPI
69
+ needs: [build, sdist]
70
+ runs-on: ubuntu-latest
71
+ environment: pypi
72
+ permissions:
73
+ id-token: write # required for Trusted Publishing
74
+ steps:
75
+ - uses: actions/download-artifact@v4
76
+ with:
77
+ pattern: wheels-*
78
+ merge-multiple: true
79
+ path: dist
80
+ - uses: pypa/gh-action-pypi-publish@release/v1
rh2-0.0.2/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ /target
2
+ /.venv
3
+ *.so
4
+ __pycache__/
5
+ .pytest_cache/
6
+ dist/
7
+ /bench_*.py
rh2-0.0.2/Cargo.lock ADDED
@@ -0,0 +1,325 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "atomic-waker"
7
+ version = "1.1.2"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
10
+
11
+ [[package]]
12
+ name = "autocfg"
13
+ version = "1.5.1"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
16
+
17
+ [[package]]
18
+ name = "bytes"
19
+ version = "1.12.0"
20
+ source = "registry+https://github.com/rust-lang/crates.io-index"
21
+ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
22
+
23
+ [[package]]
24
+ name = "cfg-if"
25
+ version = "1.0.4"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
28
+
29
+ [[package]]
30
+ name = "equivalent"
31
+ version = "1.0.2"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
34
+
35
+ [[package]]
36
+ name = "fnv"
37
+ version = "1.0.7"
38
+ source = "registry+https://github.com/rust-lang/crates.io-index"
39
+ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
40
+
41
+ [[package]]
42
+ name = "futures-core"
43
+ version = "0.3.32"
44
+ source = "registry+https://github.com/rust-lang/crates.io-index"
45
+ checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
46
+
47
+ [[package]]
48
+ name = "futures-sink"
49
+ version = "0.3.32"
50
+ source = "registry+https://github.com/rust-lang/crates.io-index"
51
+ checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
52
+
53
+ [[package]]
54
+ name = "h2"
55
+ version = "0.4.15"
56
+ source = "registry+https://github.com/rust-lang/crates.io-index"
57
+ checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
58
+ dependencies = [
59
+ "atomic-waker",
60
+ "bytes",
61
+ "fnv",
62
+ "futures-core",
63
+ "futures-sink",
64
+ "http",
65
+ "indexmap",
66
+ "slab",
67
+ "tokio",
68
+ "tokio-util",
69
+ "tracing",
70
+ ]
71
+
72
+ [[package]]
73
+ name = "hashbrown"
74
+ version = "0.17.1"
75
+ source = "registry+https://github.com/rust-lang/crates.io-index"
76
+ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
77
+
78
+ [[package]]
79
+ name = "heck"
80
+ version = "0.5.0"
81
+ source = "registry+https://github.com/rust-lang/crates.io-index"
82
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
83
+
84
+ [[package]]
85
+ name = "http"
86
+ version = "1.4.2"
87
+ source = "registry+https://github.com/rust-lang/crates.io-index"
88
+ checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
89
+ dependencies = [
90
+ "bytes",
91
+ "itoa",
92
+ ]
93
+
94
+ [[package]]
95
+ name = "indexmap"
96
+ version = "2.14.0"
97
+ source = "registry+https://github.com/rust-lang/crates.io-index"
98
+ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
99
+ dependencies = [
100
+ "equivalent",
101
+ "hashbrown",
102
+ ]
103
+
104
+ [[package]]
105
+ name = "indoc"
106
+ version = "2.0.7"
107
+ source = "registry+https://github.com/rust-lang/crates.io-index"
108
+ checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
109
+ dependencies = [
110
+ "rustversion",
111
+ ]
112
+
113
+ [[package]]
114
+ name = "itoa"
115
+ version = "1.0.18"
116
+ source = "registry+https://github.com/rust-lang/crates.io-index"
117
+ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
118
+
119
+ [[package]]
120
+ name = "libc"
121
+ version = "0.2.186"
122
+ source = "registry+https://github.com/rust-lang/crates.io-index"
123
+ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
124
+
125
+ [[package]]
126
+ name = "memoffset"
127
+ version = "0.9.1"
128
+ source = "registry+https://github.com/rust-lang/crates.io-index"
129
+ checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
130
+ dependencies = [
131
+ "autocfg",
132
+ ]
133
+
134
+ [[package]]
135
+ name = "once_cell"
136
+ version = "1.21.4"
137
+ source = "registry+https://github.com/rust-lang/crates.io-index"
138
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
139
+
140
+ [[package]]
141
+ name = "pin-project-lite"
142
+ version = "0.2.17"
143
+ source = "registry+https://github.com/rust-lang/crates.io-index"
144
+ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
145
+
146
+ [[package]]
147
+ name = "portable-atomic"
148
+ version = "1.13.1"
149
+ source = "registry+https://github.com/rust-lang/crates.io-index"
150
+ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
151
+
152
+ [[package]]
153
+ name = "proc-macro2"
154
+ version = "1.0.106"
155
+ source = "registry+https://github.com/rust-lang/crates.io-index"
156
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
157
+ dependencies = [
158
+ "unicode-ident",
159
+ ]
160
+
161
+ [[package]]
162
+ name = "pyo3"
163
+ version = "0.22.6"
164
+ source = "registry+https://github.com/rust-lang/crates.io-index"
165
+ checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
166
+ dependencies = [
167
+ "cfg-if",
168
+ "indoc",
169
+ "libc",
170
+ "memoffset",
171
+ "once_cell",
172
+ "portable-atomic",
173
+ "pyo3-build-config",
174
+ "pyo3-ffi",
175
+ "pyo3-macros",
176
+ "unindent",
177
+ ]
178
+
179
+ [[package]]
180
+ name = "pyo3-build-config"
181
+ version = "0.22.6"
182
+ source = "registry+https://github.com/rust-lang/crates.io-index"
183
+ checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
184
+ dependencies = [
185
+ "once_cell",
186
+ "target-lexicon",
187
+ ]
188
+
189
+ [[package]]
190
+ name = "pyo3-ffi"
191
+ version = "0.22.6"
192
+ source = "registry+https://github.com/rust-lang/crates.io-index"
193
+ checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
194
+ dependencies = [
195
+ "libc",
196
+ "pyo3-build-config",
197
+ ]
198
+
199
+ [[package]]
200
+ name = "pyo3-macros"
201
+ version = "0.22.6"
202
+ source = "registry+https://github.com/rust-lang/crates.io-index"
203
+ checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
204
+ dependencies = [
205
+ "proc-macro2",
206
+ "pyo3-macros-backend",
207
+ "quote",
208
+ "syn",
209
+ ]
210
+
211
+ [[package]]
212
+ name = "pyo3-macros-backend"
213
+ version = "0.22.6"
214
+ source = "registry+https://github.com/rust-lang/crates.io-index"
215
+ checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
216
+ dependencies = [
217
+ "heck",
218
+ "proc-macro2",
219
+ "pyo3-build-config",
220
+ "quote",
221
+ "syn",
222
+ ]
223
+
224
+ [[package]]
225
+ name = "quote"
226
+ version = "1.0.46"
227
+ source = "registry+https://github.com/rust-lang/crates.io-index"
228
+ checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
229
+ dependencies = [
230
+ "proc-macro2",
231
+ ]
232
+
233
+ [[package]]
234
+ name = "rh2"
235
+ version = "0.0.2"
236
+ dependencies = [
237
+ "bytes",
238
+ "h2",
239
+ "http",
240
+ "pyo3",
241
+ "tokio",
242
+ ]
243
+
244
+ [[package]]
245
+ name = "rustversion"
246
+ version = "1.0.22"
247
+ source = "registry+https://github.com/rust-lang/crates.io-index"
248
+ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
249
+
250
+ [[package]]
251
+ name = "slab"
252
+ version = "0.4.12"
253
+ source = "registry+https://github.com/rust-lang/crates.io-index"
254
+ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
255
+
256
+ [[package]]
257
+ name = "syn"
258
+ version = "2.0.118"
259
+ source = "registry+https://github.com/rust-lang/crates.io-index"
260
+ checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
261
+ dependencies = [
262
+ "proc-macro2",
263
+ "quote",
264
+ "unicode-ident",
265
+ ]
266
+
267
+ [[package]]
268
+ name = "target-lexicon"
269
+ version = "0.12.16"
270
+ source = "registry+https://github.com/rust-lang/crates.io-index"
271
+ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
272
+
273
+ [[package]]
274
+ name = "tokio"
275
+ version = "1.52.3"
276
+ source = "registry+https://github.com/rust-lang/crates.io-index"
277
+ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
278
+ dependencies = [
279
+ "bytes",
280
+ "pin-project-lite",
281
+ ]
282
+
283
+ [[package]]
284
+ name = "tokio-util"
285
+ version = "0.7.18"
286
+ source = "registry+https://github.com/rust-lang/crates.io-index"
287
+ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
288
+ dependencies = [
289
+ "bytes",
290
+ "futures-core",
291
+ "futures-sink",
292
+ "pin-project-lite",
293
+ "tokio",
294
+ ]
295
+
296
+ [[package]]
297
+ name = "tracing"
298
+ version = "0.1.44"
299
+ source = "registry+https://github.com/rust-lang/crates.io-index"
300
+ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
301
+ dependencies = [
302
+ "pin-project-lite",
303
+ "tracing-core",
304
+ ]
305
+
306
+ [[package]]
307
+ name = "tracing-core"
308
+ version = "0.1.36"
309
+ source = "registry+https://github.com/rust-lang/crates.io-index"
310
+ checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
311
+ dependencies = [
312
+ "once_cell",
313
+ ]
314
+
315
+ [[package]]
316
+ name = "unicode-ident"
317
+ version = "1.0.24"
318
+ source = "registry+https://github.com/rust-lang/crates.io-index"
319
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
320
+
321
+ [[package]]
322
+ name = "unindent"
323
+ version = "0.2.4"
324
+ source = "registry+https://github.com/rust-lang/crates.io-index"
325
+ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
rh2-0.0.2/Cargo.toml ADDED
@@ -0,0 +1,22 @@
1
+ [package]
2
+ name = "rh2"
3
+ version = "0.0.2"
4
+ edition = "2021"
5
+ description = "Rust-backed sans-IO HTTP/2 codec for Python, wrapping the h2 crate"
6
+ license = "BSD-3-Clause"
7
+ readme = "README.md"
8
+
9
+ [lib]
10
+ name = "rh2"
11
+ crate-type = ["cdylib"]
12
+
13
+ [dependencies]
14
+ pyo3 = { version = "0.22", features = ["extension-module", "abi3-py39"] }
15
+ h2 = "0.4"
16
+ http = "1"
17
+ bytes = "1"
18
+ tokio = { version = "1", features = ["io-util"] }
19
+
20
+ [profile.release]
21
+ lto = true
22
+ codegen-units = 1
rh2-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: rh2
3
+ Version: 0.0.2
4
+ Classifier: Programming Language :: Rust
5
+ Classifier: Programming Language :: Python :: Implementation :: CPython
6
+ Summary: Rust-backed sans-IO HTTP/2 codec for Python, wrapping the h2 crate
7
+ License: BSD-3-Clause
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
10
+
11
+ # rh2
12
+
13
+ A Rust-backed, sans-IO HTTP/2 codec for Python, wrapping the
14
+ [`h2`](https://crates.io/crates/h2) crate (the HTTP/2 implementation behind
15
+ `hyper`/`reqwest`) via [PyO3](https://pyo3.rs).
16
+
17
+ The `h2` crate drives a connection over an async transport; `rh2` runs it
18
+ *sans-IO* by backing that transport with in-memory buffers and pumping the
19
+ state machine by hand. The Python API mirrors
20
+ [`hyper-h2`](https://python-hyper.org/projects/h2/):
21
+
22
+ ```python
23
+ import rh2
24
+
25
+ conn = rh2.H2Connection()
26
+ events = conn.receive_data(data_from_socket)
27
+ for event in events:
28
+ if isinstance(event, rh2.RequestReceived):
29
+ conn.send_headers(event.stream_id, 200, [(b"content-type", b"text/plain")])
30
+ conn.send_data(event.stream_id, b"hello", end_stream=True)
31
+ socket.sendall(conn.data_to_send())
32
+ ```
33
+
34
+ ## Status
35
+
36
+ Experimental. Server-side only.
37
+
rh2-0.0.2/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # rh2
2
+
3
+ A Rust-backed, sans-IO HTTP/2 codec for Python, wrapping the
4
+ [`h2`](https://crates.io/crates/h2) crate (the HTTP/2 implementation behind
5
+ `hyper`/`reqwest`) via [PyO3](https://pyo3.rs).
6
+
7
+ The `h2` crate drives a connection over an async transport; `rh2` runs it
8
+ *sans-IO* by backing that transport with in-memory buffers and pumping the
9
+ state machine by hand. The Python API mirrors
10
+ [`hyper-h2`](https://python-hyper.org/projects/h2/):
11
+
12
+ ```python
13
+ import rh2
14
+
15
+ conn = rh2.H2Connection()
16
+ events = conn.receive_data(data_from_socket)
17
+ for event in events:
18
+ if isinstance(event, rh2.RequestReceived):
19
+ conn.send_headers(event.stream_id, 200, [(b"content-type", b"text/plain")])
20
+ conn.send_data(event.stream_id, b"hello", end_stream=True)
21
+ socket.sendall(conn.data_to_send())
22
+ ```
23
+
24
+ ## Status
25
+
26
+ Experimental. Server-side only.
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.7,<2"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "rh2"
7
+ version = "0.0.2"
8
+ description = "Rust-backed sans-IO HTTP/2 codec for Python, wrapping the h2 crate"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "BSD-3-Clause" }
12
+ classifiers = [
13
+ "Programming Language :: Rust",
14
+ "Programming Language :: Python :: Implementation :: CPython",
15
+ ]
16
+
17
+ [tool.maturin]
18
+ features = ["pyo3/extension-module"]
19
+ python-source = "python"
20
+ module-name = "rh2._rh2"
@@ -0,0 +1,27 @@
1
+ """Rust-backed sans-IO HTTP/2 codec, wrapping the `h2` crate.
2
+
3
+ The API is deliberately close to `hyper-h2`: create a :class:`H2Connection`, feed
4
+ it bytes from the socket with :meth:`H2Connection.receive_data`, act on the
5
+ returned events, and write whatever :meth:`H2Connection.data_to_send` returns
6
+ back to the socket.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from rh2._rh2 import (
12
+ ConnectionTerminated,
13
+ DataReceived,
14
+ H2Connection,
15
+ RequestReceived,
16
+ StreamEnded,
17
+ StreamReset,
18
+ )
19
+
20
+ __all__ = [
21
+ "ConnectionTerminated",
22
+ "DataReceived",
23
+ "H2Connection",
24
+ "RequestReceived",
25
+ "StreamEnded",
26
+ "StreamReset",
27
+ ]
rh2-0.0.2/src/lib.rs ADDED
@@ -0,0 +1,545 @@
1
+ //! Sans-IO HTTP/2 codec for Python, wrapping the async `h2` crate.
2
+ //!
3
+ //! The `h2` crate drives an HTTP/2 connection over a `tokio` `AsyncRead + AsyncWrite`
4
+ //! transport. We want a *sans-IO* codec instead: Python feeds us the bytes it read
5
+ //! from the socket (`receive_data`) and drains the bytes we want to write
6
+ //! (`data_to_send`), and we surface protocol events. To bridge the two we back the
7
+ //! transport with in-memory buffers and drive `h2`'s futures by hand with a no-op
8
+ //! waker, pumping them to `Pending` after every interaction.
9
+
10
+ use std::cell::RefCell;
11
+ use std::collections::{HashMap, VecDeque};
12
+ use std::future::Future;
13
+ use std::io;
14
+ use std::pin::Pin;
15
+ use std::rc::Rc;
16
+ use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
17
+
18
+ use bytes::Bytes;
19
+ use h2::server::{self, SendResponse};
20
+ use h2::{RecvStream, SendStream};
21
+ use http::{HeaderName, HeaderValue, Response, StatusCode};
22
+ use pyo3::exceptions::PyValueError;
23
+ use pyo3::prelude::*;
24
+ use pyo3::types::{PyBytes, PyList};
25
+ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
26
+
27
+ // --- No-op waker -----------------------------------------------------------
28
+ // We poll manually and re-poll after feeding input, so wakeups are irrelevant.
29
+
30
+ fn noop_waker() -> Waker {
31
+ fn clone(_: *const ()) -> RawWaker {
32
+ RawWaker::new(std::ptr::null(), &VTABLE)
33
+ }
34
+ fn noop(_: *const ()) {}
35
+ static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
36
+ unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }
37
+ }
38
+
39
+ // --- In-memory duplex transport -------------------------------------------
40
+
41
+ #[derive(Default)]
42
+ struct IoInner {
43
+ // Contiguous buffer + read cursor so poll_read can bulk-copy instead of
44
+ // popping a byte at a time.
45
+ inbound: Vec<u8>,
46
+ inbound_pos: usize,
47
+ outbound: Vec<u8>,
48
+ inbound_closed: bool,
49
+ }
50
+
51
+ impl IoInner {
52
+ fn push_inbound(&mut self, data: &[u8]) {
53
+ // Drop already-consumed bytes before growing, keeping the buffer small.
54
+ if self.inbound_pos > 0 {
55
+ self.inbound.drain(0..self.inbound_pos);
56
+ self.inbound_pos = 0;
57
+ }
58
+ self.inbound.extend_from_slice(data);
59
+ }
60
+ }
61
+
62
+ #[derive(Clone)]
63
+ struct SharedIo(Rc<RefCell<IoInner>>);
64
+
65
+ impl SharedIo {
66
+ fn new() -> Self {
67
+ SharedIo(Rc::new(RefCell::new(IoInner::default())))
68
+ }
69
+ }
70
+
71
+ impl AsyncRead for SharedIo {
72
+ fn poll_read(
73
+ self: Pin<&mut Self>,
74
+ _cx: &mut Context<'_>,
75
+ buf: &mut ReadBuf<'_>,
76
+ ) -> Poll<io::Result<()>> {
77
+ let mut inner = self.0.borrow_mut();
78
+ let avail = inner.inbound.len() - inner.inbound_pos;
79
+ if avail == 0 {
80
+ // EOF once the peer half-closed and we've drained everything.
81
+ if inner.inbound_closed {
82
+ return Poll::Ready(Ok(()));
83
+ }
84
+ return Poll::Pending;
85
+ }
86
+ let n = std::cmp::min(buf.remaining(), avail);
87
+ let start = inner.inbound_pos;
88
+ buf.put_slice(&inner.inbound[start..start + n]);
89
+ inner.inbound_pos += n;
90
+ if inner.inbound_pos == inner.inbound.len() {
91
+ inner.inbound.clear();
92
+ inner.inbound_pos = 0;
93
+ }
94
+ Poll::Ready(Ok(()))
95
+ }
96
+ }
97
+
98
+ impl AsyncWrite for SharedIo {
99
+ fn poll_write(
100
+ self: Pin<&mut Self>,
101
+ _cx: &mut Context<'_>,
102
+ buf: &[u8],
103
+ ) -> Poll<io::Result<usize>> {
104
+ self.0.borrow_mut().outbound.extend_from_slice(buf);
105
+ Poll::Ready(Ok(buf.len()))
106
+ }
107
+
108
+ fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
109
+ Poll::Ready(Ok(()))
110
+ }
111
+
112
+ fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
113
+ Poll::Ready(Ok(()))
114
+ }
115
+ }
116
+
117
+ // --- Events ----------------------------------------------------------------
118
+
119
+ #[pyclass(module = "rh2._rh2")]
120
+ struct RequestReceived {
121
+ #[pyo3(get)]
122
+ stream_id: u32,
123
+ headers: Py<PyList>,
124
+ #[pyo3(get)]
125
+ stream_ended: bool,
126
+ }
127
+
128
+ #[pymethods]
129
+ impl RequestReceived {
130
+ #[getter]
131
+ fn headers(&self, py: Python<'_>) -> Py<PyList> {
132
+ self.headers.clone_ref(py)
133
+ }
134
+
135
+ fn __repr__(&self) -> String {
136
+ format!("RequestReceived(stream_id={}, stream_ended={})", self.stream_id, self.stream_ended)
137
+ }
138
+ }
139
+
140
+ #[pyclass(module = "rh2._rh2")]
141
+ struct DataReceived {
142
+ #[pyo3(get)]
143
+ stream_id: u32,
144
+ data: Py<PyBytes>,
145
+ #[pyo3(get)]
146
+ stream_ended: bool,
147
+ }
148
+
149
+ #[pymethods]
150
+ impl DataReceived {
151
+ #[getter]
152
+ fn data(&self, py: Python<'_>) -> Py<PyBytes> {
153
+ self.data.clone_ref(py)
154
+ }
155
+
156
+ fn __repr__(&self) -> String {
157
+ format!("DataReceived(stream_id={})", self.stream_id)
158
+ }
159
+ }
160
+
161
+ #[pyclass(module = "rh2._rh2")]
162
+ struct StreamEnded {
163
+ #[pyo3(get)]
164
+ stream_id: u32,
165
+ }
166
+
167
+ #[pymethods]
168
+ impl StreamEnded {
169
+ fn __repr__(&self) -> String {
170
+ format!("StreamEnded(stream_id={})", self.stream_id)
171
+ }
172
+ }
173
+
174
+ #[pyclass(module = "rh2._rh2")]
175
+ struct StreamReset {
176
+ #[pyo3(get)]
177
+ stream_id: u32,
178
+ #[pyo3(get)]
179
+ error_code: u32,
180
+ }
181
+
182
+ #[pymethods]
183
+ impl StreamReset {
184
+ fn __repr__(&self) -> String {
185
+ format!("StreamReset(stream_id={}, error_code={})", self.stream_id, self.error_code)
186
+ }
187
+ }
188
+
189
+ #[pyclass(module = "rh2._rh2")]
190
+ struct ConnectionTerminated {}
191
+
192
+ #[pymethods]
193
+ impl ConnectionTerminated {
194
+ fn __repr__(&self) -> String {
195
+ "ConnectionTerminated()".to_string()
196
+ }
197
+ }
198
+
199
+ // --- Connection ------------------------------------------------------------
200
+
201
+ type HandshakeFut =
202
+ Pin<Box<dyn Future<Output = Result<server::Connection<SharedIo, Bytes>, h2::Error>>>>;
203
+
204
+ enum State {
205
+ Handshaking(HandshakeFut),
206
+ Ready(server::Connection<SharedIo, Bytes>),
207
+ Closed,
208
+ }
209
+
210
+ #[pyclass(module = "rh2._rh2", unsendable)]
211
+ struct H2Connection {
212
+ io: SharedIo,
213
+ state: State,
214
+ recv_streams: HashMap<u32, RecvStream>,
215
+ responders: HashMap<u32, SendResponse<Bytes>>,
216
+ send_streams: HashMap<u32, SendStream<Bytes>>,
217
+ // Body bytes queued by the app, flushed as the peer's flow-control window allows.
218
+ pending_send: HashMap<u32, VecDeque<(Bytes, bool)>>,
219
+ events: VecDeque<PyObject>,
220
+ }
221
+
222
+ impl H2Connection {
223
+ fn pump(&mut self, py: Python<'_>) -> PyResult<()> {
224
+ let waker = noop_waker();
225
+ let mut cx = Context::from_waker(&waker);
226
+
227
+ // 1. Complete the handshake if we're still waiting on the client preface.
228
+ if let State::Handshaking(fut) = &mut self.state {
229
+ match fut.as_mut().poll(&mut cx) {
230
+ Poll::Ready(Ok(conn)) => self.state = State::Ready(conn),
231
+ Poll::Ready(Err(e)) => {
232
+ self.state = State::Closed;
233
+ return Err(PyValueError::new_err(format!("handshake failed: {e}")));
234
+ }
235
+ Poll::Pending => return Ok(()),
236
+ }
237
+ }
238
+
239
+ if !matches!(self.state, State::Ready(_)) {
240
+ return Ok(());
241
+ }
242
+
243
+ // Drive the connection until it stops making progress. `h2` only writes
244
+ // buffered frames to the transport while the connection is being polled,
245
+ // so a final no-progress pass is what flushes the last response bytes.
246
+ for _ in 0..64 {
247
+ let mut progress = self.drive(py, &mut cx)?;
248
+ if self.flush_pending(&mut cx) {
249
+ progress = true;
250
+ }
251
+ if !progress {
252
+ break;
253
+ }
254
+ }
255
+ Ok(())
256
+ }
257
+
258
+ fn drive(&mut self, py: Python<'_>, cx: &mut Context<'_>) -> PyResult<bool> {
259
+ // Progress is measured by whether the drive surfaced any new events;
260
+ // capacity-only advances are handled by `flush_pending` in the caller.
261
+ let before = self.events.len();
262
+ let conn = match &mut self.state {
263
+ State::Ready(conn) => conn,
264
+ _ => return Ok(false),
265
+ };
266
+
267
+ // 2. Accept any newly-arrived streams.
268
+ loop {
269
+ match conn.poll_accept(cx) {
270
+ Poll::Ready(Some(Ok((request, respond)))) => {
271
+ let stream_id = respond.stream_id().as_u32();
272
+ let (parts, recv) = request.into_parts();
273
+
274
+ // Reconstruct the HTTP/2 pseudo-headers the app expects, then
275
+ // the regular headers, as a list of (name, value) byte tuples.
276
+ let headers = PyList::empty_bound(py);
277
+ let mut push = |name: &[u8], value: &[u8]| {
278
+ let pair = (PyBytes::new_bound(py, name), PyBytes::new_bound(py, value));
279
+ headers.append(pair).unwrap();
280
+ };
281
+ push(b":method", parts.method.as_str().as_bytes());
282
+ if let Some(scheme) = parts.uri.scheme_str() {
283
+ push(b":scheme", scheme.as_bytes());
284
+ }
285
+ if let Some(authority) = parts.uri.authority() {
286
+ push(b":authority", authority.as_str().as_bytes());
287
+ }
288
+ let path = parts
289
+ .uri
290
+ .path_and_query()
291
+ .map(|pq| pq.as_str())
292
+ .unwrap_or("/");
293
+ push(b":path", path.as_bytes());
294
+ for (name, value) in parts.headers.iter() {
295
+ push(name.as_str().as_bytes(), value.as_bytes());
296
+ }
297
+ let headers: Py<PyList> = headers.into();
298
+
299
+ let stream_ended = recv.is_end_stream();
300
+ self.recv_streams.insert(stream_id, recv);
301
+ self.responders.insert(stream_id, respond);
302
+ let event = RequestReceived { stream_id, headers, stream_ended };
303
+ self.events.push_back(event.into_py(py));
304
+ }
305
+ Poll::Ready(Some(Err(e))) => {
306
+ self.state = State::Closed;
307
+ let event = ConnectionTerminated {};
308
+ self.events.push_back(event.into_py(py));
309
+ let _ = e;
310
+ return Ok(self.events.len() != before);
311
+ }
312
+ Poll::Ready(None) => {
313
+ let event = ConnectionTerminated {};
314
+ self.events.push_back(event.into_py(py));
315
+ break;
316
+ }
317
+ Poll::Pending => break,
318
+ }
319
+ }
320
+
321
+ // 3. Read body data from each open receive stream.
322
+ let ids: Vec<u32> = self.recv_streams.keys().copied().collect();
323
+ for stream_id in ids {
324
+ loop {
325
+ let recv = match self.recv_streams.get_mut(&stream_id) {
326
+ Some(r) => r,
327
+ None => break,
328
+ };
329
+ match recv.poll_data(cx) {
330
+ Poll::Ready(Some(Ok(chunk))) => {
331
+ // Immediately release the connection-level window; the app
332
+ // acknowledges stream-level capacity via `acknowledge_data`.
333
+ let _ = recv.flow_control().release_capacity(chunk.len());
334
+ let stream_ended = recv.is_end_stream();
335
+ let event = DataReceived {
336
+ stream_id,
337
+ data: PyBytes::new_bound(py, &chunk).into(),
338
+ stream_ended,
339
+ };
340
+ self.events.push_back(event.into_py(py));
341
+ }
342
+ Poll::Ready(Some(Err(e))) => {
343
+ let error_code =
344
+ e.reason().map(|r| u32::from(r)).unwrap_or(0);
345
+ self.recv_streams.remove(&stream_id);
346
+ let event = StreamReset { stream_id, error_code };
347
+ self.events.push_back(event.into_py(py));
348
+ break;
349
+ }
350
+ Poll::Ready(None) => {
351
+ self.recv_streams.remove(&stream_id);
352
+ let event = StreamEnded { stream_id };
353
+ self.events.push_back(event.into_py(py));
354
+ break;
355
+ }
356
+ Poll::Pending => break,
357
+ }
358
+ }
359
+ }
360
+
361
+ Ok(self.events.len() != before)
362
+ }
363
+
364
+ fn flush_pending(&mut self, cx: &mut Context<'_>) -> bool {
365
+ let mut sent = false;
366
+ let ids: Vec<u32> = self.pending_send.keys().copied().collect();
367
+ for stream_id in ids {
368
+ loop {
369
+ let queue = match self.pending_send.get_mut(&stream_id) {
370
+ Some(q) if !q.is_empty() => q,
371
+ _ => break,
372
+ };
373
+ let send = match self.send_streams.get_mut(&stream_id) {
374
+ Some(s) => s,
375
+ None => break,
376
+ };
377
+ let (chunk, end_stream) = queue.front().cloned().unwrap();
378
+ if chunk.is_empty() {
379
+ let _ = send.send_data(Bytes::new(), end_stream);
380
+ queue.pop_front();
381
+ sent = true;
382
+ continue;
383
+ }
384
+ send.reserve_capacity(chunk.len());
385
+ match send.poll_capacity(cx) {
386
+ Poll::Ready(Some(Ok(cap))) if cap > 0 => {
387
+ let take = std::cmp::min(cap, chunk.len());
388
+ let to_send = chunk.slice(0..take);
389
+ let last = take == chunk.len();
390
+ let _ = send.send_data(to_send, last && end_stream);
391
+ if last {
392
+ queue.pop_front();
393
+ } else {
394
+ queue[0] = (chunk.slice(take..), end_stream);
395
+ }
396
+ sent = true;
397
+ }
398
+ Poll::Ready(Some(Ok(_))) => break,
399
+ Poll::Ready(Some(Err(_))) | Poll::Ready(None) => {
400
+ queue.clear();
401
+ break;
402
+ }
403
+ Poll::Pending => break,
404
+ }
405
+ }
406
+ }
407
+ sent
408
+ }
409
+ }
410
+
411
+ #[pymethods]
412
+ impl H2Connection {
413
+ #[new]
414
+ fn new() -> Self {
415
+ let io = SharedIo::new();
416
+ let fut: HandshakeFut = Box::pin(server::handshake(io.clone()));
417
+ H2Connection {
418
+ io,
419
+ state: State::Handshaking(fut),
420
+ recv_streams: HashMap::new(),
421
+ responders: HashMap::new(),
422
+ send_streams: HashMap::new(),
423
+ pending_send: HashMap::new(),
424
+ events: VecDeque::new(),
425
+ }
426
+ }
427
+
428
+ /// Feed bytes received from the peer; returns the events they produced.
429
+ fn receive_data(&mut self, py: Python<'_>, data: &[u8]) -> PyResult<Vec<PyObject>> {
430
+ self.io.0.borrow_mut().push_inbound(data);
431
+ self.pump(py)?;
432
+ Ok(self.events.drain(..).collect())
433
+ }
434
+
435
+ /// Signal that the peer closed its side of the connection (EOF).
436
+ fn receive_eof(&mut self, py: Python<'_>) -> PyResult<Vec<PyObject>> {
437
+ self.io.0.borrow_mut().inbound_closed = true;
438
+ self.pump(py)?;
439
+ Ok(self.events.drain(..).collect())
440
+ }
441
+
442
+ /// Drain bytes that must be written to the peer.
443
+ ///
444
+ /// The state-changing calls (`send_headers`, `send_data`, ...) only mutate
445
+ /// local state; the connection is driven here, so a burst of them coalesces
446
+ /// into a single pump instead of re-driving the whole machine per call.
447
+ fn data_to_send<'py>(&mut self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
448
+ self.pump(py)?;
449
+ let mut inner = self.io.0.borrow_mut();
450
+ let out = std::mem::take(&mut inner.outbound);
451
+ Ok(PyBytes::new_bound(py, &out))
452
+ }
453
+
454
+ /// Send response headers on a stream. `headers` is a list of (name, value) byte pairs;
455
+ /// the `:status` pseudo-header supplies the status code.
456
+ #[pyo3(signature = (stream_id, status, headers, end_stream=false))]
457
+ fn send_headers(
458
+ &mut self,
459
+ stream_id: u32,
460
+ status: u16,
461
+ headers: Vec<(Vec<u8>, Vec<u8>)>,
462
+ end_stream: bool,
463
+ ) -> PyResult<()> {
464
+ let respond = self
465
+ .responders
466
+ .remove(&stream_id)
467
+ .ok_or_else(|| PyValueError::new_err(format!("unknown stream {stream_id}")))?;
468
+ let mut response = Response::builder()
469
+ .status(StatusCode::from_u16(status).map_err(|e| PyValueError::new_err(e.to_string()))?);
470
+ {
471
+ let hdrs = response.headers_mut().unwrap();
472
+ for (name, value) in headers {
473
+ if name.first() == Some(&b':') {
474
+ continue; // pseudo-headers are set from `status`
475
+ }
476
+ let hn = HeaderName::from_bytes(&name)
477
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
478
+ let hv = HeaderValue::from_bytes(&value)
479
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
480
+ hdrs.append(hn, hv);
481
+ }
482
+ }
483
+ let response = response
484
+ .body(())
485
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
486
+ let mut respond = respond;
487
+ let send = respond
488
+ .send_response(response, end_stream)
489
+ .map_err(|e| PyValueError::new_err(format!("send_response failed: {e}")))?;
490
+ if !end_stream {
491
+ self.send_streams.insert(stream_id, send);
492
+ }
493
+ Ok(())
494
+ }
495
+
496
+ #[pyo3(signature = (stream_id, data, end_stream=false))]
497
+ fn send_data(&mut self, stream_id: u32, data: &[u8], end_stream: bool) -> PyResult<()> {
498
+ // Take `&[u8]` (buffer protocol, zero-copy view) rather than `Vec<u8>`,
499
+ // which PyO3 would fill element-by-element - O(n) Python int conversions.
500
+ self.pending_send
501
+ .entry(stream_id)
502
+ .or_default()
503
+ .push_back((Bytes::copy_from_slice(data), end_stream));
504
+ Ok(())
505
+ }
506
+
507
+ #[pyo3(signature = (stream_id, error_code=8))]
508
+ fn reset_stream(&mut self, stream_id: u32, error_code: u32) -> PyResult<()> {
509
+ if let Some(mut send) = self.send_streams.remove(&stream_id) {
510
+ send.send_reset(h2::Reason::from(error_code));
511
+ }
512
+ self.responders.remove(&stream_id);
513
+ self.recv_streams.remove(&stream_id);
514
+ self.pending_send.remove(&stream_id);
515
+ Ok(())
516
+ }
517
+
518
+ /// Acknowledge that the app consumed `size` bytes of a request body, refilling
519
+ /// the stream-level flow-control window.
520
+ fn acknowledge_data(&mut self, stream_id: u32, size: usize) -> PyResult<()> {
521
+ if let Some(recv) = self.recv_streams.get_mut(&stream_id) {
522
+ let _ = recv.flow_control().release_capacity(size);
523
+ }
524
+ Ok(())
525
+ }
526
+
527
+ /// Begin a graceful shutdown (send GOAWAY).
528
+ fn close_connection(&mut self) -> PyResult<()> {
529
+ if let State::Ready(conn) = &mut self.state {
530
+ conn.graceful_shutdown();
531
+ }
532
+ Ok(())
533
+ }
534
+ }
535
+
536
+ #[pymodule]
537
+ fn _rh2(m: &Bound<'_, PyModule>) -> PyResult<()> {
538
+ m.add_class::<H2Connection>()?;
539
+ m.add_class::<RequestReceived>()?;
540
+ m.add_class::<DataReceived>()?;
541
+ m.add_class::<StreamEnded>()?;
542
+ m.add_class::<StreamReset>()?;
543
+ m.add_class::<ConnectionTerminated>()?;
544
+ Ok(())
545
+ }
@@ -0,0 +1,103 @@
1
+ """Drive the rh2 server codec with hyper-h2 acting as the client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import h2.config
6
+ import h2.connection
7
+ import h2.events
8
+
9
+ import rh2
10
+
11
+
12
+ def _pump(server: rh2.H2Connection, client: h2.connection.H2Connection) -> list:
13
+ """Ferry bytes both ways until neither side has more to send."""
14
+ events: list = []
15
+ for _ in range(20):
16
+ moved = False
17
+ client_out = client.data_to_send()
18
+ if client_out:
19
+ events += server.receive_data(client_out)
20
+ moved = True
21
+ server_out = server.data_to_send()
22
+ if server_out:
23
+ events += client.receive_data(server_out)
24
+ moved = True
25
+ if not moved:
26
+ break
27
+ return events
28
+
29
+
30
+ def test_request_response_roundtrip() -> None:
31
+ server = rh2.H2Connection()
32
+ client = h2.connection.H2Connection(config=h2.config.H2Configuration(client_side=True))
33
+ client.initiate_connection()
34
+
35
+ stream_id = client.get_next_available_stream_id()
36
+ client.send_headers(
37
+ stream_id,
38
+ [
39
+ (b":method", b"GET"),
40
+ (b":scheme", b"http"),
41
+ (b":authority", b"example.com"),
42
+ (b":path", b"/hello"),
43
+ ],
44
+ end_stream=True,
45
+ )
46
+
47
+ events = _pump(server, client)
48
+ reqs = [e for e in events if isinstance(e, rh2.RequestReceived)]
49
+ assert len(reqs) == 1
50
+ req = reqs[0]
51
+ assert req.stream_id == stream_id
52
+ assert req.stream_ended is True
53
+ header_map = {bytes(k): bytes(v) for k, v in req.headers}
54
+ assert header_map[b":method"] == b"GET"
55
+ assert header_map[b":path"] == b"/hello"
56
+
57
+ server.send_headers(stream_id, 200, [(b"content-type", b"text/plain")], end_stream=False)
58
+ server.send_data(stream_id, b"hello world", end_stream=True)
59
+
60
+ client_events = _pump(server, client)
61
+ got_response = any(isinstance(e, h2.events.ResponseReceived) for e in client_events)
62
+ got_data = b"".join(
63
+ e.data for e in client_events if isinstance(e, h2.events.DataReceived)
64
+ )
65
+ assert got_response
66
+ assert got_data == b"hello world"
67
+
68
+
69
+ def test_request_body_flow_control() -> None:
70
+ server = rh2.H2Connection()
71
+ client = h2.connection.H2Connection(config=h2.config.H2Configuration(client_side=True))
72
+ client.initiate_connection()
73
+
74
+ stream_id = client.get_next_available_stream_id()
75
+ client.send_headers(
76
+ stream_id,
77
+ [
78
+ (b":method", b"POST"),
79
+ (b":scheme", b"http"),
80
+ (b":authority", b"example.com"),
81
+ (b":path", b"/upload"),
82
+ ],
83
+ )
84
+ body = b"x" * (100 * 1024) # larger than the default 64 KiB window
85
+ # Send within the client's available window, then rely on WINDOW_UPDATE.
86
+ offset = 0
87
+ events: list = []
88
+ while offset < len(body):
89
+ window = client.local_flow_control_window(stream_id)
90
+ if window == 0:
91
+ events += _pump(server, client)
92
+ continue
93
+ chunk = body[offset : offset + min(window, 16384)]
94
+ client.send_data(stream_id, chunk)
95
+ offset += len(chunk)
96
+ client.end_stream(stream_id)
97
+ events += _pump(server, client)
98
+
99
+ received = b"".join(
100
+ bytes(e.data) for e in events if isinstance(e, rh2.DataReceived)
101
+ )
102
+ assert received == body
103
+ assert any(isinstance(e, rh2.StreamEnded) for e in events)