From 4acc1cd7b8b86aa7a81eddfeb92161648c2b5308 Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 29 Sep 2026 19:05:54 +0800 Subject: [PATCH 01/15] docs(sponsors): add Byteful and remove expired Hyper Solutions --- .github/assets/byteful-logo.svg | 18 +++++++++++++ .github/assets/ezcaptcha.svg | 4 +++ .github/assets/hypersolutions.jpg | Bin 39483 -> 0 bytes .github/assets/nodemaven.svg | 5 +++- .github/assets/scrapedo.svg | 5 +++- README.md | 30 +++++++++++----------- docs/source/sponsors.md | 41 +++++++++++++----------------- 7 files changed, 63 insertions(+), 40 deletions(-) create mode 100644 .github/assets/byteful-logo.svg create mode 100644 .github/assets/ezcaptcha.svg delete mode 100644 .github/assets/hypersolutions.jpg diff --git a/.github/assets/byteful-logo.svg b/.github/assets/byteful-logo.svg new file mode 100644 index 00000000..2f09be60 --- /dev/null +++ b/.github/assets/byteful-logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/.github/assets/ezcaptcha.svg b/.github/assets/ezcaptcha.svg new file mode 100644 index 00000000..396a6338 --- /dev/null +++ b/.github/assets/ezcaptcha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.github/assets/hypersolutions.jpg b/.github/assets/hypersolutions.jpg deleted file mode 100644 index f1d90152dedbd296bd0606449acdb9c10017da21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39483 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 + + + + diff --git a/.github/assets/scrapedo.svg b/.github/assets/scrapedo.svg index 641b5925..2eee1142 100644 --- a/.github/assets/scrapedo.svg +++ b/.github/assets/scrapedo.svg @@ -1,4 +1,6 @@ + + @@ -50,4 +52,5 @@ - \ No newline at end of file + + diff --git a/README.md b/README.md index 1f79c915..4f0bb348 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,20 @@ Unless you explicitly state otherwise, any contribution intentionally submitted ## Sponsors +Byteful + +**35M+ residential IPs with 99.9% success rates** + +UK-based **[Byteful](https://byteful.com/?utm_source=github_python&utm_medium=github-sponsor&utm_campaign=wreq_github_sponsorship)** provides ethically sourced residential, mobile, static residential (ISP), and datacenter proxies. Its API-first scraping, data collection, and AI automation tools handle tens of billions of monthly requests. + +With free geo-targeting and ~0.5s response times, Byteful helps you collect data at scale. Use code **`WREQ10`** for **10% off residential proxies**. + +**[Dashboard](https://dashboard.byteful.com/)** | **[Docs](https://documentation.byteful.com/)** | **[Discord](https://discord.com/invite/ping-proxies-584021352940568578)** + +--- + - NodeMaven + NodeMaven **[NodeMaven](https://go.nodemaven.com/wreqpythonGHaugust)** is the most efficient proxy provider for web scraping and automation, with the highest-quality IPs on the market. @@ -142,7 +154,7 @@ Unique free tools include the **Proxy Bandwidth Checker**, **Meta Tag Checker**, --- - + Scrape.do **[Scrape.do](https://scrape.do/?utm_source=github&utm_medium=rnet)** is the ultimate toolkit for collecting public data at scale. Unmatched speed, unbeatable prices, unblocked access. @@ -156,7 +168,7 @@ One line of code. Instant data access --- - + EzCaptcha Captcha solving can be slow and unreliable, but **[EzCaptcha](https://www.ez-captcha.com/?r=github-rnet)** delivers fast, reliable solving through a simple API — supporting a wide range of captcha types with no complex integration required. @@ -166,15 +178,3 @@ Captcha solving can be slow and unreliable, but **[EzCaptcha](https://www.ez-cap Designed for developers, it offers high accuracy, low price, low latency, and easy integration, helping you automate verification while keeping traffic secure and user flows smooth. **[Dashboard](https://www.ez-captcha.com/?r=github-rnet)** | **[Docs](https://ezcaptcha.atlassian.net/wiki/spaces/IS/pages/7045121/EzCaptcha+API+Docs+English)** | **[Telegram](https://t.me/+NrVmPhlb9ZFkZGY5)** - ---- - - - -TLS fingerprinting alone isn't enough for modern bot protection. **[Hyper Solutions](https://hypersolutions.co?utm_source=github&utm_medium=readme&utm_campaign=rnet)** provides the missing piece - API endpoints that generate valid antibot tokens for: - -**Akamai** • **DataDome** • **Kasada** • **Incapsula** - -No browser automation. Just simple API calls that return the exact cookies and headers these systems require. - -**[Dashboard](https://hypersolutions.co?utm_source=github&utm_medium=readme&utm_campaign=rnet)** | **[Docs](https://docs.justhyped.dev)** | **[Discord](https://discord.gg/akamai)** diff --git a/docs/source/sponsors.md b/docs/source/sponsors.md index 9d026434..8d2a7715 100644 --- a/docs/source/sponsors.md +++ b/docs/source/sponsors.md @@ -5,11 +5,24 @@ We're grateful for the support of our sponsors who help make this project possib --- +## [Byteful](https://byteful.com/?utm_source=github_python&utm_medium=github-sponsor&utm_campaign=wreq_github_sponsorship) + +[![Byteful](https://raw.githubusercontent.com/0x676e67/wreq-python/main/.github/assets/byteful-logo.svg){ width="149" height="47" }](https://byteful.com/?utm_source=github_python&utm_medium=github-sponsor&utm_campaign=wreq_github_sponsorship){ target="_blank" } + +**35M+ residential IPs with 99.9% success rates** + +UK-based **[Byteful](https://byteful.com/?utm_source=github_python&utm_medium=github-sponsor&utm_campaign=wreq_github_sponsorship)** provides ethically sourced residential, mobile, static residential (ISP), and datacenter proxies. Its API-first scraping, data collection, and AI automation tools handle tens of billions of monthly requests. + +With free geo-targeting and ~0.5s response times, Byteful helps you collect data at scale. Use code **`WREQ10`** for **10% off residential proxies**. + +**[Dashboard](https://dashboard.byteful.com/)** | **[Docs](https://documentation.byteful.com/)** | **[Discord](https://discord.com/invite/ping-proxies-584021352940568578)** + +--- + + ## [NodeMaven](https://go.nodemaven.com/wreqpythonGHaugust) - - NodeMaven - +[![NodeMaven](https://raw.githubusercontent.com/0x676e67/wreq-python/main/.github/assets/nodemaven.svg){ width="165" height="47" }](https://go.nodemaven.com/wreqpythonGHaugust){ target="_blank" } NodeMaven is the most efficient proxy provider for web scraping and automation, with the highest-quality IPs on the market. @@ -28,9 +41,7 @@ Unique free tools include the **Proxy Bandwidth Checker**, **Meta Tag Checker**, ## [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=wreq) - - - +[![Scrape.do](https://raw.githubusercontent.com/0x676e67/wreq-python/main/.github/assets/scrapedo.svg){ width="149" height="47" }](https://scrape.do/?utm_source=github&utm_medium=wreq){ target="_blank" } Scrape.do is the ultimate toolkit for collecting public data at scale. Unmatched speed, unbeatable prices, unblocked access. @@ -45,9 +56,7 @@ One line of code. Instant data access ## [EzCaptcha](https://www.ez-captcha.com/?r=github-wreq) - - - +[![EzCaptcha](https://raw.githubusercontent.com/0x676e67/wreq-python/main/.github/assets/ezcaptcha.svg){ width="47" height="47" }](https://www.ez-captcha.com){ target="_blank" } Captcha solving can be slow and unreliable, but EzCaptcha delivers fast, reliable solving through a simple API — supporting a wide range of captcha types with no complex integration required. @@ -60,20 +69,6 @@ Designed for developers, it offers high accuracy, low price, low latency, and ea --- -## [Hyper Solutions](https://hypersolutions.co?utm_source=github&utm_medium=readme&utm_campaign=wreq) - - - -TLS fingerprinting alone isn't enough for modern bot protection. Hyper Solutions provides the missing piece - API endpoints that generate valid antibot tokens for: - -**Akamai** • **DataDome** • **Kasada** • **Incapsula** - -No browser automation. Just simple API calls that return the exact cookies and headers these systems require. - -**[Dashboard](https://hypersolutions.co?utm_source=github&utm_medium=readme&utm_campaign=wreq)** | **[Docs](https://docs.justhyped.dev)** | **[Discord](https://discord.gg/akamai)** - ---- - ## Become a Sponsor Interested in sponsoring this project? [Contact us](mailto:gngppz@gmail.com) or check out our [sponsorship options](https://github.com/0x676e67/0x676e67/blob/main/SPONSOR.md). From 4060a7c229177efbc66f2ac2c1728e960395153f Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 29 Sep 2026 22:04:50 +0800 Subject: [PATCH 02/15] feat(runtime): support custom client runtimes --- Cargo.lock | 120 +++++----- Cargo.toml | 7 +- docs/mkdocs.yml | 1 + docs/source/api/runtime.md | 8 + docs/source/guide/advanced.md | 59 +++++ python/wreq/__init__.py | 2 + python/wreq/blocking.py | 4 + python/wreq/runtime.py | 52 +++++ python/wreq/wreq.py | 13 +- src/client.rs | 34 ++- src/client/body/multipart.rs | 9 +- src/client/body/stream.rs | 92 ++++---- src/client/body/stream/upload.rs | 131 +++++++++++ src/client/nogil.rs | 16 +- src/client/req.rs | 4 +- src/client/resp/http.rs | 25 +- src/client/resp/ws.rs | 37 +-- src/dns.rs | 55 ++--- src/lib.rs | 14 ++ src/runtime.rs | 386 +++++++++++++++++++++++++++++++ tests/cancellation_test.py | 87 ++++++- tests/runtime_test.py | 314 +++++++++++++++++++++++++ tests/upload_test.py | 148 ++++++++++++ 23 files changed, 1424 insertions(+), 194 deletions(-) create mode 100644 docs/source/api/runtime.md create mode 100644 python/wreq/runtime.py create mode 100644 src/client/body/stream/upload.rs create mode 100644 src/runtime.rs create mode 100644 tests/runtime_test.py create mode 100644 tests/upload_test.py diff --git a/Cargo.lock b/Cargo.lock index 7af90083..5aaea141 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,18 +47,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-compression" version = "0.4.47" @@ -271,15 +259,6 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "cookie" version = "0.18.2" @@ -439,26 +418,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -547,7 +506,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1237,12 +1195,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1278,6 +1230,20 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pingora-runtime" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89aed2e58b34196682bdc2370c4293f908dece5741c56f5f5ba1018f55de68f2" +dependencies = [ + "log", + "once_cell", + "rand 0.8.8", + "serde", + "thread_local", + "tokio", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -1350,21 +1316,6 @@ dependencies = [ "pyo3-macros", ] -[[package]] -name = "pyo3-async-runtimes" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3ef68daa7316a3fac65e5e18b2203f010346de1c1c53456811a2624673ab046" -dependencies = [ - "async-channel", - "futures-channel", - "futures-util", - "once_cell", - "pin-project-lite", - "pyo3", - "tokio", -] - [[package]] name = "pyo3-build-config" version = "0.29.2" @@ -1429,13 +1380,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -1450,6 +1412,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1460,6 +1432,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -1843,6 +1824,15 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tikv-jemalloc-sys" version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" @@ -2476,8 +2466,8 @@ dependencies = [ "indexmap", "mimalloc", "pin-project-lite", + "pingora-runtime", "pyo3", - "pyo3-async-runtimes", "serde", "serde_json", "serde_urlencoded", diff --git a/Cargo.toml b/Cargo.toml index 1711c00e..3461ea82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,8 @@ abi3-py313 = ["pyo3/abi3-py313"] abi3-py314 = ["pyo3/abi3-py314"] [dependencies] -tokio = "1.52.2" +pingora-runtime = "0.9.0" +tokio = { version = "1.52.2", features = ["rt-multi-thread", "sync", "time", "net"] } tokio-util = { version = "0.7.18", features = ["rt"] } pyo3 = { version = "0.29.0", features = [ "indexmap", @@ -37,10 +38,6 @@ pyo3 = { version = "0.29.0", features = [ "generate-import-lib", "experimental-async", ] } -pyo3-async-runtimes = { version = "0.29.0", features = [ - "tokio-runtime", - "unstable-streams", -] } pin-project-lite = "0.2.16" futures-util = { version = "0.3.33", default-features = false } serde = { version = "1.0.228", features = ["derive"] } diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 81652186..51ae0e80 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -113,6 +113,7 @@ nav: - Modules: - wreq: api/wreq.md - wreq.blocking: api/blocking.md + - wreq.runtime: api/runtime.md - wreq.header: api/header.md - wreq.cookie: api/cookie.md - wreq.exceptions: api/exceptions.md diff --git a/docs/source/api/runtime.md b/docs/source/api/runtime.md new file mode 100644 index 00000000..dfda7fb5 --- /dev/null +++ b/docs/source/api/runtime.md @@ -0,0 +1,8 @@ +# wreq.runtime + +Runtime configuration for asynchronous and blocking clients. `Runtime` is also +available as `wreq.Runtime`. + +::: wreq.runtime.Runtime + options: + show_root_heading: true diff --git a/docs/source/guide/advanced.md b/docs/source/guide/advanced.md index a7bb3987..c29c2351 100644 --- a/docs/source/guide/advanced.md +++ b/docs/source/guide/advanced.md @@ -8,6 +8,11 @@ Send data using async generators for streaming uploads: +Async upload generators run on the caller's running event loop with its context variables. +Their exceptions fail the request. When an upload ends, the generator is closed; +cancelling or dropping the upload schedules producer cancellation and cleanup on that loop. +Keep the loop running until generator cleanup has finished. This also applies to async multipart parts. + ```python import asyncio import wreq @@ -87,6 +92,60 @@ if __name__ == "__main__": asyncio.run(main()) ``` +### Custom runtimes + +Clients share a lazily started multi-thread runtime by default. Pass a `Runtime` +to choose a separate worker pool for an async or blocking client: + +```python +from wreq import Client +from wreq.runtime import Runtime + +runtime = Runtime( + workers=1, + work_steal=False, + thread_name="http-client", + max_blocking_threads=8, + thread_keep_alive=10.0, +) +client = Client(runtime=runtime) +``` + +With `work_steal=False`, Pingora uses independent single-thread Tokio runtimes. +Each client is assigned one worker for its lifetime; requests, response reads, +streams and WebSocket operations use that worker. With multiple workers, newly +created clients are assigned round-robin. This is not CPU pinning. Sharing the +same `Runtime` between clients is supported, and `client.runtime` returns its +runtime configuration and owner. + +`workers=None` uses `TOKIO_WORKER_THREADS` when it contains a positive integer, +otherwise the available parallelism. `Runtime.default()` returns the shared +multi-thread instance; its configuration is chosen when first accessed. Both +default and custom runtimes start their threads on first use. + +`thread_keep_alive` is in seconds. `max_blocking_threads` and +`thread_keep_alive` default to Tokio's settings (512 and 10 seconds). In +no-steal mode these limits apply to **each worker's** blocking pool, not the pool +as a whole. Python async upload generators still run on the caller's event loop. +Standalone multipart file preparation and upload-task cleanup can use the +shared runtime; a dedicated client runtime does not isolate Python's GIL or +every process resource. DNS resolvers are owned by individual clients so their +connections are not shared across runtimes. + +Closing a client cancels its requests but does not shut down its runtime. +Responses and streams retain the runtime independently. For explicit shutdown, +release all clients (including closed ones), responses, streams and in-flight +tasks, then call `runtime.shutdown_timeout(1.0)`. This is a blocking call which +releases the GIL. The timeout is in seconds per worker; already running blocking +tasks may outlive it. Shutdown raises `RuntimeError` while the runtime is in use +or if it is the shared default. Repeated shutdown of a custom runtime is harmless, +and `runtime.closed` reports whether it has been explicitly shut down. + +Dropping all owners automatically releases a custom runtime without synchronously +waiting for its worker threads. Invalid configuration is rejected with Python +exceptions. Pingora's internal thread-creation failures can still panic; with +the release build's `panic=abort`, this terminates the process. + ### TLS Key Logging Capture TLS keys for debugging with tools like Wireshark: diff --git a/python/wreq/__init__.py b/python/wreq/__init__.py index f92a9ae4..9dc0c8d4 100644 --- a/python/wreq/__init__.py +++ b/python/wreq/__init__.py @@ -12,6 +12,7 @@ from .dns import * from .redirect import * from .proxy import * +from .runtime import * __all__ = ( header.__all__ @@ -24,4 +25,5 @@ + dns.__all__ + redirect.__all__ + proxy.__all__ + + runtime.__all__ ) # type: ignore diff --git a/python/wreq/blocking.py b/python/wreq/blocking.py index 4a30eb75..5351c3eb 100644 --- a/python/wreq/blocking.py +++ b/python/wreq/blocking.py @@ -19,6 +19,7 @@ from .cookie import Cookie, Jar from .header import HeaderMap from .redirect import History +from .runtime import Runtime from .tls import TlsInfo @@ -211,6 +212,9 @@ class Client: A blocking client for making HTTP requests. """ + @property + def runtime(self) -> Runtime: ... + cookie_jar: Jar | None r""" Get the cookie jar used by this client (if enabled/configured). diff --git a/python/wreq/runtime.py b/python/wreq/runtime.py new file mode 100644 index 00000000..84e87d61 --- /dev/null +++ b/python/wreq/runtime.py @@ -0,0 +1,52 @@ +from typing import final + +__all__ = ["Runtime"] + + +@final +class Runtime: + """Lazy Pingora/Tokio runtime. No-steal clients keep a fixed worker, without CPU pinning.""" + + def __init__( + self, + *, + workers: int | None = None, + work_steal: bool = True, + thread_name: str = "wreq", + max_blocking_threads: int | None = None, + thread_keep_alive: float | None = None, + ) -> None: + """Thread counts must be positive; thread_keep_alive is in seconds. + + workers defaults to TOKIO_WORKER_THREADS or available parallelism. + Blocking-pool settings apply to each worker runtime in no-steal mode. + None preserves Tokio's blocking-pool defaults. + """ + ... + + @staticmethod + def default() -> "Runtime": + """Return the shared, lazy multi-thread runtime, which cannot be shut down.""" + ... + + @property + def workers(self) -> int: ... + @property + def work_steal(self) -> bool: ... + @property + def thread_name(self) -> str: ... + @property + def max_blocking_threads(self) -> int | None: ... + @property + def thread_keep_alive(self) -> float | None: ... + @property + def closed(self) -> bool: ... + + def shutdown_timeout(self, timeout: float) -> None: + """Shut down an unused custom runtime; repeated calls are harmless. + + Release clients (including closed clients), responses, streams and tasks + first, or RuntimeError is raised. timeout is finite nonnegative seconds, + per worker in no-steal mode. Running blocking work can outlive the timeout. + """ + ... diff --git a/python/wreq/wreq.py b/python/wreq/wreq.py index 744ee9b2..644d57f2 100644 --- a/python/wreq/wreq.py +++ b/python/wreq/wreq.py @@ -26,6 +26,7 @@ from .http2 import Http2Options from .proxy import * from .redirect import History +from .runtime import Runtime from .tls import * @@ -161,6 +162,8 @@ def __init__( r""" Creates a new part. + Async generators run on the caller's running event loop and are closed after use. + # Arguments - `name` - The name of the part. - `value` - The value of the part, either text, bytes, a file path, or a async or sync stream. @@ -308,7 +311,7 @@ def __iter__(self) -> "Streamer": ... def __next__(self) -> bytes | HeaderMap: ... def __enter__(self) -> Any: ... def __exit__(self, _exc_type: Any, _exc_value: Any, _traceback: Any) -> None: ... - async def __aiter__(self) -> "Streamer": ... + def __aiter__(self) -> "Streamer": ... async def __anext__(self) -> bytes | HeaderMap: ... async def __aenter__(self) -> Any: ... async def __aexit__( @@ -508,6 +511,9 @@ def __str__(self) -> str: ... class ClientConfig(TypedDict): + runtime: NotRequired[Runtime | None] + """Runtime for this client and its responses; None uses the shared default.""" + emulation: NotRequired[emulation.Emulation | emulation.Profile] """Emulation config.""" @@ -941,6 +947,8 @@ class Request(TypedDict): ] """ The body to use for the request. + Async generators run on the caller's running event loop. Upload errors fail + the request; cancellation schedules generator cleanup on that loop. """ multipart: NotRequired[Multipart] @@ -1095,6 +1103,9 @@ class Client: A client for making HTTP requests. """ + @property + def runtime(self) -> Runtime: ... + cookie_jar: Jar | None r""" Get the cookie jar used by this client (if enabled/configured). diff --git a/src/client.rs b/src/client.rs index e5f28296..86384b38 100644 --- a/src/client.rs +++ b/src/client.rs @@ -32,7 +32,7 @@ use crate::{ http1::Http1Options, http2::Http2Options, proxy::Proxy, - redirect, + redirect, runtime, tls::{Identity, KeyLog, TlsOptions, TlsVerify, TlsVersion}, }; @@ -59,6 +59,7 @@ impl_print_str!(Display, SocketAddr); /// A builder for `Client`. #[derive(Default)] struct Builder { + runtime: Option, /// The Emulation settings for the client. emulation: Option, /// The user agent to use for the client. @@ -171,6 +172,7 @@ impl FromPyObject<'_, '_> for Builder { fn extract(ob: Borrowed) -> PyResult { let mut builder = Self::default(); + extract_option!(ob, builder, runtime); extract_option!(ob, builder, emulation); extract_option!(ob, builder, user_agent); extract_option!(ob, builder, headers); @@ -233,6 +235,7 @@ impl FromPyObject<'_, '_> for Builder { #[pyclass(subclass, frozen, skip_from_py_object)] pub struct Client { inner: wreq::Client, + runtime: runtime::Executor, cancel: CancellationToken, raise_for_status: bool, @@ -255,6 +258,12 @@ impl Client { #[pyo3(signature = (**kwds))] fn new(py: Python, kwds: Option) -> PyResult { py.detach(|| { + let runtime = kwds + .as_ref() + .and_then(|config| config.runtime.as_ref()) + .map(runtime::Runtime::bind) + .transpose()? + .unwrap_or_default(); // Create the client builder. let mut builder = wreq::Client::builder(); let mut cookie_jar: Option = None; @@ -464,6 +473,7 @@ impl Client { .build() .map(|inner| Client { inner, + runtime, cancel: CancellationToken::new(), cookie_jar, raise_for_status, @@ -479,6 +489,12 @@ impl Client { self.cancel.cancel(); } + /// The runtime used by this client and its responses. + #[getter] + pub fn runtime(&self) -> runtime::Runtime { + self.runtime.runtime() + } + /// Make a GET request to the given URL. #[inline(always)] #[pyo3(signature = (url, **kwds))] @@ -586,10 +602,11 @@ impl Client { kwds: Option, ) -> PyResult { NoGIL::new_with_token( + &self.runtime, execute_request(self.clone(), method, url, kwds), cancel, self.cancel.clone(), - ) + )? .await } @@ -603,10 +620,11 @@ impl Client { kwds: Option, ) -> PyResult { NoGIL::new_with_token( + &self.runtime, execute_websocket_request(self.clone(), url, kwds), cancel, self.cancel.clone(), - ) + )? .await } } @@ -628,6 +646,10 @@ impl Client { #[pymethods] impl BlockingClient { + #[getter] + pub fn runtime(&self) -> runtime::Runtime { + self.0.runtime() + } /// Creates a new blocking Client instance. #[new] #[inline] @@ -755,7 +777,8 @@ impl BlockingClient { kwds: Option, ) -> PyResult { py.detach(|| { - pyo3_async_runtimes::tokio::get_runtime() + self.0 + .runtime .block_on(execute_request(self.0.clone(), method, url, kwds)) .map(Into::into) }) @@ -770,7 +793,8 @@ impl BlockingClient { kwds: Option, ) -> PyResult { py.detach(|| { - pyo3_async_runtimes::tokio::get_runtime() + self.0 + .runtime .block_on(execute_websocket_request(self.0.clone(), url, kwds)) .map(Into::into) }) diff --git a/src/client/body/multipart.rs b/src/client/body/multipart.rs index 51f561e5..ebf97b73 100644 --- a/src/client/body/multipart.rs +++ b/src/client/body/multipart.rs @@ -133,9 +133,12 @@ impl Part { let mut inner = match value { Value::Text(text) => multipart::Part::stream(Bytes::from_owner(text)), Value::Bytes(bytes) => multipart::Part::stream(Bytes::from_owner(bytes)), - Value::File(path) => pyo3_async_runtimes::tokio::get_runtime() - .block_on(multipart::Part::file(path)) - .map_err(Error::from)?, + Value::File(path) => crate::runtime::get()?.block_on(async move { + multipart::Part::file(path) + .await + .map_err(Error::from) + .map_err(Into::into) + })?, Value::Stream(stream) => { let stream = Body::wrap_stream(stream); match self.length { diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index e24862cb..e4ebc138 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -1,3 +1,5 @@ +mod upload; + use std::{ pin::Pin, sync::Arc, @@ -5,7 +7,7 @@ use std::{ }; use bytes::Bytes; -use futures_util::{FutureExt, Stream, StreamExt, stream::BoxStream}; +use futures_util::{FutureExt, Stream}; use http_body_util::BodyExt; use pyo3::{ coroutine::CancelHandle, @@ -15,14 +17,16 @@ use pyo3::{ }; use tokio::{sync::Mutex, task::JoinHandle}; -use crate::{buffer::PyBuffer, client::nogil::NoGIL, error::Error, header::HeaderMap}; +use crate::{ + buffer::PyBuffer, client::nogil::NoGIL, error::Error, header::HeaderMap, runtime::Executor, +}; type Pending = Option>>>; /// Python stream source. enum PyStreamSource { Sync(Arc>), - Async(Arc>>>), + Async(upload::Upload), } /// A bytes-like object that can be extracted from Python. @@ -48,7 +52,7 @@ pub struct PyStream { /// A bytes stream response. #[derive(Clone)] #[pyclass(subclass, frozen, skip_from_py_object)] -pub struct Streamer(Arc>>); +pub struct Streamer(Arc>>, Executor); // ===== impl PyStream ===== @@ -67,8 +71,8 @@ impl From for PyStream { impl Streamer { /// Create a new [`Streamer`] instance. #[inline] - pub fn new(resp: wreq::Response) -> Streamer { - Streamer(Arc::new(Mutex::new(Some(resp)))) + pub fn new(resp: wreq::Response, runtime: Executor) -> Streamer { + Streamer(Arc::new(Mutex::new(Some(resp))), runtime) } async fn next(self, error: fn() -> Error) -> PyResult { @@ -108,10 +112,7 @@ impl Streamer { #[inline] fn __next__(&self, py: Python) -> PyResult { - py.detach(|| { - pyo3_async_runtimes::tokio::get_runtime() - .block_on(self.clone().next(|| Error::StopIteration)) - }) + py.detach(|| self.1.block_on(self.clone().next(|| Error::StopIteration))) } #[inline] @@ -138,12 +139,30 @@ impl Streamer { slf } + /// Read the next frame when awaited; returns a coroutine, not a Future. #[inline] fn __anext__<'py>(&self, py: Python<'py>) -> PyResult> { - pyo3_async_runtimes::tokio::future_into_py( + let this = self.clone(); + let cancel = CancelHandle::new(); + // PyO3 0.29 cannot wrap an async __anext__ slot; use its macro constructor. + // Recheck this internal API when upgrading PyO3. + Bound::new( py, - self.clone().next(|| Error::StopAsyncIteration), + pyo3::impl_::coroutine::new_coroutine( + intern!(py, "__anext__"), + Some("Streamer"), + Some(cancel.throw_callback()), + async move { + let runtime = this.1.clone(); + let frame = + NoGIL::new(&runtime, this.next(|| Error::StopAsyncIteration), cancel)? + .await?; + // PyO3 polls this coroutine while attached, outside the Tokio task. + Python::attach(|py| frame.into_pyobject(py).map(|obj| obj.unbind())) + }, + ), ) + .map(Bound::into_any) } #[inline] @@ -160,6 +179,7 @@ impl Streamer { ) -> PyResult<()> { let this = self.0.clone(); NoGIL::new( + &self.1, async move { if let Some(resp) = this.lock().await.take() { drop(resp) @@ -167,7 +187,7 @@ impl Streamer { Ok(()) }, CancelHandle::new(), - ) + )? .await } } @@ -191,10 +211,7 @@ impl FromPyObject<'_, '_> for PyStream { fn extract(ob: Borrowed) -> PyResult { if ob.hasattr(intern!(ob.py(), "asend"))? { - pyo3_async_runtimes::tokio::into_stream_v2(ob.to_owned()) - .map(StreamExt::boxed) - .map(Mutex::new) - .map(Arc::new) + upload::Upload::new(ob.to_owned()) .map(PyStreamSource::Async) .map(PyStream::from) } else { @@ -212,37 +229,22 @@ impl Stream for PyStream { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.as_mut().get_mut(); + let ob = match &mut this.inner { + PyStreamSource::Async(stream) => return Pin::new(stream).poll_next(cx), + PyStreamSource::Sync(ob) => ob, + }; let mut pending = match this.pending.take() { Some(pending) => pending, None => { - let runtime = pyo3_async_runtimes::tokio::get_runtime(); - - // Move GIL acquisition to blocking threads to prevent blocking async runtime. - // This is crucial because holding the GIL in async tasks can block the entire - // async executor and cause deadlocks or performance degradation. - match this.inner { - PyStreamSource::Sync(ref ob) => { - let ob = ob.clone(); - runtime.spawn_blocking(move || { - Python::attach(|py| { - ob.call_method0(py, intern!(py, "__next__")) - .ok() - .map(|ob| ob.extract(py)) - }) - }) - } - PyStreamSource::Async(ref stream) => { - let stream = stream.clone(); - runtime.spawn(async move { - let ob = stream.lock().await.next().await; - tokio::task::spawn_blocking(move || { - Python::attach(|py| ob.map(|ob| ob.extract(py))) - }) - .await - .ok()? - }) - } - } + // Acquiring the interpreter must not block a Tokio worker. + let ob = ob.clone(); + tokio::task::spawn_blocking(move || { + Python::attach(|py| { + ob.call_method0(py, intern!(py, "__next__")) + .ok() + .map(|ob| ob.extract(py)) + }) + }) } }; diff --git a/src/client/body/stream/upload.rs b/src/client/body/stream/upload.rs new file mode 100644 index 00000000..6c3ee035 --- /dev/null +++ b/src/client/body/stream/upload.rs @@ -0,0 +1,131 @@ +use std::{ + pin::Pin, + task::{Context, Poll}, +}; + +use futures_util::{Stream, future::poll_fn}; +use pyo3::{coroutine::CancelHandle, intern, prelude::*, sync::PyOnceLock}; +use tokio::sync::mpsc; + +use super::PyBytesLike; + +/// Owns the upload task; dropping the body cancels it on its Python event loop. +pub struct Upload { + rx: mpsc::Receiver>, + task: Option<(Py, Py)>, +} + +#[pyclass(frozen)] +struct Sender(mpsc::Sender>); + +// ===== impl Upload ===== + +impl Upload { + pub fn new(generator: Bound<'_, PyAny>) -> PyResult { + static FORWARD: PyOnceLock> = PyOnceLock::new(); + let py = generator.py(); + let event_loop = py.import("asyncio")?.call_method0("get_running_loop")?; + let forward = FORWARD.get_or_try_init(py, || { + PyModule::from_code( + py, + c"import asyncio + +async def forward(gen, sender): + try: + try: + async for item in gen: + if not await sender.send(item, False): + return + finally: + close = getattr(gen, 'aclose', None) + if close is not None: + await close() + except BaseException as error: + await sender.send(error, True) + if isinstance(error, asyncio.CancelledError): + raise +", + c"wreq/_upload.py", + c"wreq._upload", + )? + .getattr("forward") + .map(Bound::unbind) + })?; + let (tx, rx) = mpsc::channel(1); + let coroutine = forward.bind(py).call1((generator, Sender(tx)))?; + // create_task captures the caller's contextvars on the running loop. + let task = match event_loop.call_method1("create_task", (&coroutine,)) { + Ok(task) => task, + Err(err) => { + let _ = coroutine.call_method0("close"); + return Err(err); + } + }; + Ok(Self { + rx, + task: Some((task.unbind(), event_loop.unbind())), + }) + } +} + +impl Stream for Upload { + type Item = PyResult; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.get_mut().rx.poll_recv(cx) + } +} + +impl Drop for Upload { + fn drop(&mut self) { + self.rx.close(); + if let (Some((task, event_loop)), Ok(runtime)) = (self.task.take(), crate::runtime::get()) { + // Body drop can run on Tokio: acquire the interpreter on a blocking thread. + runtime.spawn_blocking(move || { + Python::try_attach(|py| { + if let Ok(cancel) = task.bind(py).getattr(intern!(py, "cancel")) { + let _ = event_loop.call_method1( + py, + intern!(py, "call_soon_threadsafe"), + (cancel,), + ); + } + }); + }); + } + } +} + +// ===== impl Sender ===== + +#[pymethods] +impl Sender { + async fn send( + &self, + item: Py, + error: bool, + #[pyo3(cancel_handle)] mut cancel: CancelHandle, + ) -> PyResult { + let item = Python::attach(|py| { + if error { + Ok(Err(PyErr::from_value(item.into_bound(py)))) + } else { + item.extract(py).map(Ok) + } + })?; + let item = match self.0.try_send(item) { + Ok(()) => return Ok(true), + Err(mpsc::error::TrySendError::Closed(_)) => return Ok(false), + Err(mpsc::error::TrySendError::Full(item)) => item, + }; + let tx = self.0.clone(); + // Channel readiness is runtime-independent; keep this on the Python loop. + tokio::select! { + biased; + exception = poll_fn(|cx| cancel.poll_cancelled(cx)) => { + Err(Python::attach(|py| PyErr::from_value(exception.into_bound(py)))) + } + result = tx.send(item) => Ok(result.is_ok()), + } + } +} diff --git a/src/client/nogil.rs b/src/client/nogil.rs index 6a9c59a6..9b71bd25 100644 --- a/src/client/nogil.rs +++ b/src/client/nogil.rs @@ -28,27 +28,33 @@ where { /// Create [`NoGIL`] from a future #[inline] - pub fn new(fut: Fut, cancel: CancelHandle) -> Self + pub fn new( + runtime: &crate::runtime::Executor, + fut: Fut, + cancel: CancelHandle, + ) -> PyResult where Fut: Future> + Send + 'static, { - Self { - handle: AbortOnDropHandle::new(pyo3_async_runtimes::tokio::get_runtime().spawn(fut)), + Ok(Self { + handle: AbortOnDropHandle::new(Python::attach(|py| py.detach(|| runtime.spawn(fut)))), cancel, - } + }) } /// Create [`NoGIL`] from a future and a cancellation token #[inline] pub fn new_with_token( + runtime: &crate::runtime::Executor, fut: Fut, cancel: CancelHandle, cancel_token: CancellationToken, - ) -> Self + ) -> PyResult where Fut: Future> + Send + 'static, { Self::new( + runtime, async move { tokio::select! { result = fut => result, diff --git a/src/client/req.rs b/src/client/req.rs index 9ae6b98f..639497bb 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -419,7 +419,7 @@ where Ok(r) } }) - .map(Response::new) + .map(|response| Response::new(response, client.runtime.clone())) .map_err(Error::Library) .map_err(Into::into) } @@ -547,7 +547,7 @@ where // Send the WebSocket request. builder .send() - .and_then(WebSocket::new) + .and_then(|response| WebSocket::new(response, client.runtime.clone())) .await .map_err(Error::Library) .map_err(Into::into) diff --git a/src/client/resp/http.rs b/src/client/resp/http.rs index 6e0cb882..6ce23faf 100644 --- a/src/client/resp/http.rs +++ b/src/client/resp/http.rs @@ -24,6 +24,7 @@ use crate::{ header::HeaderMap, http::{StatusCode, Version}, redirect::History, + runtime::Executor, tls::TlsInfo, }; @@ -33,6 +34,7 @@ pub struct Response { uri: Uri, parts: Parts, body: Arc>, + runtime: Executor, } /// Represents the state of the HTTP response body. @@ -51,14 +53,19 @@ pub struct BlockingResponse(Response); impl Response { /// Create a new [`Response`] instance. - pub fn new(response: wreq::Response) -> Self { + pub fn new(response: wreq::Response, runtime: Executor) -> Self { let uri = response.uri().clone(); let response = HttpResponse::from(response) .map(Body::Streamable) .map(ArcSwapOption::from_pointee) .map(Arc::new); let (parts, body) = response.into_parts(); - Response { uri, parts, body } + Response { + uri, + parts, + body, + runtime, + } } /// Builds a [`wreq::Response`] from the current response metadata and the given body. @@ -212,7 +219,7 @@ impl Response { /// Get the response into a `Stream` of `Bytes` from the body. pub fn stream(&self) -> PyResult { self.stream_response() - .map(Streamer::new) + .map(|response| Streamer::new(response, self.runtime.clone())) .map_err(Into::into) } @@ -227,7 +234,7 @@ impl Response { .cache_response() .and_then(|resp| ResponseExt::text(resp, encoding)) .map_err(Into::into); - NoGIL::new(fut, cancel).await + NoGIL::new(&self.runtime, fut, cancel)?.await } /// Get the JSON content of the response. @@ -236,7 +243,7 @@ impl Response { .cache_response() .and_then(ResponseExt::json::) .map_err(Into::into); - NoGIL::new(fut, cancel).await + NoGIL::new(&self.runtime, fut, cancel)?.await } /// Get the bytes content of the response. @@ -246,7 +253,7 @@ impl Response { .and_then(ResponseExt::bytes) .map_ok(PyBuffer::from) .map_err(Into::into); - NoGIL::new(fut, cancel).await + NoGIL::new(&self.runtime, fut, cancel)?.await } /// Close the response. @@ -381,7 +388,7 @@ impl BlockingResponse { .cache_response() .and_then(|resp| ResponseExt::text(resp, encoding)) .map_err(Into::into); - pyo3_async_runtimes::tokio::get_runtime().block_on(fut) + self.0.runtime.block_on(fut) }) } @@ -393,7 +400,7 @@ impl BlockingResponse { .cache_response() .and_then(ResponseExt::json::) .map_err(Into::into); - pyo3_async_runtimes::tokio::get_runtime().block_on(fut) + self.0.runtime.block_on(fut) }) } @@ -406,7 +413,7 @@ impl BlockingResponse { .and_then(ResponseExt::bytes) .map_ok(PyBuffer::from) .map_err(Into::into); - pyo3_async_runtimes::tokio::get_runtime().block_on(fut) + self.0.runtime.block_on(fut) }) } diff --git a/src/client/resp/ws.rs b/src/client/resp/ws.rs index cf4a4394..e1d21c7f 100644 --- a/src/client/resp/ws.rs +++ b/src/client/resp/ws.rs @@ -17,6 +17,7 @@ use crate::{ error::Error, header::HeaderMap, http::{StatusCode, Version}, + runtime::Executor, }; /// A WebSocket response. @@ -43,6 +44,7 @@ pub struct WebSocket { headers: HeaderMap, protocol: Option, cmd: mpsc::UnboundedSender, + runtime: Executor, } /// A blocking WebSocket response. @@ -53,7 +55,7 @@ pub struct BlockingWebSocket(WebSocket); impl WebSocket { /// Creates a new [`WebSocket`] instance. - pub async fn new(response: WebSocketResponse) -> wreq::Result { + pub async fn new(response: WebSocketResponse, runtime: Executor) -> wreq::Result { let (version, status, remote_addr, local_addr, headers) = ( Version::from_ffi(response.version()), StatusCode(response.status()), @@ -67,6 +69,7 @@ impl WebSocket { tokio::spawn(cmd::task(websocket, rx)); Ok(WebSocket { + runtime, version, status, remote_addr, @@ -105,7 +108,7 @@ impl WebSocket { timeout: Option, ) -> PyResult> { let tx = self.cmd.clone(); - NoGIL::new(cmd::recv(tx, timeout), cancel).await + NoGIL::new(&self.runtime, cmd::recv(tx, timeout), cancel)?.await } /// Send a message to the WebSocket. @@ -116,7 +119,7 @@ impl WebSocket { message: Message, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(cmd::send(tx, message), cancel).await + NoGIL::new(&self.runtime, cmd::send(tx, message), cancel)?.await } /// Send multiple messages to the WebSocket. @@ -127,7 +130,7 @@ impl WebSocket { messages: Vec, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(cmd::send_all(tx, messages), cancel).await + NoGIL::new(&self.runtime, cmd::send_all(tx, messages), cancel)?.await } /// Close the WebSocket connection. @@ -139,7 +142,7 @@ impl WebSocket { reason: Option, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(cmd::close(tx, code, reason), cancel).await + NoGIL::new(&self.runtime, cmd::close(tx, code, reason), cancel)?.await } } @@ -158,7 +161,12 @@ impl WebSocket { _traceback: Py, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(cmd::close(tx, None, None), CancelHandle::new()).await + NoGIL::new( + &self.runtime, + cmd::close(tx, None, None), + CancelHandle::new(), + )? + .await } } @@ -218,7 +226,8 @@ impl BlockingWebSocket { #[pyo3(signature = (timeout=None))] pub fn recv(&self, py: Python, timeout: Option) -> PyResult> { py.detach(|| { - pyo3_async_runtimes::tokio::get_runtime() + self.0 + .runtime .block_on(cmd::recv(self.0.cmd.clone(), timeout)) }) } @@ -227,7 +236,8 @@ impl BlockingWebSocket { #[pyo3(signature = (message))] pub fn send(&self, py: Python, message: Message) -> PyResult<()> { py.detach(|| { - pyo3_async_runtimes::tokio::get_runtime() + self.0 + .runtime .block_on(cmd::send(self.0.cmd.clone(), message)) }) } @@ -236,7 +246,8 @@ impl BlockingWebSocket { #[pyo3(signature = (messages))] pub fn send_all(&self, py: Python, messages: Vec) -> PyResult<()> { py.detach(|| { - pyo3_async_runtimes::tokio::get_runtime() + self.0 + .runtime .block_on(cmd::send_all(self.0.cmd.clone(), messages)) }) } @@ -250,11 +261,9 @@ impl BlockingWebSocket { reason: Option, ) -> PyResult<()> { py.detach(|| { - pyo3_async_runtimes::tokio::get_runtime().block_on(cmd::close( - self.0.cmd.clone(), - code, - reason, - )) + self.0 + .runtime + .block_on(cmd::close(self.0.cmd.clone(), code, reason)) }) } } diff --git a/src/dns.rs b/src/dns.rs index 8ce0123d..841ea2ae 100644 --- a/src/dns.rs +++ b/src/dns.rs @@ -2,7 +2,7 @@ use std::{ net::{IpAddr, SocketAddr}, - sync::{Arc, OnceLock}, + sync::Arc, }; use hickory_resolver::{ @@ -71,52 +71,31 @@ impl DnsOptions { } } -// Static resolvers for each IP strategy, lazily initialized -static RESOLVER_IPV4_ONLY: OnceLock = OnceLock::new(); -static RESOLVER_IPV6_ONLY: OnceLock = OnceLock::new(); -static RESOLVER_IPV4_AND_IPV6: OnceLock = OnceLock::new(); -static RESOLVER_IPV6_THEN_IPV4: OnceLock = OnceLock::new(); -static RESOLVER_IPV4_THEN_IPV6: OnceLock = OnceLock::new(); - /// Wrapper around an [`TokioResolver`], which implements the `Resolve` trait. #[derive(Clone)] pub struct HickoryResolver { - /// Shared, lazily-initialized Tokio-based DNS resolver. - resolver: &'static TokioResolver, + // DNS connections must not outlive or cross the client's selected runtime. + resolver: TokioResolver, } impl HickoryResolver { /// Use the system DNS configuration, falling back to Cloudflare if unreadable. - /// Only successfully built resolvers are cached for each IP strategy. pub fn new(strategy: LookupIpStrategy) -> Result { - let cell = match strategy { - LookupIpStrategy::IPV4_ONLY => &RESOLVER_IPV4_ONLY, - LookupIpStrategy::IPV6_ONLY => &RESOLVER_IPV6_ONLY, - LookupIpStrategy::IPV4_AND_IPV6 => &RESOLVER_IPV4_AND_IPV6, - LookupIpStrategy::IPV6_THEN_IPV4 => &RESOLVER_IPV6_THEN_IPV4, - LookupIpStrategy::IPV4_THEN_IPV6 => &RESOLVER_IPV4_THEN_IPV6, - }; - - let resolver = if let Some(resolver) = cell.get() { - resolver - } else { - let mut builder = match TokioResolver::builder_tokio() { - Ok(resolver) => resolver, - Err(err) => { - eprintln!( - "error reading DNS system conf: {}, using Cloudflare DNS", - err - ); - TokioResolver::builder_with_config( - ResolverConfig::udp_and_tcp(&CLOUDFLARE), - TokioRuntimeProvider::default(), - ) - } - }; - builder.options_mut().ip_strategy = strategy.into_ffi(); - let resolver = builder.build().map_err(Error::Dns)?; - cell.get_or_init(|| resolver) + let mut builder = match TokioResolver::builder_tokio() { + Ok(resolver) => resolver, + Err(err) => { + eprintln!( + "error reading DNS system conf: {}, using Cloudflare DNS", + err + ); + TokioResolver::builder_with_config( + ResolverConfig::udp_and_tcp(&CLOUDFLARE), + TokioRuntimeProvider::default(), + ) + } }; + builder.options_mut().ip_strategy = strategy.into_ffi(); + let resolver = builder.build().map_err(Error::Dns)?; Ok(Self { resolver }) } } diff --git a/src/lib.rs b/src/lib.rs index 8f3b3cfc..183a9a1e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ mod http1; mod http2; mod proxy; mod redirect; +mod runtime; mod tls; use client::{ @@ -47,6 +48,7 @@ use pyo3::{ coroutine::CancelHandle, intern, prelude::*, pybacked::PyBackedStr, types::PyDict, wrap_pymodule, }; +use runtime::Runtime; #[cfg(feature = "jemalloc")] use tikv_jemallocator as _; use tls::{ @@ -339,6 +341,7 @@ fn wreq(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -357,6 +360,7 @@ fn wreq(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(websocket, m)?)?; m.add_wrapped(wrap_pymodule!(proxy_module))?; + m.add_wrapped(wrap_pymodule!(runtime_module))?; m.add_wrapped(wrap_pymodule!(dns_module))?; m.add_wrapped(wrap_pymodule!(http1_module))?; m.add_wrapped(wrap_pymodule!(http2_module))?; @@ -371,6 +375,10 @@ fn wreq(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { let sys = PyModule::import(py, intern!(py, "sys"))?; let sys_modules: Bound<'_, PyDict> = sys.getattr(intern!(py, "modules"))?.cast_into()?; sys_modules.set_item(intern!(py, "wreq.proxy"), m.getattr(intern!(py, "proxy"))?)?; + sys_modules.set_item( + intern!(py, "wreq.runtime"), + m.getattr(intern!(py, "runtime"))?, + )?; sys_modules.set_item(intern!(py, "wreq.dns"), m.getattr(intern!(py, "dns"))?)?; sys_modules.set_item(intern!(py, "wreq.http1"), m.getattr(intern!(py, "http1"))?)?; sys_modules.set_item(intern!(py, "wreq.http2"), m.getattr(intern!(py, "http2"))?)?; @@ -408,6 +416,12 @@ fn proxy_module(m: &Bound<'_, PyModule>) -> PyResult<()> { Ok(()) } +#[pymodule(gil_used = false, name = "runtime")] +fn runtime_module(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} + #[pymodule(gil_used = false, name = "dns")] fn dns_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 00000000..a32da63d --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,386 @@ +use std::{ + future::Future, + sync::{Arc, Mutex, MutexGuard, OnceLock}, + time::Duration, +}; + +use pingora_runtime::{BlockingPoolOpts, Runtime as PingoraRuntime, RuntimeBuilder}; +use pyo3::{ + exceptions::{PyRuntimeError, PyValueError}, + prelude::*, +}; +use tokio::{runtime::Handle, task::JoinHandle}; +use tokio_util::task::AbortOnDropHandle; + +/// Tokio workers shared explicitly or through the process-wide default instance. +#[derive(Clone)] +#[pyclass(frozen, skip_from_py_object, module = "wreq.runtime")] +pub struct Runtime(Arc); + +struct Inner { + workers: usize, + work_steal: bool, + thread_name: String, + max_blocking_threads: Option, + thread_keep_alive: Option, + global: bool, + state: Mutex, +} + +#[derive(Default)] +struct State { + runtime: Option, + closed: bool, + users: usize, + next_worker: usize, +} + +/// A fixed worker selection shared by a client, its responses and active tasks. +#[derive(Clone)] +pub struct Executor(Arc); + +struct Lease { + runtime: Runtime, + worker: usize, + handle: OnceLock, +} + +// ===== impl Runtime ===== + +impl Runtime { + fn global() -> Self { + static RUNTIME: OnceLock = OnceLock::new(); + RUNTIME + .get_or_init(|| { + Self(Arc::new(Inner { + workers: automatic_workers(), + work_steal: true, + thread_name: "wreq".into(), + max_blocking_threads: None, + thread_keep_alive: None, + global: true, + state: Mutex::default(), + })) + }) + .clone() + } + + pub fn bind(&self) -> PyResult { + let mut state = self.0.lock(); + if state.closed { + return Err(PyRuntimeError::new_err("Runtime is closed")); + } + Ok(self.lease(&mut state)) + } + + fn lease(&self, state: &mut State) -> Executor { + let worker = state.next_worker; + state.next_worker = (worker + 1) % self.0.workers; + state.users += 1; + Executor(Arc::new(Lease { + runtime: self.clone(), + worker, + handle: OnceLock::new(), + })) + } +} + +impl FromPyObject<'_, '_> for Runtime { + type Error = PyErr; + + fn extract(value: Borrowed<'_, '_, PyAny>) -> PyResult { + Ok(value.extract::>()?.clone()) + } +} + +#[pymethods] +impl Runtime { + /// Configure workers; OS threads start lazily when first used. + /// Without work stealing, each client stays on one worker (not CPU-pinned). + #[new] + #[pyo3(signature = (*, workers=None, work_steal=true, thread_name="wreq", max_blocking_threads=None, thread_keep_alive=None))] + fn new( + workers: Option, + work_steal: bool, + thread_name: &str, + max_blocking_threads: Option, + thread_keep_alive: Option, + ) -> PyResult { + let workers = workers.unwrap_or_else(automatic_workers); + if workers == 0 + || max_blocking_threads == Some(0) + || workers + .checked_add(max_blocking_threads.unwrap_or(512)) + .is_none() + { + return Err(PyValueError::new_err("Invalid runtime thread counts")); + } + if thread_name.contains('\0') { + return Err(PyValueError::new_err("thread_name must not contain NUL")); + } + let thread_keep_alive = thread_keep_alive.map(duration).transpose()?; + Ok(Self(Arc::new(Inner { + workers, + work_steal, + thread_name: thread_name.into(), + max_blocking_threads, + thread_keep_alive, + global: false, + state: Mutex::default(), + }))) + } + + /// Return the lazy, shared multi-thread runtime. + #[staticmethod] + #[pyo3(name = "default")] + fn shared() -> Self { + Self::global() + } + + #[getter] + fn workers(&self) -> usize { + self.0.workers + } + #[getter] + fn work_steal(&self) -> bool { + self.0.work_steal + } + #[getter] + fn thread_name(&self) -> &str { + &self.0.thread_name + } + #[getter] + fn max_blocking_threads(&self) -> Option { + self.0.max_blocking_threads + } + #[getter] + fn thread_keep_alive(&self) -> Option { + self.0.thread_keep_alive.map(|d| d.as_secs_f64()) + } + #[getter] + fn closed(&self) -> bool { + self.0.lock().closed + } + + /// Shut down an unused custom runtime. Release all clients and responses first. + /// Timeout is seconds per worker; already running blocking work may outlive it. + fn shutdown_timeout(&self, py: Python<'_>, timeout: f64) -> PyResult<()> { + let timeout = duration(timeout)?; + py.detach(|| { + let runtime = { + let mut state = self.0.lock(); + if self.0.global { + return Err(PyRuntimeError::new_err( + "The default Runtime cannot be shut down", + )); + } + if state.users != 0 { + return Err(PyRuntimeError::new_err( + "Runtime is in use; release its clients, responses and tasks first", + )); + } + state.closed = true; + state.runtime.take() + }; + if let Some(runtime) = runtime { + runtime.shutdown_timeout(timeout); + } + Ok(()) + }) + } +} + +// ===== impl Inner ===== + +impl Inner { + fn lock(&self) -> MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl Drop for Inner { + fn drop(&mut self) { + if let Some(runtime) = self + .state + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .runtime + .take() + { + // Never synchronously wait on our own worker or while holding Python's GIL. + match runtime { + PingoraRuntime::Steal { runtime, .. } => runtime.shutdown_background(), + // Dropping the controls wakes Pingora's dedicated driver threads. + PingoraRuntime::NoSteal(runtime) => drop(runtime), + } + } + } +} + +// ===== impl Executor ===== + +impl Default for Executor { + fn default() -> Self { + let runtime = Runtime::global(); + let mut state = runtime.0.lock(); + runtime.lease(&mut state) + } +} + +impl Executor { + pub fn runtime(&self) -> Runtime { + self.0.runtime.clone() + } + + pub fn handle(&self) -> Handle { + self.0 + .handle + .get_or_init(|| { + let inner = &self.0.runtime.0; + let mut state = inner.lock(); + let runtime = state.runtime.get_or_insert_with(|| { + RuntimeBuilder::new(inner.workers, &inner.thread_name) + .work_steal(inner.work_steal) + .blocking_pool_opts(BlockingPoolOpts { + max_threads: inner.max_blocking_threads, + thread_keep_alive: inner.thread_keep_alive, + }) + .build() + }); + // Serialize lazy initialization and keep the selected no-steal worker stable. + match runtime { + PingoraRuntime::NoSteal(runtime) => { + runtime.get_runtime_at(self.0.worker).clone() + } + runtime => runtime.get_handle().clone(), + } + }) + .clone() + } + + pub fn spawn(&self, future: F) -> JoinHandle + where + F: Future + Send + 'static, + F::Output: Send + 'static, + { + let owner = self.clone(); + self.handle().spawn(async move { + let _owner = owner; + future.await + }) + } + + pub fn spawn_blocking(&self, function: F) -> JoinHandle + where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, + { + let owner = self.clone(); + self.handle().spawn_blocking(move || { + let _owner = owner; + function() + }) + } + + /// Only the join is polled on the caller; network work stays on our worker. + pub fn block_on(&self, future: F) -> PyResult + where + F: Future> + Send + 'static, + T: Send + 'static, + { + self.handle() + .block_on(AbortOnDropHandle::new(self.spawn(future))) + .map_err(|err| PyRuntimeError::new_err(err.to_string()))? + } +} + +// ===== impl Lease ===== + +impl Drop for Lease { + fn drop(&mut self) { + self.runtime.0.lock().users -= 1; + } +} + +pub fn get() -> PyResult { + Runtime::global().bind() +} + +fn automatic_workers() -> usize { + std::env::var("TOKIO_WORKER_THREADS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or_else(|| std::thread::available_parallelism().map_or(1, usize::from)) +} + +fn duration(seconds: f64) -> PyResult { + Duration::try_from_secs_f64(seconds).map_err(|_| { + PyValueError::new_err("Duration must be finite, nonnegative and representable") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_workers_and_lifetime() { + let runtime = Runtime::new(Some(2), false, "wreq-affinity", Some(2), None).unwrap(); + let first = runtime.bind().unwrap(); + let second = runtime.bind().unwrap(); + assert!(runtime.0.lock().runtime.is_none()); + let caller = std::thread::current().id(); + let mut ids = Vec::new(); + for executor in [&first, &second, &first, &second] { + let id = executor + .block_on(async { + let thread = std::thread::current().id(); + for _ in 0..8 { + tokio::task::yield_now().await; + assert_eq!(thread, std::thread::current().id()); + } + let child = tokio::spawn(async { std::thread::current().id() }) + .await + .unwrap(); + assert_eq!(thread, child); + Ok(thread) + }) + .unwrap(); + assert_ne!(id, caller); + ids.push(id); + } + assert_eq!(ids[0], ids[2]); + assert_eq!(ids[1], ids[3]); + assert_ne!(ids[0], ids[1]); + assert_eq!(runtime.0.lock().users, 2); + drop((first, second)); + assert_eq!(runtime.0.lock().users, 0); + } + + #[test] + fn last_owner_can_be_released_on_a_worker() { + for steal in [false, true] { + let runtime = Runtime::new(Some(1), steal, "wreq-drop", None, None).unwrap(); + let weak = Arc::downgrade(&runtime.0); + let executor = runtime.bind().unwrap(); + let (tx, rx) = tokio::sync::oneshot::channel(); + let (done, wait) = std::sync::mpsc::channel(); + let task = executor.spawn(async move { + let _ = rx.await; + done.send(()).unwrap(); + }); + drop((runtime, executor)); + tx.send(()).unwrap(); + wait.recv_timeout(Duration::from_secs(5)).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while weak.upgrade().is_some() && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(weak.upgrade().is_none()); + drop(task); + } + } +} diff --git a/tests/cancellation_test.py b/tests/cancellation_test.py index 2bc55669..1589e8e1 100644 --- a/tests/cancellation_test.py +++ b/tests/cancellation_test.py @@ -35,7 +35,9 @@ async def accept(reader, writer): @pytest.mark.asyncio -@pytest.mark.parametrize("operation", ["request", "request_error", "bytes", "text", "json"]) +@pytest.mark.parametrize( + "operation", ["request", "request_error", "bytes", "text", "json", "stream"] +) async def test_cancellation_after_rust_completion(operation): async with local_server() as (url, connections), wreq.Client(proxies=[]) as client: response = None @@ -49,7 +51,11 @@ async def test_cancellation_after_rust_completion(operation): writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n") await writer.drain() response = await asyncio.wait_for(task, 5) - coroutine = getattr(response, operation)() + coroutine = ( + anext(response.stream()) + if operation == "stream" + else getattr(response, operation)() + ) waiter = coroutine.send(None) try: @@ -111,3 +117,80 @@ async def test_pending_request_cancellation(action): # The cancelled operation must release its pending network request. assert await asyncio.wait_for(reader.read(), 5) == b"" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["cancel", "close_coroutine"]) +async def test_pending_stream_cancellation(action): + async with local_server() as (url, connections), wreq.Client(proxies=[]) as client: + task = asyncio.create_task(client.get(url)) + _, writer = await asyncio.wait_for(connections.get(), 5) + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n") + await writer.drain() + response = await asyncio.wait_for(task, 5) + stream = response.stream() + coroutine = anext(stream) + + if action == "close_coroutine": + assert isinstance(coroutine.send(None), asyncio.Future) + coroutine.close() + else: + started = asyncio.Event() + + async def read(): + started.set() + return await coroutine + + task = asyncio.create_task(read()) + await started.wait() + task.cancel("cancel stream read") + with pytest.raises(asyncio.CancelledError, match="cancel stream read"): + await asyncio.wait_for(task, 5) + + # Closing the stream must acquire the lock held by the pending read. + # Do not send a body: that would let a leaked read release it naturally. + await asyncio.wait_for(stream.__aexit__(None, None, None), 5) + with pytest.raises(StopAsyncIteration): + await anext(stream) + await response.close() + + +@pytest.mark.asyncio +async def test_stream_coroutine_iteration(): + async with local_server() as (url, connections), wreq.Client(proxies=[]) as client: + task = asyncio.create_task(client.get(url)) + _, writer = await asyncio.wait_for(connections.get(), 5) + writer.write( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + b"Trailer: x-check\r\n\r\n1\r\na\r\n" + ) + await writer.drain() + response = await asyncio.wait_for(task, 5) + async with response.stream() as stream: + deferred = stream.__anext__() + assert asyncio.iscoroutine(deferred) + assert not isinstance(deferred, asyncio.Future) + assert deferred.__qualname__ == "Streamer.__anext__" + assert not hasattr(stream, "_anext") + try: + # An unawaited __anext__ must not consume the first frame. + assert await asyncio.wait_for(anext(stream), 5) == b"a" + writer.write(b"1\r\nb\r\n0\r\nx-check: done\r\n\r\n") + await writer.drain() + assert await asyncio.wait_for(deferred, 5) == b"b" + with pytest.raises( + RuntimeError, match="cannot reuse already awaited coroutine" + ): + await deferred + finally: + deferred.close() + + async with asyncio.timeout(5): + frames = [frame async for frame in stream] + assert len(frames) == 1 + assert isinstance(frames[0], wreq.HeaderMap) + assert frames[0]["x-check"] == b"done" + with pytest.raises(StopAsyncIteration): + await stream.__anext__() + assert await anext(stream, None) is None + await response.close() diff --git a/tests/runtime_test.py b/tests/runtime_test.py new file mode 100644 index 00000000..720b0769 --- /dev/null +++ b/tests/runtime_test.py @@ -0,0 +1,314 @@ +import asyncio +import base64 +import gc +import hashlib + +import pytest +import wreq +from wreq.runtime import Runtime + +from cancellation_test import local_server +from upload_test import read_chunked + + +def test_runtime_configuration_and_shutdown(): + assert Runtime is wreq.Runtime + assert Runtime is wreq.runtime.Runtime + assert Runtime.__module__ == "wreq.runtime" + for kwargs in ( + {"workers": 0}, + {"max_blocking_threads": 0}, + {"thread_name": "bad\0name"}, + {"thread_keep_alive": -1}, + {"thread_keep_alive": float("inf")}, + {"thread_keep_alive": float("nan")}, + ): + with pytest.raises(ValueError): + wreq.Runtime(**kwargs) + + runtime = wreq.Runtime( + workers=2, + work_steal=False, + thread_name="isolated", + max_blocking_threads=3, + thread_keep_alive=0.25, + ) + assert (runtime.workers, runtime.work_steal, runtime.thread_name) == ( + 2, + False, + "isolated", + ) + assert (runtime.max_blocking_threads, runtime.thread_keep_alive) == (3, 0.25) + with pytest.raises(AttributeError): + runtime.workers = 4 + with pytest.raises(TypeError): + wreq.Client(runtime=object()) + client = wreq.Client(runtime=runtime) + alias = client.runtime + client.close() + # close cancels requests; the still-live client continues owning its runtime. + with pytest.raises(RuntimeError, match="in use"): + runtime.shutdown_timeout(0) + del client + runtime.shutdown_timeout(0) + runtime.shutdown_timeout(0) + assert alias.closed + with pytest.raises(RuntimeError, match="closed"): + wreq.Client(runtime=runtime) + for timeout in (-1, float("inf"), float("nan")): + with pytest.raises(ValueError): + runtime.shutdown_timeout(timeout) + default = wreq.Runtime.default() + assert default.work_steal and default.workers > 0 + with pytest.raises(RuntimeError, match="default"): + default.shutdown_timeout(0) + assert "Runtime" in wreq.__all__ + + +async def shutdown(runtime): + # Aborted Rust tasks may finish releasing their owners after cancellation returns. + for _ in range(100): + gc.collect() + try: + runtime.shutdown_timeout(0.1) + return + except RuntimeError as error: + if "in use" not in str(error): + raise + await asyncio.sleep(0.01) + runtime.shutdown_timeout(0.1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("steal", [False, True]) +async def test_response_and_stream_keep_runtime_alive(steal): + runtime = wreq.Runtime(workers=1, work_steal=steal) + async with local_server() as (url, connections): + client = wreq.Client(runtime=runtime, proxies=[]) + task = asyncio.create_task(client.get(url)) + _, writer = await asyncio.wait_for(connections.get(), 5) + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n") + await writer.drain() + response = await asyncio.wait_for(task, 5) + del task + client.close() + del client + with pytest.raises(RuntimeError, match="in use"): + runtime.shutdown_timeout(0) + stream = response.stream() + del response + with pytest.raises(RuntimeError, match="in use"): + runtime.shutdown_timeout(0) + writer.write(b"body") + await writer.drain() + assert await asyncio.wait_for(anext(stream), 5) == b"body" + with pytest.raises(StopAsyncIteration): + await anext(stream) + del stream + await shutdown(runtime) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("steal", [False, True]) +async def test_shared_runtime_cancellation_and_upload(steal): + runtime = wreq.Runtime(workers=2, work_steal=steal, max_blocking_threads=2) + async with local_server() as (url, connections): + first = wreq.Client(runtime=runtime, proxies=[]) + second = wreq.Client(runtime=runtime, proxies=[]) + pending = asyncio.create_task(first.get(url)) + await asyncio.wait_for(connections.get(), 5) + first.close() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(pending, 5) + del pending, first + finalized = asyncio.Event() + + async def chunks(): + try: + yield b"custom " + await asyncio.sleep(0) + yield b"runtime" + finally: + finalized.set() + + task = asyncio.create_task(second.post(url, body=chunks())) + reader, writer = await asyncio.wait_for(connections.get(), 5) + assert await asyncio.wait_for(read_chunked(reader), 5) == b"custom runtime" + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + await writer.drain() + response = await asyncio.wait_for(task, 5) + assert await response.json() == {} + await asyncio.wait_for(finalized.wait(), 5) + await response.close() + second.close() + del response, task, second + await shutdown(runtime) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("steal", [False, True]) +async def test_blocking_client_uses_custom_runtime(steal): + runtime = wreq.Runtime(workers=1, work_steal=steal, max_blocking_threads=2) + + def request(url): + with wreq.blocking.Client(runtime=runtime, proxies=[]) as client: + assert client.runtime.work_steal == steal + with client.post(url, body=iter((b"blocking",))) as response: + with response.stream() as stream: + return b"".join(stream) + + async with local_server() as (url, connections): + task = asyncio.create_task(asyncio.to_thread(request, url)) + reader, writer = await asyncio.wait_for(connections.get(), 5) + assert await asyncio.wait_for(read_chunked(reader), 5) == b"blocking" + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + await writer.drain() + assert await asyncio.wait_for(task, 5) == b"ok" + del task + await shutdown(runtime) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("blocking", [False, True]) +async def test_websocket_outlives_client(blocking): + connections = asyncio.Queue() + + async def accept(reader, writer): + header = await reader.readuntil(b"\r\n\r\n") + key = next( + line.split(b":", 1)[1].strip() + for line in header.split(b"\r\n") + if line.lower().startswith(b"sec-websocket-key:") + ) + digest = base64.b64encode( + hashlib.sha1(key + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest() + ) + writer.write( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n" + b"Connection: Upgrade\r\nSec-WebSocket-Accept: " + digest + b"\r\n\r\n" + ) + await writer.drain() + connections.put_nowait((reader, writer)) + + server = await asyncio.start_server(accept, "127.0.0.1", 0) + runtime = wreq.Runtime(workers=1, work_steal=False) + writer = None + try: + url = f"ws://127.0.0.1:{server.sockets[0].getsockname()[1]}/" + client = (wreq.blocking.Client if blocking else wreq.Client)( + runtime=runtime, proxies=[] + ) + ws = ( + await asyncio.to_thread(client.websocket, url) + if blocking + else await client.websocket(url) + ) + reader, writer = await asyncio.wait_for(connections.get(), 5) + client.close() + del client + with pytest.raises(RuntimeError, match="in use"): + runtime.shutdown_timeout(0) + writer.write(b"\x81\x04pong") + await writer.drain() + message = await asyncio.to_thread(ws.recv) if blocking else await ws.recv() + assert message.text == "pong" + outgoing = wreq.Message.from_text("ping") + if blocking: + await asyncio.to_thread(ws.send, outgoing) + else: + await ws.send(outgoing) + frame = await asyncio.wait_for(reader.readexactly(10), 5) + assert frame[:2] == b"\x81\x84" + assert ( + bytes(byte ^ frame[2 + i % 4] for i, byte in enumerate(frame[6:])) + == b"ping" + ) + if blocking: + await asyncio.to_thread(ws.close) + else: + await ws.close() + del ws + finally: + if writer is not None: + writer.close() + await writer.wait_closed() + server.close() + await server.wait_closed() + await shutdown(runtime) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("steal", [False, True]) +async def test_http2_multiplexing_on_custom_runtime(steal): + # Minimal h2c responder: indexed :status=200, then a two-byte DATA frame. + # No HPACK decoder is needed because the test does not inspect request headers. + def frame(kind, flags, stream, payload=b""): + return ( + len(payload).to_bytes(3, "big") + + bytes((kind, flags)) + + stream.to_bytes(4, "big") + + payload + ) + + connections = [] + handlers = set() + errors = [] + + async def accept(reader, writer): + handlers.add(asyncio.current_task()) + connections.append(writer) + try: + assert await reader.readexactly(24) == b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + writer.write(frame(4, 0, 0)) + await writer.drain() + while True: + header = await reader.readexactly(9) + payload = await reader.readexactly(int.from_bytes(header[:3], "big")) + kind, flags = header[3:5] + stream = int.from_bytes(header[5:], "big") & 0x7FFFFFFF + if kind == 4 and not flags & 1: + writer.write(frame(4, 1, 0)) + elif kind == 6 and not flags & 1: + writer.write(frame(6, 1, 0, payload)) + elif kind == 1: + assert flags & 4 # These small requests fit in one header block. + writer.write( + frame(1, 4, stream, b"\x88") + frame(0, 1, stream, b"ok") + ) + await writer.drain() + except (asyncio.IncompleteReadError, ConnectionError): + pass + except Exception as error: + errors.append(error) + finally: + handlers.discard(asyncio.current_task()) + + runtime = wreq.Runtime(workers=2, work_steal=steal) + server = await asyncio.start_server(accept, "127.0.0.1", 0) + try: + url = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/" + client = wreq.Client(runtime=runtime, http2_only=True, proxies=[]) + + async def request(): + response = await client.get(url) + assert response.version == wreq.Version.HTTP_2 + # Consume without close(), which intentionally forbids connection reuse. + return await response.bytes() + + assert await asyncio.wait_for(request(), 5) == b"ok" + assert ( + await asyncio.wait_for(asyncio.gather(*(request() for _ in range(16))), 5) + == [b"ok"] * 16 + ) + assert len(connections) == 1 + assert not errors + client.close() + del client + finally: + server.close() + for writer in connections: + writer.close() + await asyncio.gather(*(writer.wait_closed() for writer in connections)) + await asyncio.gather(*handlers) + await server.wait_closed() + await shutdown(runtime) diff --git a/tests/upload_test.py b/tests/upload_test.py new file mode 100644 index 00000000..4102833c --- /dev/null +++ b/tests/upload_test.py @@ -0,0 +1,148 @@ +import asyncio +import contextvars +import threading + +import pytest +import wreq + +from cancellation_test import local_server + + +async def read_chunked(reader): + body = bytearray() + while True: + size = int(await reader.readline(), 16) + if not size: + assert await reader.readline() == b"\r\n" + return bytes(body) + body.extend(await reader.readexactly(size)) + assert await reader.readexactly(2) == b"\r\n" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("multipart", [False, True]) +async def test_async_upload(multipart): + context = contextvars.ContextVar("upload_context", default="missing") + context.set("caller") + thread = threading.get_ident() + closed = asyncio.Event() + + async def chunks(): + try: + for item in (b"hello ", "world"): + await asyncio.sleep(0) + assert context.get() == "caller" + assert threading.get_ident() == thread + yield item + finally: + closed.set() + + async with local_server() as (url, connections), wreq.Client(proxies=[]) as client: + kwds = ( + {"multipart": wreq.Multipart(wreq.Part(name="file", value=chunks()))} + if multipart + else {"body": chunks()} + ) + task = asyncio.create_task(client.post(url, **kwds)) + reader, writer = await asyncio.wait_for(connections.get(), 5) + body = await asyncio.wait_for(read_chunked(reader), 5) + assert (b"hello world" in body) if multipart else (body == b"hello world") + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + await writer.drain() + response = await asyncio.wait_for(task, 5) + await response.close() + assert closed.is_set() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["exception", "type", "cancelled"]) +async def test_upload_errors(failure): + closed = asyncio.Event() + + async def chunks(): + try: + yield b"first" + if failure == "exception": + raise ValueError("upload exploded") + if failure == "cancelled": + raise asyncio.CancelledError("generator cancelled") + yield object() + finally: + closed.set() + + async with local_server() as (url, _), wreq.Client(proxies=[]) as client: + with pytest.raises(wreq.exceptions.RequestError): + await asyncio.wait_for(client.post(url, body=chunks()), 5) + await asyncio.wait_for(closed.wait(), 5) + + +@pytest.mark.asyncio +async def test_unconsumed_upload_backpressure(): + produced = [] + full = asyncio.Event() + closed = asyncio.Event() + + async def chunks(): + try: + for index in range(100): + produced.append(index) + if index == 1: + full.set() + yield b"chunk" + finally: + closed.set() + + # A multipart part retains the body without polling its Rust stream. + part = wreq.Part(name="file", value=chunks()) + await asyncio.wait_for(full.wait(), 5) + await asyncio.sleep(0.05) + assert produced == [0, 1] + del part + await asyncio.wait_for(closed.wait(), 5) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("read", ["bytes", "json", "stream"]) +async def test_blocking_runtime(read): + def request(url): + with wreq.blocking.Client(proxies=[]) as client: + with client.post(url, body=iter((b"sync", b" upload"))) as response: + if read == "stream": + with response.stream() as stream: + return b"".join(stream) + return getattr(response, read)() + + async with local_server() as (url, connections): + task = asyncio.create_task(asyncio.to_thread(request, url)) + reader, writer = await asyncio.wait_for(connections.get(), 5) + assert await asyncio.wait_for(read_chunked(reader), 5) == b"sync upload" + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + await writer.drain() + assert await asyncio.wait_for(task, 5) == ({} if read == "json" else b"{}") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["cancel", "close_client"]) +async def test_upload_cancellation(action): + started = asyncio.Event() + closed = asyncio.Event() + + async def chunks(): + try: + yield b"first" + started.set() + await asyncio.Event().wait() + finally: + await asyncio.sleep(0) + closed.set() + + async with local_server() as (url, _), wreq.Client(proxies=[]) as client: + task = asyncio.create_task(client.post(url, body=chunks())) + await asyncio.wait_for(started.wait(), 5) + if action == "cancel": + task.cancel("stop upload") + else: + client.close() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 5) + await asyncio.wait_for(closed.wait(), 5) From 163d4437af42151683ec997b0ac8feefe759b761 Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 29 Sep 2026 22:11:59 +0800 Subject: [PATCH 03/15] refactor(runtime): remove explicit default accessor --- docs/source/guide/advanced.md | 11 ++++++----- python/wreq/runtime.py | 5 ----- src/runtime.rs | 7 ------- tests/runtime_test.py | 13 +++++++++---- 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/docs/source/guide/advanced.md b/docs/source/guide/advanced.md index c29c2351..c1e9c0cb 100644 --- a/docs/source/guide/advanced.md +++ b/docs/source/guide/advanced.md @@ -94,8 +94,9 @@ if __name__ == "__main__": ### Custom runtimes -Clients share a lazily started multi-thread runtime by default. Pass a `Runtime` -to choose a separate worker pool for an async or blocking client: +Clients share a lazily started global multi-thread runtime when `runtime` is +omitted or `None`. Pass a `Runtime` to choose a separate worker pool for an +async or blocking client: ```python from wreq import Client @@ -119,9 +120,9 @@ same `Runtime` between clients is supported, and `client.runtime` returns its runtime configuration and owner. `workers=None` uses `TOKIO_WORKER_THREADS` when it contains a positive integer, -otherwise the available parallelism. `Runtime.default()` returns the shared -multi-thread instance; its configuration is chosen when first accessed. Both -default and custom runtimes start their threads on first use. +otherwise the available parallelism. The global runtime's configuration is +chosen when first accessed. Both default and custom runtimes start their +threads on first use. `thread_keep_alive` is in seconds. `max_blocking_threads` and `thread_keep_alive` default to Tokio's settings (512 and 10 seconds). In diff --git a/python/wreq/runtime.py b/python/wreq/runtime.py index 84e87d61..2f54fd87 100644 --- a/python/wreq/runtime.py +++ b/python/wreq/runtime.py @@ -24,11 +24,6 @@ def __init__( """ ... - @staticmethod - def default() -> "Runtime": - """Return the shared, lazy multi-thread runtime, which cannot be shut down.""" - ... - @property def workers(self) -> int: ... @property diff --git a/src/runtime.rs b/src/runtime.rs index a32da63d..6821f6d5 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -130,13 +130,6 @@ impl Runtime { }))) } - /// Return the lazy, shared multi-thread runtime. - #[staticmethod] - #[pyo3(name = "default")] - fn shared() -> Self { - Self::global() - } - #[getter] fn workers(&self) -> usize { self.0.workers diff --git a/tests/runtime_test.py b/tests/runtime_test.py index 720b0769..d01a9e59 100644 --- a/tests/runtime_test.py +++ b/tests/runtime_test.py @@ -58,10 +58,15 @@ def test_runtime_configuration_and_shutdown(): for timeout in (-1, float("inf"), float("nan")): with pytest.raises(ValueError): runtime.shutdown_timeout(timeout) - default = wreq.Runtime.default() - assert default.work_steal and default.workers > 0 - with pytest.raises(RuntimeError, match="default"): - default.shutdown_timeout(0) + assert not hasattr(Runtime, "default") + for factory in (wreq.Client, wreq.blocking.Client): + for kwargs in ({}, {"runtime": None}): + client = factory(**kwargs) + default = client.runtime + assert default.work_steal and default.workers > 0 + with pytest.raises(RuntimeError, match="default"): + default.shutdown_timeout(0) + client.close() assert "Runtime" in wreq.__all__ From b38935156b9cc762bed1535c96631082606d45df Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 29 Sep 2026 22:15:44 +0800 Subject: [PATCH 04/15] docs(runtime): describe the generic Tokio runtime wrapper --- docs/source/guide/advanced.md | 4 ++-- python/wreq/runtime.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/guide/advanced.md b/docs/source/guide/advanced.md index c1e9c0cb..bff49560 100644 --- a/docs/source/guide/advanced.md +++ b/docs/source/guide/advanced.md @@ -112,7 +112,7 @@ runtime = Runtime( client = Client(runtime=runtime) ``` -With `work_steal=False`, Pingora uses independent single-thread Tokio runtimes. +With `work_steal=False`, workers use independent single-thread Tokio runtimes. Each client is assigned one worker for its lifetime; requests, response reads, streams and WebSocket operations use that worker. With multiple workers, newly created clients are assigned round-robin. This is not CPU pinning. Sharing the @@ -144,7 +144,7 @@ and `runtime.closed` reports whether it has been explicitly shut down. Dropping all owners automatically releases a custom runtime without synchronously waiting for its worker threads. Invalid configuration is rejected with Python -exceptions. Pingora's internal thread-creation failures can still panic; with +exceptions. Internal thread-creation failures can still panic; with the release build's `panic=abort`, this terminates the process. ### TLS Key Logging diff --git a/python/wreq/runtime.py b/python/wreq/runtime.py index 2f54fd87..8c785408 100644 --- a/python/wreq/runtime.py +++ b/python/wreq/runtime.py @@ -5,7 +5,7 @@ @final class Runtime: - """Lazy Pingora/Tokio runtime. No-steal clients keep a fixed worker, without CPU pinning.""" + """Lazy Tokio runtime wrapper. No-steal clients keep a fixed worker, without CPU pinning.""" def __init__( self, From c29273cc0337e07c660432ab59db1687b758d769 Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 29 Sep 2026 22:40:32 +0800 Subject: [PATCH 05/15] fix(client): guard interpreter shutdown with try_attach --- docs/source/guide/advanced.md | 3 +- src/client/body/stream.rs | 5 +- src/client/body/stream/upload.rs | 4 +- src/client/nogil.rs | 39 +++++++++++--- src/redirect.rs | 5 +- tests/redirect_test.py | 34 ++++++++++++ tests/shutdown_test.py | 91 ++++++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 tests/shutdown_test.py diff --git a/docs/source/guide/advanced.md b/docs/source/guide/advanced.md index bff49560..72691780 100644 --- a/docs/source/guide/advanced.md +++ b/docs/source/guide/advanced.md @@ -144,8 +144,7 @@ and `runtime.closed` reports whether it has been explicitly shut down. Dropping all owners automatically releases a custom runtime without synchronously waiting for its worker threads. Invalid configuration is rejected with Python -exceptions. Internal thread-creation failures can still panic; with -the release build's `panic=abort`, this terminates the process. +exceptions. ### TLS Key Logging diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index e4ebc138..6c568512 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -239,11 +239,14 @@ impl Stream for PyStream { // Acquiring the interpreter must not block a Tokio worker. let ob = ob.clone(); tokio::task::spawn_blocking(move || { - Python::attach(|py| { + Python::try_attach(|py| { ob.call_method0(py, intern!(py, "__next__")) .ok() .map(|ob| ob.extract(py)) }) + // Once Python is unavailable, stop reading without creating + // a PyErr that could require another attachment to format. + .flatten() }) } }; diff --git a/src/client/body/stream/upload.rs b/src/client/body/stream/upload.rs index 6c3ee035..2ea00b50 100644 --- a/src/client/body/stream/upload.rs +++ b/src/client/body/stream/upload.rs @@ -8,6 +8,7 @@ use pyo3::{coroutine::CancelHandle, intern, prelude::*, sync::PyOnceLock}; use tokio::sync::mpsc; use super::PyBytesLike; +use crate::client::nogil::poll_with_guard; /// Owns the upload task; dropping the body cancels it on its Python event loop. pub struct Upload { @@ -120,12 +121,13 @@ impl Sender { }; let tx = self.0.clone(); // Channel readiness is runtime-independent; keep this on the Python loop. + let mut send = std::pin::pin!(tx.send(item)); tokio::select! { biased; exception = poll_fn(|cx| cancel.poll_cancelled(cx)) => { Err(Python::attach(|py| PyErr::from_value(exception.into_bound(py)))) } - result = tx.send(item) => Ok(result.is_ok()), + result = poll_fn(|cx| poll_with_guard(send.as_mut(), cx)) => Ok(result.is_ok()), } } } diff --git a/src/client/nogil.rs b/src/client/nogil.rs index 9b71bd25..a5a0558f 100644 --- a/src/client/nogil.rs +++ b/src/client/nogil.rs @@ -1,7 +1,8 @@ use std::{ future::Future, pin::Pin, - task::{Context, Poll}, + sync::Arc, + task::{Context, Poll, Wake, Waker}, }; use pin_project_lite::pin_project; @@ -22,6 +23,10 @@ pin_project! { } } +struct GuardedWaker(Waker); + +// ===== impl NoGIL ===== + impl NoGIL where T: Send + 'static, @@ -85,11 +90,33 @@ where let waker = cx.waker(); Python::attach(|py| { - py.detach(|| match this.handle.poll(&mut Context::from_waker(waker)) { - Poll::Ready(Ok(result)) => Poll::Ready(result), - Poll::Ready(Err(e)) => Poll::Ready(Err(PyRuntimeError::new_err(e.to_string()))), - Poll::Pending => Poll::Pending, - }) + py.detach( + || match poll_with_guard(this.handle, &mut Context::from_waker(waker)) { + Poll::Ready(Ok(result)) => Poll::Ready(result), + Poll::Ready(Err(e)) => Poll::Ready(Err(PyRuntimeError::new_err(e.to_string()))), + Poll::Pending => Poll::Pending, + }, + ) }) } } + +/// Protect Python wakers retained by futures that can be woken from Rust threads. +pub fn poll_with_guard(future: Pin<&mut F>, cx: &mut Context<'_>) -> Poll { + let waker = Waker::from(Arc::new(GuardedWaker(cx.waker().clone()))); + future.poll(&mut Context::from_waker(&waker)) +} + +// ===== impl GuardedWaker ===== + +impl Wake for GuardedWaker { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + // PyO3's nested attach reuses this attachment. If Python is unavailable, + // skip the wake instead of invoking its infallible attachment path. + Python::try_attach(|_| self.0.wake_by_ref()); + } +} diff --git a/src/redirect.rs b/src/redirect.rs index 353f0555..97b0191a 100644 --- a/src/redirect.rs +++ b/src/redirect.rs @@ -103,13 +103,16 @@ impl Policy { attempt.pending(|attempt| async move { let args = Attempt::from(&attempt); let kind = tokio::task::spawn_blocking(move || { - Python::attach(|py| { + Python::try_attach(|py| { callback .call1(py, (args,)) .and_then(|result| result.extract::(py).map_err(PyErr::from)) .map(|action| action.kind) .unwrap_or_else(|err| ActionKind::Error(err.to_string())) }) + .unwrap_or_else(|| { + ActionKind::Error("The Python interpreter is not available".into()) + }) }) .await; diff --git a/tests/redirect_test.py b/tests/redirect_test.py index 0f5f9703..a7afe7d5 100644 --- a/tests/redirect_test.py +++ b/tests/redirect_test.py @@ -1,10 +1,44 @@ +import asyncio + import pytest import wreq from wreq import redirect +from cancellation_test import local_server + client = wreq.Client(redirect=redirect.Policy.limited(10)) +@pytest.mark.asyncio +@pytest.mark.parametrize("fail", [False, True]) +async def test_custom_redirect_callback(fail): + def callback(attempt): + if fail: + raise ValueError("redirect callback failed") + return attempt.stop() + + async with ( + local_server() as (url, connections), + wreq.Client(proxies=[], redirect=redirect.Policy.custom(callback)) as client, + ): + task = asyncio.create_task(client.get(url)) + _, writer = await asyncio.wait_for(connections.get(), 5) + writer.write( + b"HTTP/1.1 302 Found\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n" + ) + await writer.drain() + if fail: + with pytest.raises( + wreq.exceptions.RequestError, + match="ValueError: redirect callback failed", + ): + await asyncio.wait_for(task, 5) + else: + response = await asyncio.wait_for(task, 5) + assert response.status.is_redirection() + await response.close() + + @pytest.mark.asyncio @pytest.mark.flaky(reruns=3, reruns_delay=2) async def test_request_disable_redirect(): diff --git a/tests/shutdown_test.py b/tests/shutdown_test.py new file mode 100644 index 00000000..61305958 --- /dev/null +++ b/tests/shutdown_test.py @@ -0,0 +1,91 @@ +import platform +import subprocess +import sys + +import pytest + +# Keep a pending request alive into CPython module teardown, then fail its I/O. +# Upload mode also wakes the Python producer waiting for channel capacity. +SCRIPT = """ +import asyncio +import os +import socket +import sys +import threading +from types import FunctionType + +import wreq + +listener = socket.socket() +listener.bind(("127.0.0.1", 0)) +listener.listen(8) +listener.settimeout(5) +url = f"http://127.0.0.1:{listener.getsockname()[1]}/" + + +wait_lock = threading.Lock() +wait_lock.acquire() + + +class HoldTeardown: + def __init__(self, peer, task): + self.peer = peer + self.task = task + + def __del__(self, write=os.write, wait=wait_lock.acquire): + self.peer.close() + write(2, b"teardown: connection closed\\n") + wait(timeout=1) + write(2, b"teardown: wait complete\\n") + + +async def chunks(): + chunk = b"x" * (1024 * 1024) + while True: + yield chunk + + +# Do not let the suspended generator retain this module's teardown sentinel. +chunks = FunctionType(chunks.__code__, {}) +loop = asyncio.new_event_loop() +client = wreq.Client(proxies=[]) +upload = sys.argv[1] == "upload" +task = loop.create_task( + client.post(url, body=chunks()) if upload else client.get(url) +) +loop.run_until_complete(asyncio.sleep(0.05)) +peer, _ = listener.accept() +listener.close() +peer.settimeout(5) +assert peer.recv(4096), "request did not reach the server" +loop.run_until_complete(asyncio.sleep(0.1)) +assert not task.done(), "request must remain pending" +if upload: + assert any( + getattr(getattr(t.get_coro(), "cr_await", None), "__name__", None) == "send" + for t in asyncio.all_tasks(loop) + ), "upload producer must be waiting for channel capacity" +hold = HoldTeardown(peer, task) +del peer, task +raise RuntimeError("uncaught error while a request is in flight") +""" + + +@pytest.mark.skipif( + platform.python_implementation() != "CPython", + reason="requires CPython module teardown to run __del__", +) +@pytest.mark.parametrize("operation", ["request", "upload"]) +def test_shutdown_wake_without_panic(operation): + proc = subprocess.run( + [sys.executable, "-c", SCRIPT, operation], + capture_output=True, + text=True, + timeout=15, + ) + assert proc.returncode == 1, proc.stderr + assert "uncaught error while a request is in flight" in proc.stderr + assert "teardown: connection closed" in proc.stderr + assert "teardown: wait complete" in proc.stderr + assert "panicked" not in proc.stderr + assert "Exception ignored" not in proc.stderr From 33a7000a67ad03527c378ee4142cfb4921124aca Mon Sep 17 00:00:00 2001 From: 0x676e67 Date: Tue, 29 Sep 2026 22:41:15 +0800 Subject: [PATCH 06/15] fix(client): avoid PyO3 attach panics during interpreter shutdown (#621) --- Cargo.toml | 1 - src/client/body/stream.rs | 13 ++++++-- src/client/nogil.rs | 38 ++++++++++++++++++---- src/error.rs | 50 ++++++++++++++++++++++++++++- src/redirect.rs | 5 +-- tests/shutdown_test.py | 66 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 tests/shutdown_test.py diff --git a/Cargo.toml b/Cargo.toml index 1711c00e..539d6cd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,5 +92,4 @@ debug = false incremental = false lto = "fat" opt-level = 3 -panic = "abort" strip = true diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index e24862cb..179aea10 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -15,7 +15,12 @@ use pyo3::{ }; use tokio::{sync::Mutex, task::JoinHandle}; -use crate::{buffer::PyBuffer, client::nogil::NoGIL, error::Error, header::HeaderMap}; +use crate::{ + buffer::PyBuffer, + client::nogil::NoGIL, + error::{self, Error}, + header::HeaderMap, +}; type Pending = Option>>>; @@ -224,11 +229,12 @@ impl Stream for PyStream { PyStreamSource::Sync(ref ob) => { let ob = ob.clone(); runtime.spawn_blocking(move || { - Python::attach(|py| { + error::attach(|py| { ob.call_method0(py, intern!(py, "__next__")) .ok() .map(|ob| ob.extract(py)) }) + .unwrap_or_else(|err| Some(Err(err.into()))) }) } PyStreamSource::Async(ref stream) => { @@ -236,7 +242,8 @@ impl Stream for PyStream { runtime.spawn(async move { let ob = stream.lock().await.next().await; tokio::task::spawn_blocking(move || { - Python::attach(|py| ob.map(|ob| ob.extract(py))) + error::attach(|py| ob.map(|ob| ob.extract(py))) + .unwrap_or_else(|err| Some(Err(err.into()))) }) .await .ok()? diff --git a/src/client/nogil.rs b/src/client/nogil.rs index 6a9c59a6..db537352 100644 --- a/src/client/nogil.rs +++ b/src/client/nogil.rs @@ -1,7 +1,8 @@ use std::{ future::Future, pin::Pin, - task::{Context, Poll}, + sync::{Arc, Once}, + task::{Context, Poll, Wake, Waker}, }; use pin_project_lite::pin_project; @@ -12,6 +13,8 @@ use pyo3::{ }; use tokio_util::{sync::CancellationToken, task::AbortOnDropHandle}; +use crate::error; + pin_project! { /// A future that allows Python threads to run while it is being polled or executed. /// It also handles cancellation and spawns the task in tokio runtime. @@ -77,13 +80,36 @@ where }))); } - let waker = cx.waker(); + let waker = Waker::from(Arc::new(GuardedWaker(cx.waker().clone()))); Python::attach(|py| { - py.detach(|| match this.handle.poll(&mut Context::from_waker(waker)) { - Poll::Ready(Ok(result)) => Poll::Ready(result), - Poll::Ready(Err(e)) => Poll::Ready(Err(PyRuntimeError::new_err(e.to_string()))), - Poll::Pending => Poll::Pending, + py.detach(|| { + let mut cx = Context::from_waker(&waker); + match this.handle.poll(&mut cx) { + Poll::Ready(Ok(result)) => Poll::Ready(result), + Poll::Ready(Err(e)) => Poll::Ready(Err(PyRuntimeError::new_err(e.to_string()))), + Poll::Pending => Poll::Pending, + } }) }) } } + +/// Wakes the Python coroutine from Tokio threads. +/// +/// PyO3's coroutine waker calls `Python::attach`, which panics once the interpreter has +/// shut down. Waking inside [`error::attach`] lets it reuse that attachment instead; when +/// Python is gone the wake is dropped and reported once, as no coroutine is left to resume. +struct GuardedWaker(Waker); + +impl Wake for GuardedWaker { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + if let Err(err) = error::attach(|_| self.0.wake_by_ref()) { + static REPORTED: Once = Once::new(); + REPORTED.call_once(|| eprintln!("wreq: failed to wake a Python coroutine: {err}")); + } + } +} diff --git a/src/error.rs b/src/error.rs index 7a78a8f3..9899b00f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,11 @@ +use std::{ + any::Any, + fmt, + panic::{self, AssertUnwindSafe}, +}; + use pyo3::{ - PyErr, create_exception, + PyErr, Python, create_exception, exceptions::{PyException, PyRuntimeError, PyStopAsyncIteration, PyStopIteration}, }; use wreq::header; @@ -57,6 +63,9 @@ macro_rules! wrap_error { }; } +const INTERPRETER_UNAVAILABLE_MSG: &str = + "The Python interpreter is not available (not initialized or shutting down)"; + /// Unified error enum #[derive(Debug)] pub enum Error { @@ -74,6 +83,8 @@ pub enum Error { Json(serde_json::Error), Form(serde_urlencoded::ser::Error), Library(wreq::Error), + InterpreterUnavailable, + Panic(Box), } impl From for PyErr { @@ -100,6 +111,8 @@ impl From for PyErr { Error::Dns(err) => BuilderError::new_err(format!("DNS resolver error: {err:?}")), Error::Json(err) => PyRuntimeError::new_err(format!("JSON error: {err:?}")), Error::Form(err) => PyRuntimeError::new_err(format!("Form error: {err:?}")), + Error::InterpreterUnavailable => PyRuntimeError::new_err(INTERPRETER_UNAVAILABLE_MSG), + Error::Panic(msg) => RustPanic::new_err(msg.into_string()), Error::Library(err) => wrap_error!(err, is_body => BodyError, is_tls => TlsError, @@ -118,6 +131,41 @@ impl From for PyErr { } } +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::InterpreterUnavailable => f.write_str(INTERPRETER_UNAVAILABLE_MSG), + Error::Panic(msg) => f.write_str(msg), + err => fmt::Debug::fmt(err, f), + } + } +} + +impl Error { + fn panic(payload: Box) -> Self { + let msg = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("unknown panic"); + Error::Panic(format!("Rust panic: {msg}").into_boxed_str()) + } +} + +/// Attaches to Python from a non-Python thread. +/// +/// `Python::attach` panics once the interpreter has shut down (see discussions/305), so +/// this uses `Python::try_attach` and returns [`Error::InterpreterUnavailable`] instead. +/// Any other panic in `f` is caught as [`Error::Panic`] to keep Tokio threads alive. +pub fn attach(f: F) -> Result +where + F: for<'py> FnOnce(Python<'py>) -> R, +{ + panic::catch_unwind(AssertUnwindSafe(|| Python::try_attach(f))) + .map_err(Error::panic)? + .ok_or(Error::InterpreterUnavailable) +} + impl From for Error { fn from(err: header::InvalidHeaderName) -> Self { Error::InvalidHeaderName(err) diff --git a/src/redirect.rs b/src/redirect.rs index 353f0555..2ee1bebe 100644 --- a/src/redirect.rs +++ b/src/redirect.rs @@ -2,7 +2,7 @@ use std::{fmt::Display, sync::Arc}; use pyo3::prelude::*; -use crate::{header::HeaderMap, http::StatusCode}; +use crate::{error, header::HeaderMap, http::StatusCode}; /// Represents the redirect policy for HTTP requests. #[derive(Clone)] @@ -103,13 +103,14 @@ impl Policy { attempt.pending(|attempt| async move { let args = Attempt::from(&attempt); let kind = tokio::task::spawn_blocking(move || { - Python::attach(|py| { + error::attach(|py| { callback .call1(py, (args,)) .and_then(|result| result.extract::(py).map_err(PyErr::from)) .map(|action| action.kind) .unwrap_or_else(|err| ActionKind::Error(err.to_string())) }) + .unwrap_or_else(|err| ActionKind::Error(err.to_string())) }) .await; diff --git a/tests/shutdown_test.py b/tests/shutdown_test.py new file mode 100644 index 00000000..8aba518c --- /dev/null +++ b/tests/shutdown_test.py @@ -0,0 +1,66 @@ +import platform +import subprocess +import sys + +import pytest + +# https://github.com/0x676e67/wreq-python/discussions/305: a Tokio worker that attaches +# to Python during interpreter shutdown used to hit PyO3's not-initialized panic. wreq +# now detects the unavailable interpreter and reports a wreq error without panicking. + +# A request is left pending on a listener that never accepts, then an uncaught error +# shuts the interpreter down. Module teardown runs after Py_IsInitialized() drops to 0; +# `HoldTeardown.__del__` closes the listener there, failing the request so the coroutine +# waker attaches from a Tokio worker, and stalls teardown until that happens. +SCRIPT = """ +import asyncio +import socket +import time + +import wreq + +listener = socket.socket() +listener.bind(("127.0.0.1", 0)) +listener.listen(8) +url = f"http://127.0.0.1:{listener.getsockname()[1]}/" + + +class HoldTeardown: + def __init__(self, listener, task): + self.listener = listener + self.task = task + + def __del__(self): + self.listener.close() + time.sleep(1) + + +loop = asyncio.new_event_loop() +task = loop.create_task(wreq.Client().get(url)) +loop.run_until_complete(asyncio.sleep(0.05)) +hold = HoldTeardown(listener, task) +del listener, task +raise RuntimeError("uncaught error while a request is in flight") +""" + +# PyPy doesn't guarantee `__del__` runs during interpreter exit, so the trigger may not fire. +pytestmark = pytest.mark.skipif( + platform.python_implementation() != "CPython", + reason="relies on CPython running __del__ during module teardown", +) + + +def test_shutdown_wake_reports_error_without_panic(): + proc = subprocess.run( + [sys.executable, "-c", SCRIPT], + capture_output=True, + text=True, + timeout=60, + ) + # Exits through the uncaught RuntimeError, not an abort. + assert proc.returncode == 1, proc.stderr + assert "panicked" not in proc.stderr + assert ( + "wreq: failed to wake a Python coroutine: " + "The Python interpreter is not available" in proc.stderr + ) From ea58e0d9ea1803c83de9f1216027503698cd240b Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 29 Sep 2026 22:44:46 +0800 Subject: [PATCH 07/15] refactor(runtime): use field annotations in client declarations --- python/wreq/blocking.py | 3 +-- python/wreq/wreq.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/python/wreq/blocking.py b/python/wreq/blocking.py index 5351c3eb..3459357a 100644 --- a/python/wreq/blocking.py +++ b/python/wreq/blocking.py @@ -212,8 +212,7 @@ class Client: A blocking client for making HTTP requests. """ - @property - def runtime(self) -> Runtime: ... + runtime: Runtime cookie_jar: Jar | None r""" diff --git a/python/wreq/wreq.py b/python/wreq/wreq.py index 644d57f2..17679d01 100644 --- a/python/wreq/wreq.py +++ b/python/wreq/wreq.py @@ -1103,8 +1103,7 @@ class Client: A client for making HTTP requests. """ - @property - def runtime(self) -> Runtime: ... + runtime: Runtime cookie_jar: Jar | None r""" From 6da54eecdbc61419a4597aa9f920a7fdb7df87ef Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 29 Sep 2026 22:47:33 +0800 Subject: [PATCH 08/15] style(python): format code with black --- tests/dns_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/dns_test.py b/tests/dns_test.py index 8d586948..c95c34f9 100644 --- a/tests/dns_test.py +++ b/tests/dns_test.py @@ -45,6 +45,7 @@ async def fetch(host, options): kwargs["dns_options"] = options if blocking_api: + def request(): with blocking.Client(**kwargs) as client: with client.get(url) as response: From f552742cd54bde8431b3ed8742c5713bc0049343 Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 29 Sep 2026 22:50:30 +0800 Subject: [PATCH 09/15] style(runtime): separate method declarations with blank lines --- python/wreq/runtime.py | 5 +++++ python/wreq/wreq.py | 7 +++++++ src/runtime.rs | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/python/wreq/runtime.py b/python/wreq/runtime.py index 8c785408..db8f4deb 100644 --- a/python/wreq/runtime.py +++ b/python/wreq/runtime.py @@ -26,14 +26,19 @@ def __init__( @property def workers(self) -> int: ... + @property def work_steal(self) -> bool: ... + @property def thread_name(self) -> str: ... + @property def max_blocking_threads(self) -> int | None: ... + @property def thread_keep_alive(self) -> float | None: ... + @property def closed(self) -> bool: ... diff --git a/python/wreq/wreq.py b/python/wreq/wreq.py index 17679d01..76abedd0 100644 --- a/python/wreq/wreq.py +++ b/python/wreq/wreq.py @@ -308,12 +308,19 @@ async def main(): """ def __iter__(self) -> "Streamer": ... + def __next__(self) -> bytes | HeaderMap: ... + def __enter__(self) -> Any: ... + def __exit__(self, _exc_type: Any, _exc_value: Any, _traceback: Any) -> None: ... + def __aiter__(self) -> "Streamer": ... + async def __anext__(self) -> bytes | HeaderMap: ... + async def __aenter__(self) -> Any: ... + async def __aexit__( self, _exc_type: Any, _exc_value: Any, _traceback: Any ) -> None: ... diff --git a/src/runtime.rs b/src/runtime.rs index 6821f6d5..1f179ab5 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -134,22 +134,27 @@ impl Runtime { fn workers(&self) -> usize { self.0.workers } + #[getter] fn work_steal(&self) -> bool { self.0.work_steal } + #[getter] fn thread_name(&self) -> &str { &self.0.thread_name } + #[getter] fn max_blocking_threads(&self) -> Option { self.0.max_blocking_threads } + #[getter] fn thread_keep_alive(&self) -> Option { self.0.thread_keep_alive.map(|d| d.as_secs_f64()) } + #[getter] fn closed(&self) -> bool { self.0.lock().closed From 3951e5594a6a1cf4c4cb382218705f1145506686 Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 29 Sep 2026 23:23:57 +0800 Subject: [PATCH 10/15] refactor(runtime): simplify eager runtime ownership --- docs/source/guide/advanced.md | 46 ++-- python/wreq/runtime.py | 44 +--- src/client.rs | 59 +++-- src/client/body/multipart.rs | 7 +- src/client/body/stream.rs | 30 ++- src/client/body/stream/upload.rs | 4 +- src/client/nogil.rs | 50 +++- src/client/req.rs | 6 +- src/client/resp/http.rs | 22 +- src/client/resp/ws.rs | 67 ++++-- src/runtime.rs | 402 +++++++++---------------------- tests/runtime_test.py | 106 +++----- 12 files changed, 344 insertions(+), 499 deletions(-) diff --git a/docs/source/guide/advanced.md b/docs/source/guide/advanced.md index 72691780..e09cb34d 100644 --- a/docs/source/guide/advanced.md +++ b/docs/source/guide/advanced.md @@ -94,11 +94,13 @@ if __name__ == "__main__": ### Custom runtimes -Clients share a lazily started global multi-thread runtime when `runtime` is -omitted or `None`. Pass a `Runtime` to choose a separate worker pool for an -async or blocking client: +Clients share a global multi-thread runtime when `runtime` is omitted or `None`. +It starts when the first default client is created. Construct a `Runtime` to +start a separate worker pool for an async or blocking client: ```python +from datetime import timedelta + from wreq import Client from wreq.runtime import Runtime @@ -107,7 +109,7 @@ runtime = Runtime( work_steal=False, thread_name="http-client", max_blocking_threads=8, - thread_keep_alive=10.0, + thread_keep_alive=timedelta(seconds=10), ) client = Client(runtime=runtime) ``` @@ -115,17 +117,19 @@ client = Client(runtime=runtime) With `work_steal=False`, workers use independent single-thread Tokio runtimes. Each client is assigned one worker for its lifetime; requests, response reads, streams and WebSocket operations use that worker. With multiple workers, newly -created clients are assigned round-robin. This is not CPU pinning. Sharing the -same `Runtime` between clients is supported, and `client.runtime` returns its -runtime configuration and owner. +created clients select a worker randomly and keep that selection. This is not +CPU pinning. Sharing the same `Runtime` between clients is supported, and +`client.runtime` returns the shared runtime object. + +`workers=None` uses the available CPU parallelism, or 1 if it cannot be determined. +Custom runtimes start their threads during construction, before any client is +bound or request is sent. -`workers=None` uses `TOKIO_WORKER_THREADS` when it contains a positive integer, -otherwise the available parallelism. The global runtime's configuration is -chosen when first accessed. Both default and custom runtimes start their -threads on first use. +`thread_name=None` uses the package name, `wreq-python`, as the thread-name prefix. -`thread_keep_alive` is in seconds. `max_blocking_threads` and -`thread_keep_alive` default to Tokio's settings (512 and 10 seconds). In +`thread_keep_alive` accepts a nonnegative `datetime.timedelta`. +`max_blocking_threads` and `thread_keep_alive` default to Tokio's settings +(512 and 10 seconds). In no-steal mode these limits apply to **each worker's** blocking pool, not the pool as a whole. Python async upload generators still run on the caller's event loop. Standalone multipart file preparation and upload-task cleanup can use the @@ -134,17 +138,11 @@ every process resource. DNS resolvers are owned by individual clients so their connections are not shared across runtimes. Closing a client cancels its requests but does not shut down its runtime. -Responses and streams retain the runtime independently. For explicit shutdown, -release all clients (including closed ones), responses, streams and in-flight -tasks, then call `runtime.shutdown_timeout(1.0)`. This is a blocking call which -releases the GIL. The timeout is in seconds per worker; already running blocking -tasks may outlive it. Shutdown raises `RuntimeError` while the runtime is in use -or if it is the shared default. Repeated shutdown of a custom runtime is harmless, -and `runtime.closed` reports whether it has been explicitly shut down. - -Dropping all owners automatically releases a custom runtime without synchronously -waiting for its worker threads. Invalid configuration is rejected with Python -exceptions. +Clients, responses, streams and active tasks share ownership. Dropping the last +owner automatically releases a custom runtime without synchronously waiting for +its workers; already running blocking work may finish later. The default runtime +is shared for the process lifetime. Zero thread counts, NUL characters in thread +names and negative durations raise `ValueError`. ### TLS Key Logging diff --git a/python/wreq/runtime.py b/python/wreq/runtime.py index db8f4deb..6d6360d7 100644 --- a/python/wreq/runtime.py +++ b/python/wreq/runtime.py @@ -1,3 +1,4 @@ +import datetime from typing import final __all__ = ["Runtime"] @@ -5,48 +6,27 @@ @final class Runtime: - """Lazy Tokio runtime wrapper. No-steal clients keep a fixed worker, without CPU pinning.""" + """Shared Tokio runtime whose workers start when constructed. + + Clients and active work keep it alive; the last owner releases it automatically. + No-steal clients keep a fixed worker, without CPU pinning. + """ def __init__( self, *, workers: int | None = None, work_steal: bool = True, - thread_name: str = "wreq", + thread_name: str | None = None, max_blocking_threads: int | None = None, - thread_keep_alive: float | None = None, + thread_keep_alive: datetime.timedelta | None = None, ) -> None: - """Thread counts must be positive; thread_keep_alive is in seconds. + """Thread counts must be positive; thread_keep_alive is a nonnegative timedelta. - workers defaults to TOKIO_WORKER_THREADS or available parallelism. + workers defaults to available CPU parallelism, or 1 if unavailable. + thread_name defaults to the package name, "wreq-python". + Thread names cannot contain NUL characters. Blocking-pool settings apply to each worker runtime in no-steal mode. None preserves Tokio's blocking-pool defaults. """ ... - - @property - def workers(self) -> int: ... - - @property - def work_steal(self) -> bool: ... - - @property - def thread_name(self) -> str: ... - - @property - def max_blocking_threads(self) -> int | None: ... - - @property - def thread_keep_alive(self) -> float | None: ... - - @property - def closed(self) -> bool: ... - - def shutdown_timeout(self, timeout: float) -> None: - """Shut down an unused custom runtime; repeated calls are harmless. - - Release clients (including closed clients), responses, streams and tasks - first, or RuntimeError is raised. timeout is finite nonnegative seconds, - per worker in no-steal mode. Running blocking work can outlive the timeout. - """ - ... diff --git a/src/client.rs b/src/client.rs index 86384b38..b4452928 100644 --- a/src/client.rs +++ b/src/client.rs @@ -13,6 +13,7 @@ use std::{ use pyo3::{IntoPyObjectExt, coroutine::CancelHandle, prelude::*, pybacked::PyBackedStr}; use req::{Request, WebSocketRequest}; +use tokio::runtime::Handle; use tokio_util::sync::CancellationToken; use wreq::tls::trust::CertStore; @@ -231,11 +232,12 @@ impl FromPyObject<'_, '_> for Builder { } /// A client for making HTTP requests. -#[derive(Default, Clone)] +#[derive(Clone)] #[pyclass(subclass, frozen, skip_from_py_object)] pub struct Client { inner: wreq::Client, - runtime: runtime::Executor, + runtime: runtime::Runtime, + handle: Handle, cancel: CancellationToken, raise_for_status: bool, @@ -251,6 +253,20 @@ pub struct BlockingClient(Client); // ====== Client ===== +impl Default for Client { + fn default() -> Self { + let (runtime, handle) = runtime::get(); + Self { + inner: wreq::Client::default(), + runtime: runtime.clone(), + handle: handle.clone(), + cancel: CancellationToken::new(), + raise_for_status: false, + cookie_jar: None, + } + } +} + #[pymethods] impl Client { /// Creates a new Client instance. @@ -258,12 +274,10 @@ impl Client { #[pyo3(signature = (**kwds))] fn new(py: Python, kwds: Option) -> PyResult { py.detach(|| { - let runtime = kwds - .as_ref() - .and_then(|config| config.runtime.as_ref()) - .map(runtime::Runtime::bind) - .transpose()? - .unwrap_or_default(); + let (runtime, handle) = match kwds.as_ref().and_then(|config| config.runtime.as_ref()) { + Some(runtime) => (runtime.clone(), runtime.handle()?.clone()), + None => runtime::get().clone(), + }; // Create the client builder. let mut builder = wreq::Client::builder(); let mut cookie_jar: Option = None; @@ -474,6 +488,7 @@ impl Client { .map(|inner| Client { inner, runtime, + handle, cancel: CancellationToken::new(), cookie_jar, raise_for_status, @@ -492,7 +507,7 @@ impl Client { /// The runtime used by this client and its responses. #[getter] pub fn runtime(&self) -> runtime::Runtime { - self.runtime.runtime() + self.runtime.clone() } /// Make a GET request to the given URL. @@ -603,10 +618,11 @@ impl Client { ) -> PyResult { NoGIL::new_with_token( &self.runtime, + &self.handle, execute_request(self.clone(), method, url, kwds), cancel, self.cancel.clone(), - )? + ) .await } @@ -621,10 +637,11 @@ impl Client { ) -> PyResult { NoGIL::new_with_token( &self.runtime, + &self.handle, execute_websocket_request(self.clone(), url, kwds), cancel, self.cancel.clone(), - )? + ) .await } } @@ -777,10 +794,12 @@ impl BlockingClient { kwds: Option, ) -> PyResult { py.detach(|| { - self.0 - .runtime - .block_on(execute_request(self.0.clone(), method, url, kwds)) - .map(Into::into) + nogil::block_on( + &self.0.runtime, + &self.0.handle, + execute_request(self.0.clone(), method, url, kwds), + ) + .map(Into::into) }) } @@ -793,10 +812,12 @@ impl BlockingClient { kwds: Option, ) -> PyResult { py.detach(|| { - self.0 - .runtime - .block_on(execute_websocket_request(self.0.clone(), url, kwds)) - .map(Into::into) + nogil::block_on( + &self.0.runtime, + &self.0.handle, + execute_websocket_request(self.0.clone(), url, kwds), + ) + .map(Into::into) }) } } diff --git a/src/client/body/multipart.rs b/src/client/body/multipart.rs index ebf97b73..963b2a1e 100644 --- a/src/client/body/multipart.rs +++ b/src/client/body/multipart.rs @@ -133,11 +133,8 @@ impl Part { let mut inner = match value { Value::Text(text) => multipart::Part::stream(Bytes::from_owner(text)), Value::Bytes(bytes) => multipart::Part::stream(Bytes::from_owner(bytes)), - Value::File(path) => crate::runtime::get()?.block_on(async move { - multipart::Part::file(path) - .await - .map_err(Error::from) - .map_err(Into::into) + Value::File(path) => crate::runtime::get().1.block_on(async move { + multipart::Part::file(path).await.map_err(Error::from) })?, Value::Stream(stream) => { let stream = Body::wrap_stream(stream); diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index 6c568512..410a2432 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -15,10 +15,14 @@ use pyo3::{ prelude::*, pybacked::{PyBackedBytes, PyBackedStr}, }; -use tokio::{sync::Mutex, task::JoinHandle}; +use tokio::{runtime::Handle, sync::Mutex, task::JoinHandle}; use crate::{ - buffer::PyBuffer, client::nogil::NoGIL, error::Error, header::HeaderMap, runtime::Executor, + buffer::PyBuffer, + client::nogil::{self, NoGIL}, + error::Error, + header::HeaderMap, + runtime::Runtime, }; type Pending = Option>>>; @@ -52,7 +56,7 @@ pub struct PyStream { /// A bytes stream response. #[derive(Clone)] #[pyclass(subclass, frozen, skip_from_py_object)] -pub struct Streamer(Arc>>, Executor); +pub struct Streamer(Arc>>, Runtime, Handle); // ===== impl PyStream ===== @@ -71,8 +75,8 @@ impl From for PyStream { impl Streamer { /// Create a new [`Streamer`] instance. #[inline] - pub fn new(resp: wreq::Response, runtime: Executor) -> Streamer { - Streamer(Arc::new(Mutex::new(Some(resp))), runtime) + pub fn new(resp: wreq::Response, runtime: Runtime, handle: Handle) -> Streamer { + Streamer(Arc::new(Mutex::new(Some(resp))), runtime, handle) } async fn next(self, error: fn() -> Error) -> PyResult { @@ -112,7 +116,7 @@ impl Streamer { #[inline] fn __next__(&self, py: Python) -> PyResult { - py.detach(|| self.1.block_on(self.clone().next(|| Error::StopIteration))) + py.detach(|| nogil::block_on(&self.1, &self.2, self.clone().next(|| Error::StopIteration))) } #[inline] @@ -154,9 +158,14 @@ impl Streamer { Some(cancel.throw_callback()), async move { let runtime = this.1.clone(); - let frame = - NoGIL::new(&runtime, this.next(|| Error::StopAsyncIteration), cancel)? - .await?; + let handle = this.2.clone(); + let frame = NoGIL::new( + &runtime, + &handle, + this.next(|| Error::StopAsyncIteration), + cancel, + ) + .await?; // PyO3 polls this coroutine while attached, outside the Tokio task. Python::attach(|py| frame.into_pyobject(py).map(|obj| obj.unbind())) }, @@ -180,6 +189,7 @@ impl Streamer { let this = self.0.clone(); NoGIL::new( &self.1, + &self.2, async move { if let Some(resp) = this.lock().await.take() { drop(resp) @@ -187,7 +197,7 @@ impl Streamer { Ok(()) }, CancelHandle::new(), - )? + ) .await } } diff --git a/src/client/body/stream/upload.rs b/src/client/body/stream/upload.rs index 2ea00b50..6d355df7 100644 --- a/src/client/body/stream/upload.rs +++ b/src/client/body/stream/upload.rs @@ -80,9 +80,9 @@ impl Stream for Upload { impl Drop for Upload { fn drop(&mut self) { self.rx.close(); - if let (Some((task, event_loop)), Ok(runtime)) = (self.task.take(), crate::runtime::get()) { + if let Some((task, event_loop)) = self.task.take() { // Body drop can run on Tokio: acquire the interpreter on a blocking thread. - runtime.spawn_blocking(move || { + crate::runtime::get().1.spawn_blocking(move || { Python::try_attach(|py| { if let Ok(cancel) = task.bind(py).getattr(intern!(py, "cancel")) { let _ = event_loop.call_method1( diff --git a/src/client/nogil.rs b/src/client/nogil.rs index a5a0558f..97a411ae 100644 --- a/src/client/nogil.rs +++ b/src/client/nogil.rs @@ -11,8 +11,11 @@ use pyo3::{ exceptions::{PyRuntimeError, asyncio::CancelledError}, prelude::*, }; +use tokio::runtime::Handle; use tokio_util::{sync::CancellationToken, task::AbortOnDropHandle}; +use crate::runtime::Runtime; + pin_project! { /// A future that allows Python threads to run while it is being polled or executed. /// It also handles cancellation and spawns the task in tokio runtime. @@ -31,35 +34,41 @@ impl NoGIL where T: Send + 'static, { - /// Create [`NoGIL`] from a future + /// Spawn on the selected handle, keeping the runtime alive until the task ends. #[inline] - pub fn new( - runtime: &crate::runtime::Executor, - fut: Fut, - cancel: CancelHandle, - ) -> PyResult + pub fn new(runtime: &Runtime, handle: &Handle, fut: Fut, cancel: CancelHandle) -> Self where Fut: Future> + Send + 'static, { - Ok(Self { - handle: AbortOnDropHandle::new(Python::attach(|py| py.detach(|| runtime.spawn(fut)))), + let owner = runtime.clone(); + Self { + handle: AbortOnDropHandle::new(Python::attach(|py| { + py.detach(|| { + handle.spawn(async move { + let _owner = owner; + fut.await + }) + }) + })), cancel, - }) + } } - /// Create [`NoGIL`] from a future and a cancellation token + /// Also cancel the task when its client is closed. #[inline] pub fn new_with_token( - runtime: &crate::runtime::Executor, + runtime: &Runtime, + handle: &Handle, fut: Fut, cancel: CancelHandle, cancel_token: CancellationToken, - ) -> PyResult + ) -> Self where Fut: Future> + Send + 'static, { Self::new( runtime, + handle, async move { tokio::select! { result = fut => result, @@ -101,6 +110,23 @@ where } } +/// Run network work on its selected worker; only wait for completion on the caller. +/// The caller must be detached from Python and outside an async Tokio context. +pub fn block_on(runtime: &Runtime, handle: &Handle, future: F) -> PyResult +where + F: Future> + Send + 'static, + T: Send + 'static, +{ + let owner = runtime.clone(); + let task = handle.spawn(async move { + let _owner = owner; + future.await + }); + handle + .block_on(AbortOnDropHandle::new(task)) + .map_err(|err| PyRuntimeError::new_err(err.to_string()))? +} + /// Protect Python wakers retained by futures that can be woken from Rust threads. pub fn poll_with_guard(future: Pin<&mut F>, cx: &mut Context<'_>) -> Poll { let waker = Waker::from(Arc::new(GuardedWaker(cx.waker().clone()))); diff --git a/src/client/req.rs b/src/client/req.rs index 639497bb..9689ea64 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -419,7 +419,7 @@ where Ok(r) } }) - .map(|response| Response::new(response, client.runtime.clone())) + .map(|response| Response::new(response, client.runtime.clone(), client.handle.clone())) .map_err(Error::Library) .map_err(Into::into) } @@ -547,7 +547,9 @@ where // Send the WebSocket request. builder .send() - .and_then(|response| WebSocket::new(response, client.runtime.clone())) + .and_then(|response| { + WebSocket::new(response, client.runtime.clone(), client.handle.clone()) + }) .await .map_err(Error::Library) .map_err(Into::into) diff --git a/src/client/resp/http.rs b/src/client/resp/http.rs index 6ce23faf..58aae0df 100644 --- a/src/client/resp/http.rs +++ b/src/client/resp/http.rs @@ -24,7 +24,7 @@ use crate::{ header::HeaderMap, http::{StatusCode, Version}, redirect::History, - runtime::Executor, + runtime::Runtime, tls::TlsInfo, }; @@ -34,7 +34,8 @@ pub struct Response { uri: Uri, parts: Parts, body: Arc>, - runtime: Executor, + runtime: Runtime, + handle: tokio::runtime::Handle, } /// Represents the state of the HTTP response body. @@ -53,7 +54,7 @@ pub struct BlockingResponse(Response); impl Response { /// Create a new [`Response`] instance. - pub fn new(response: wreq::Response, runtime: Executor) -> Self { + pub fn new(response: wreq::Response, runtime: Runtime, handle: tokio::runtime::Handle) -> Self { let uri = response.uri().clone(); let response = HttpResponse::from(response) .map(Body::Streamable) @@ -65,6 +66,7 @@ impl Response { parts, body, runtime, + handle, } } @@ -219,7 +221,7 @@ impl Response { /// Get the response into a `Stream` of `Bytes` from the body. pub fn stream(&self) -> PyResult { self.stream_response() - .map(|response| Streamer::new(response, self.runtime.clone())) + .map(|response| Streamer::new(response, self.runtime.clone(), self.handle.clone())) .map_err(Into::into) } @@ -234,7 +236,7 @@ impl Response { .cache_response() .and_then(|resp| ResponseExt::text(resp, encoding)) .map_err(Into::into); - NoGIL::new(&self.runtime, fut, cancel)?.await + NoGIL::new(&self.runtime, &self.handle, fut, cancel).await } /// Get the JSON content of the response. @@ -243,7 +245,7 @@ impl Response { .cache_response() .and_then(ResponseExt::json::) .map_err(Into::into); - NoGIL::new(&self.runtime, fut, cancel)?.await + NoGIL::new(&self.runtime, &self.handle, fut, cancel).await } /// Get the bytes content of the response. @@ -253,7 +255,7 @@ impl Response { .and_then(ResponseExt::bytes) .map_ok(PyBuffer::from) .map_err(Into::into); - NoGIL::new(&self.runtime, fut, cancel)?.await + NoGIL::new(&self.runtime, &self.handle, fut, cancel).await } /// Close the response. @@ -388,7 +390,7 @@ impl BlockingResponse { .cache_response() .and_then(|resp| ResponseExt::text(resp, encoding)) .map_err(Into::into); - self.0.runtime.block_on(fut) + crate::client::nogil::block_on(&self.0.runtime, &self.0.handle, fut) }) } @@ -400,7 +402,7 @@ impl BlockingResponse { .cache_response() .and_then(ResponseExt::json::) .map_err(Into::into); - self.0.runtime.block_on(fut) + crate::client::nogil::block_on(&self.0.runtime, &self.0.handle, fut) }) } @@ -413,7 +415,7 @@ impl BlockingResponse { .and_then(ResponseExt::bytes) .map_ok(PyBuffer::from) .map_err(Into::into); - self.0.runtime.block_on(fut) + crate::client::nogil::block_on(&self.0.runtime, &self.0.handle, fut) }) } diff --git a/src/client/resp/ws.rs b/src/client/resp/ws.rs index e1d21c7f..4af8ce45 100644 --- a/src/client/resp/ws.rs +++ b/src/client/resp/ws.rs @@ -17,7 +17,7 @@ use crate::{ error::Error, header::HeaderMap, http::{StatusCode, Version}, - runtime::Executor, + runtime::Runtime, }; /// A WebSocket response. @@ -44,7 +44,8 @@ pub struct WebSocket { headers: HeaderMap, protocol: Option, cmd: mpsc::UnboundedSender, - runtime: Executor, + runtime: Runtime, + handle: tokio::runtime::Handle, } /// A blocking WebSocket response. @@ -55,7 +56,11 @@ pub struct BlockingWebSocket(WebSocket); impl WebSocket { /// Creates a new [`WebSocket`] instance. - pub async fn new(response: WebSocketResponse, runtime: Executor) -> wreq::Result { + pub async fn new( + response: WebSocketResponse, + runtime: Runtime, + handle: tokio::runtime::Handle, + ) -> wreq::Result { let (version, status, remote_addr, local_addr, headers) = ( Version::from_ffi(response.version()), StatusCode(response.status()), @@ -70,6 +75,7 @@ impl WebSocket { Ok(WebSocket { runtime, + handle, version, status, remote_addr, @@ -108,7 +114,7 @@ impl WebSocket { timeout: Option, ) -> PyResult> { let tx = self.cmd.clone(); - NoGIL::new(&self.runtime, cmd::recv(tx, timeout), cancel)?.await + NoGIL::new(&self.runtime, &self.handle, cmd::recv(tx, timeout), cancel).await } /// Send a message to the WebSocket. @@ -119,7 +125,7 @@ impl WebSocket { message: Message, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(&self.runtime, cmd::send(tx, message), cancel)?.await + NoGIL::new(&self.runtime, &self.handle, cmd::send(tx, message), cancel).await } /// Send multiple messages to the WebSocket. @@ -130,7 +136,13 @@ impl WebSocket { messages: Vec, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(&self.runtime, cmd::send_all(tx, messages), cancel)?.await + NoGIL::new( + &self.runtime, + &self.handle, + cmd::send_all(tx, messages), + cancel, + ) + .await } /// Close the WebSocket connection. @@ -142,7 +154,13 @@ impl WebSocket { reason: Option, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(&self.runtime, cmd::close(tx, code, reason), cancel)?.await + NoGIL::new( + &self.runtime, + &self.handle, + cmd::close(tx, code, reason), + cancel, + ) + .await } } @@ -163,9 +181,10 @@ impl WebSocket { let tx = self.cmd.clone(); NoGIL::new( &self.runtime, + &self.handle, cmd::close(tx, None, None), CancelHandle::new(), - )? + ) .await } } @@ -226,9 +245,11 @@ impl BlockingWebSocket { #[pyo3(signature = (timeout=None))] pub fn recv(&self, py: Python, timeout: Option) -> PyResult> { py.detach(|| { - self.0 - .runtime - .block_on(cmd::recv(self.0.cmd.clone(), timeout)) + crate::client::nogil::block_on( + &self.0.runtime, + &self.0.handle, + cmd::recv(self.0.cmd.clone(), timeout), + ) }) } @@ -236,9 +257,11 @@ impl BlockingWebSocket { #[pyo3(signature = (message))] pub fn send(&self, py: Python, message: Message) -> PyResult<()> { py.detach(|| { - self.0 - .runtime - .block_on(cmd::send(self.0.cmd.clone(), message)) + crate::client::nogil::block_on( + &self.0.runtime, + &self.0.handle, + cmd::send(self.0.cmd.clone(), message), + ) }) } @@ -246,9 +269,11 @@ impl BlockingWebSocket { #[pyo3(signature = (messages))] pub fn send_all(&self, py: Python, messages: Vec) -> PyResult<()> { py.detach(|| { - self.0 - .runtime - .block_on(cmd::send_all(self.0.cmd.clone(), messages)) + crate::client::nogil::block_on( + &self.0.runtime, + &self.0.handle, + cmd::send_all(self.0.cmd.clone(), messages), + ) }) } @@ -261,9 +286,11 @@ impl BlockingWebSocket { reason: Option, ) -> PyResult<()> { py.detach(|| { - self.0 - .runtime - .block_on(cmd::close(self.0.cmd.clone(), code, reason)) + crate::client::nogil::block_on( + &self.0.runtime, + &self.0.handle, + cmd::close(self.0.cmd.clone(), code, reason), + ) }) } } diff --git a/src/runtime.rs b/src/runtime.rs index 1f179ab5..ea6b8d79 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,6 +1,5 @@ use std::{ - future::Future, - sync::{Arc, Mutex, MutexGuard, OnceLock}, + sync::{Arc, OnceLock}, time::Duration, }; @@ -9,79 +8,28 @@ use pyo3::{ exceptions::{PyRuntimeError, PyValueError}, prelude::*, }; -use tokio::{runtime::Handle, task::JoinHandle}; -use tokio_util::task::AbortOnDropHandle; +use tokio::runtime::Handle; -/// Tokio workers shared explicitly or through the process-wide default instance. +/// Shared Tokio runtime, released after its clients and active work are dropped. #[derive(Clone)] -#[pyclass(frozen, skip_from_py_object, module = "wreq.runtime")] -pub struct Runtime(Arc); - -struct Inner { - workers: usize, - work_steal: bool, - thread_name: String, - max_blocking_threads: Option, - thread_keep_alive: Option, - global: bool, - state: Mutex, -} - -#[derive(Default)] -struct State { - runtime: Option, - closed: bool, - users: usize, - next_worker: usize, -} - -/// A fixed worker selection shared by a client, its responses and active tasks. -#[derive(Clone)] -pub struct Executor(Arc); - -struct Lease { - runtime: Runtime, - worker: usize, - handle: OnceLock, -} - -// ===== impl Runtime ===== +#[pyclass(frozen, skip_from_py_object)] +pub struct Runtime(Option>); impl Runtime { - fn global() -> Self { - static RUNTIME: OnceLock = OnceLock::new(); - RUNTIME - .get_or_init(|| { - Self(Arc::new(Inner { - workers: automatic_workers(), - work_steal: true, - thread_name: "wreq".into(), - max_blocking_threads: None, - thread_keep_alive: None, - global: true, - state: Mutex::default(), - })) - }) - .clone() - } - - pub fn bind(&self) -> PyResult { - let mut state = self.0.lock(); - if state.closed { - return Err(PyRuntimeError::new_err("Runtime is closed")); - } - Ok(self.lease(&mut state)) + /// Borrow a Tokio handle; in no-steal mode, select once per client and retain it. + pub fn handle(&self) -> PyResult<&Handle> { + self.0 + .as_deref() + .map(PingoraRuntime::get_handle) + .ok_or_else(|| PyRuntimeError::new_err("Runtime is unavailable")) } +} - fn lease(&self, state: &mut State) -> Executor { - let worker = state.next_worker; - state.next_worker = (worker + 1) % self.0.workers; - state.users += 1; - Executor(Arc::new(Lease { - runtime: self.clone(), - worker, - handle: OnceLock::new(), - })) +impl From for Runtime { + fn from(runtime: PingoraRuntime) -> Self { + // No-steal workers are lazy upstream. Start them before sharing the runtime. + runtime.get_handle(); + Self(Some(Arc::new(runtime))) } } @@ -95,18 +43,29 @@ impl FromPyObject<'_, '_> for Runtime { #[pymethods] impl Runtime { - /// Configure workers; OS threads start lazily when first used. + /// Create and start the runtime's workers. /// Without work stealing, each client stays on one worker (not CPU-pinned). + /// Workers default to CPU parallelism and names to the package name. + /// Thread counts must be positive; thread_keep_alive is a nonnegative timedelta. #[new] - #[pyo3(signature = (*, workers=None, work_steal=true, thread_name="wreq", max_blocking_threads=None, thread_keep_alive=None))] + #[pyo3(signature = ( + *, + workers = None, + work_steal = true, + thread_name = None, + max_blocking_threads = None, + thread_keep_alive = None, + ))] fn new( + py: Python<'_>, workers: Option, work_steal: bool, - thread_name: &str, + thread_name: Option<&str>, max_blocking_threads: Option, - thread_keep_alive: Option, + thread_keep_alive: Option, ) -> PyResult { - let workers = workers.unwrap_or_else(automatic_workers); + let workers = + workers.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, usize::from)); if workers == 0 || max_blocking_threads == Some(0) || workers @@ -115,209 +74,55 @@ impl Runtime { { return Err(PyValueError::new_err("Invalid runtime thread counts")); } + + let thread_name = thread_name.unwrap_or(env!("CARGO_PKG_NAME")); if thread_name.contains('\0') { return Err(PyValueError::new_err("thread_name must not contain NUL")); } - let thread_keep_alive = thread_keep_alive.map(duration).transpose()?; - Ok(Self(Arc::new(Inner { - workers, - work_steal, - thread_name: thread_name.into(), - max_blocking_threads, - thread_keep_alive, - global: false, - state: Mutex::default(), - }))) - } - - #[getter] - fn workers(&self) -> usize { - self.0.workers - } - - #[getter] - fn work_steal(&self) -> bool { - self.0.work_steal - } - - #[getter] - fn thread_name(&self) -> &str { - &self.0.thread_name - } - - #[getter] - fn max_blocking_threads(&self) -> Option { - self.0.max_blocking_threads - } - - #[getter] - fn thread_keep_alive(&self) -> Option { - self.0.thread_keep_alive.map(|d| d.as_secs_f64()) - } - - #[getter] - fn closed(&self) -> bool { - self.0.lock().closed - } - - /// Shut down an unused custom runtime. Release all clients and responses first. - /// Timeout is seconds per worker; already running blocking work may outlive it. - fn shutdown_timeout(&self, py: Python<'_>, timeout: f64) -> PyResult<()> { - let timeout = duration(timeout)?; - py.detach(|| { - let runtime = { - let mut state = self.0.lock(); - if self.0.global { - return Err(PyRuntimeError::new_err( - "The default Runtime cannot be shut down", - )); - } - if state.users != 0 { - return Err(PyRuntimeError::new_err( - "Runtime is in use; release its clients, responses and tasks first", - )); - } - state.closed = true; - state.runtime.take() - }; - if let Some(runtime) = runtime { - runtime.shutdown_timeout(timeout); - } - Ok(()) - }) - } -} -// ===== impl Inner ===== - -impl Inner { - fn lock(&self) -> MutexGuard<'_, State> { - self.state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + Ok(py.detach(|| { + RuntimeBuilder::new(workers, thread_name) + .work_steal(work_steal) + .blocking_pool_opts(BlockingPoolOpts { + max_threads: max_blocking_threads, + thread_keep_alive, + }) + .build() + .into() + })) } } -impl Drop for Inner { +impl Drop for Runtime { fn drop(&mut self) { - if let Some(runtime) = self - .state - .get_mut() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .runtime - .take() - { - // Never synchronously wait on our own worker or while holding Python's GIL. + // The final owner may be released on a worker or while holding the GIL. + if let Some(runtime) = self.0.take().and_then(Arc::into_inner) { match runtime { PingoraRuntime::Steal { runtime, .. } => runtime.shutdown_background(), - // Dropping the controls wakes Pingora's dedicated driver threads. PingoraRuntime::NoSteal(runtime) => drop(runtime), } } } } -// ===== impl Executor ===== - -impl Default for Executor { - fn default() -> Self { - let runtime = Runtime::global(); - let mut state = runtime.0.lock(); - runtime.lease(&mut state) - } -} - -impl Executor { - pub fn runtime(&self) -> Runtime { - self.0.runtime.clone() - } - - pub fn handle(&self) -> Handle { - self.0 - .handle - .get_or_init(|| { - let inner = &self.0.runtime.0; - let mut state = inner.lock(); - let runtime = state.runtime.get_or_insert_with(|| { - RuntimeBuilder::new(inner.workers, &inner.thread_name) - .work_steal(inner.work_steal) - .blocking_pool_opts(BlockingPoolOpts { - max_threads: inner.max_blocking_threads, - thread_keep_alive: inner.thread_keep_alive, - }) - .build() - }); - // Serialize lazy initialization and keep the selected no-steal worker stable. - match runtime { - PingoraRuntime::NoSteal(runtime) => { - runtime.get_runtime_at(self.0.worker).clone() - } - runtime => runtime.get_handle().clone(), - } - }) - .clone() - } +/// Create the shared runtime on first use and retain it for the process lifetime. +pub fn get() -> &'static (Runtime, Handle) { + static RUNTIME: OnceLock<(Runtime, Handle)> = OnceLock::new(); - pub fn spawn(&self, future: F) -> JoinHandle - where - F: Future + Send + 'static, - F::Output: Send + 'static, - { - let owner = self.clone(); - self.handle().spawn(async move { - let _owner = owner; - future.await - }) + fn create() -> (Runtime, Handle) { + let workers = std::thread::available_parallelism().map_or(1, usize::from); + let runtime = RuntimeBuilder::new(workers, env!("CARGO_PKG_NAME")).build(); + let handle = runtime.get_handle().clone(); + (runtime.into(), handle) } - pub fn spawn_blocking(&self, function: F) -> JoinHandle - where - F: FnOnce() -> T + Send + 'static, - T: Send + 'static, - { - let owner = self.clone(); - self.handle().spawn_blocking(move || { - let _owner = owner; - function() - }) + if let Some(runtime) = RUNTIME.get() { + return runtime; } - /// Only the join is polled on the caller; network work stays on our worker. - pub fn block_on(&self, future: F) -> PyResult - where - F: Future> + Send + 'static, - T: Send + 'static, - { - self.handle() - .block_on(AbortOnDropHandle::new(self.spawn(future))) - .map_err(|err| PyRuntimeError::new_err(err.to_string()))? - } -} - -// ===== impl Lease ===== - -impl Drop for Lease { - fn drop(&mut self) { - self.runtime.0.lock().users -= 1; - } -} - -pub fn get() -> PyResult { - Runtime::global().bind() -} - -fn automatic_workers() -> usize { - std::env::var("TOKIO_WORKER_THREADS") - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|n| *n > 0) - .unwrap_or_else(|| std::thread::available_parallelism().map_or(1, usize::from)) -} - -fn duration(seconds: f64) -> PyResult { - Duration::try_from_secs_f64(seconds).map_err(|_| { - PyValueError::new_err("Duration must be finite, nonnegative and representable") - }) + // Never wait for another initializer while holding the interpreter. + Python::try_attach(|py| py.detach(|| RUNTIME.get_or_init(create))) + .unwrap_or_else(|| RUNTIME.get_or_init(create)) } #[cfg(test)] @@ -325,60 +130,77 @@ mod tests { use super::*; #[test] - fn fixed_workers_and_lifetime() { - let runtime = Runtime::new(Some(2), false, "wreq-affinity", Some(2), None).unwrap(); - let first = runtime.bind().unwrap(); - let second = runtime.bind().unwrap(); - assert!(runtime.0.lock().runtime.is_none()); + fn fixed_worker() { + let runtime = Runtime::from( + RuntimeBuilder::new(2, "wreq-affinity") + .work_steal(false) + .build(), + ); + let first = runtime.handle().unwrap(); + let second = runtime.handle().unwrap(); let caller = std::thread::current().id(); let mut ids = Vec::new(); - for executor in [&first, &second, &first, &second] { - let id = executor - .block_on(async { - let thread = std::thread::current().id(); - for _ in 0..8 { - tokio::task::yield_now().await; - assert_eq!(thread, std::thread::current().id()); - } - let child = tokio::spawn(async { std::thread::current().id() }) - .await - .unwrap(); - assert_eq!(thread, child); - Ok(thread) - }) - .unwrap(); + for handle in [first, second, first, second] { + let id = crate::client::nogil::block_on(&runtime, handle, async { + let thread = std::thread::current().id(); + for _ in 0..8 { + tokio::task::yield_now().await; + assert_eq!(thread, std::thread::current().id()); + } + let child = tokio::spawn(async { std::thread::current().id() }) + .await + .unwrap(); + assert_eq!(thread, child); + Ok(thread) + }) + .unwrap(); assert_ne!(id, caller); ids.push(id); } assert_eq!(ids[0], ids[2]); assert_eq!(ids[1], ids[3]); - assert_ne!(ids[0], ids[1]); - assert_eq!(runtime.0.lock().users, 2); - drop((first, second)); - assert_eq!(runtime.0.lock().users, 0); + // Different clients may select the same worker; each selection stays fixed. } #[test] fn last_owner_can_be_released_on_a_worker() { + struct NotifyOnDrop(std::sync::mpsc::Sender<()>); + + impl Drop for NotifyOnDrop { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + for steal in [false, true] { - let runtime = Runtime::new(Some(1), steal, "wreq-drop", None, None).unwrap(); - let weak = Arc::downgrade(&runtime.0); - let executor = runtime.bind().unwrap(); + let runtime = Runtime::from( + RuntimeBuilder::new(1, "wreq-drop") + .work_steal(steal) + .build(), + ); + let weak = Arc::downgrade(runtime.0.as_ref().unwrap()); + let handle = runtime.handle().unwrap().clone(); + let (dropped, released) = std::sync::mpsc::channel(); + let guard = NotifyOnDrop(dropped); + let background = handle.spawn(async move { + let _guard = guard; + std::future::pending::<()>().await; + }); let (tx, rx) = tokio::sync::oneshot::channel(); let (done, wait) = std::sync::mpsc::channel(); - let task = executor.spawn(async move { + let owner = runtime.clone(); + let task = handle.spawn(async move { + let _owner = owner; let _ = rx.await; done.send(()).unwrap(); }); - drop((runtime, executor)); + drop(runtime); tx.send(()).unwrap(); wait.recv_timeout(Duration::from_secs(5)).unwrap(); - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while weak.upgrade().is_some() && std::time::Instant::now() < deadline { - std::thread::yield_now(); - } + released.recv_timeout(Duration::from_secs(5)).unwrap(); assert!(weak.upgrade().is_none()); - drop(task); + handle.block_on(task).unwrap(); + drop(background); } } } diff --git a/tests/runtime_test.py b/tests/runtime_test.py index d01a9e59..783f634e 100644 --- a/tests/runtime_test.py +++ b/tests/runtime_test.py @@ -1,7 +1,7 @@ import asyncio import base64 -import gc import hashlib +from datetime import timedelta import pytest import wreq @@ -11,77 +11,47 @@ from upload_test import read_chunked -def test_runtime_configuration_and_shutdown(): +def test_runtime_configuration(): assert Runtime is wreq.Runtime assert Runtime is wreq.runtime.Runtime - assert Runtime.__module__ == "wreq.runtime" + assert "Runtime" in wreq.__all__ for kwargs in ( {"workers": 0}, {"max_blocking_threads": 0}, {"thread_name": "bad\0name"}, - {"thread_keep_alive": -1}, - {"thread_keep_alive": float("inf")}, - {"thread_keep_alive": float("nan")}, + {"thread_keep_alive": timedelta(microseconds=-1)}, ): with pytest.raises(ValueError): - wreq.Runtime(**kwargs) - - runtime = wreq.Runtime( - workers=2, - work_steal=False, - thread_name="isolated", - max_blocking_threads=3, - thread_keep_alive=0.25, - ) - assert (runtime.workers, runtime.work_steal, runtime.thread_name) == ( - 2, - False, - "isolated", - ) - assert (runtime.max_blocking_threads, runtime.thread_keep_alive) == (3, 0.25) - with pytest.raises(AttributeError): - runtime.workers = 4 + Runtime(**kwargs) + for invalid in (0.25, "1s"): + with pytest.raises(TypeError): + Runtime(thread_keep_alive=invalid) with pytest.raises(TypeError): wreq.Client(runtime=object()) - client = wreq.Client(runtime=runtime) - alias = client.runtime - client.close() - # close cancels requests; the still-live client continues owning its runtime. - with pytest.raises(RuntimeError, match="in use"): - runtime.shutdown_timeout(0) - del client - runtime.shutdown_timeout(0) - runtime.shutdown_timeout(0) - assert alias.closed - with pytest.raises(RuntimeError, match="closed"): - wreq.Client(runtime=runtime) - for timeout in (-1, float("inf"), float("nan")): - with pytest.raises(ValueError): - runtime.shutdown_timeout(timeout) - assert not hasattr(Runtime, "default") + for duration in (None, timedelta(), timedelta(microseconds=250001)): + runtime = Runtime( + workers=1, + work_steal=False, + thread_name=None if duration is None else "isolated", + max_blocking_threads=3, + thread_keep_alive=duration, + ) + assert not hasattr(runtime, "default") + assert not hasattr(runtime, "closed") + assert not hasattr(runtime, "shutdown_timeout") + for factory in (wreq.Client, wreq.blocking.Client): + client = factory(runtime=runtime) + alias = client.runtime + client.close() + del client + # Releasing a client does not close a shared runtime. + other = factory(runtime=alias) + other.close() for factory in (wreq.Client, wreq.blocking.Client): for kwargs in ({}, {"runtime": None}): client = factory(**kwargs) - default = client.runtime - assert default.work_steal and default.workers > 0 - with pytest.raises(RuntimeError, match="default"): - default.shutdown_timeout(0) + assert isinstance(client.runtime, Runtime) client.close() - assert "Runtime" in wreq.__all__ - - -async def shutdown(runtime): - # Aborted Rust tasks may finish releasing their owners after cancellation returns. - for _ in range(100): - gc.collect() - try: - runtime.shutdown_timeout(0.1) - return - except RuntimeError as error: - if "in use" not in str(error): - raise - await asyncio.sleep(0.01) - runtime.shutdown_timeout(0.1) @pytest.mark.asyncio @@ -97,20 +67,15 @@ async def test_response_and_stream_keep_runtime_alive(steal): response = await asyncio.wait_for(task, 5) del task client.close() - del client - with pytest.raises(RuntimeError, match="in use"): - runtime.shutdown_timeout(0) + del client, runtime stream = response.stream() del response - with pytest.raises(RuntimeError, match="in use"): - runtime.shutdown_timeout(0) writer.write(b"body") await writer.drain() assert await asyncio.wait_for(anext(stream), 5) == b"body" with pytest.raises(StopAsyncIteration): await anext(stream) del stream - await shutdown(runtime) @pytest.mark.asyncio @@ -120,6 +85,7 @@ async def test_shared_runtime_cancellation_and_upload(steal): async with local_server() as (url, connections): first = wreq.Client(runtime=runtime, proxies=[]) second = wreq.Client(runtime=runtime, proxies=[]) + del runtime pending = asyncio.create_task(first.get(url)) await asyncio.wait_for(connections.get(), 5) first.close() @@ -147,7 +113,6 @@ async def chunks(): await response.close() second.close() del response, task, second - await shutdown(runtime) @pytest.mark.asyncio @@ -157,7 +122,7 @@ async def test_blocking_client_uses_custom_runtime(steal): def request(url): with wreq.blocking.Client(runtime=runtime, proxies=[]) as client: - assert client.runtime.work_steal == steal + assert isinstance(client.runtime, Runtime) with client.post(url, body=iter((b"blocking",))) as response: with response.stream() as stream: return b"".join(stream) @@ -170,7 +135,6 @@ def request(url): await writer.drain() assert await asyncio.wait_for(task, 5) == b"ok" del task - await shutdown(runtime) @pytest.mark.asyncio @@ -210,9 +174,7 @@ async def accept(reader, writer): ) reader, writer = await asyncio.wait_for(connections.get(), 5) client.close() - del client - with pytest.raises(RuntimeError, match="in use"): - runtime.shutdown_timeout(0) + del client, runtime writer.write(b"\x81\x04pong") await writer.drain() message = await asyncio.to_thread(ws.recv) if blocking else await ws.recv() @@ -239,7 +201,6 @@ async def accept(reader, writer): await writer.wait_closed() server.close() await server.wait_closed() - await shutdown(runtime) @pytest.mark.asyncio @@ -308,7 +269,7 @@ async def request(): assert len(connections) == 1 assert not errors client.close() - del client + del client, runtime finally: server.close() for writer in connections: @@ -316,4 +277,3 @@ async def request(): await asyncio.gather(*(writer.wait_closed() for writer in connections)) await asyncio.gather(*handlers) await server.wait_closed() - await shutdown(runtime) From 0e8b856303fed880c8c79bed9373b9829aad345b Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 30 Sep 2026 01:57:08 +0800 Subject: [PATCH 11/15] refactor(stream): consolidate Python stream adapters --- src/client/body/stream.rs | 162 +++++++++++++++++++++++++++---- src/client/body/stream/upload.rs | 133 ------------------------- 2 files changed, 143 insertions(+), 152 deletions(-) delete mode 100644 src/client/body/stream/upload.rs diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index 410a2432..143aa939 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -1,5 +1,3 @@ -mod upload; - use std::{ pin::Pin, sync::Arc, @@ -7,15 +5,20 @@ use std::{ }; use bytes::Bytes; -use futures_util::{FutureExt, Stream}; +use futures_util::{FutureExt, Stream, future::poll_fn}; use http_body_util::BodyExt; use pyo3::{ coroutine::CancelHandle, intern, prelude::*, pybacked::{PyBackedBytes, PyBackedStr}, + sync::PyOnceLock, +}; +use tokio::{ + runtime::Handle, + sync::{Mutex, mpsc}, + task::JoinHandle, }; -use tokio::{runtime::Handle, sync::Mutex, task::JoinHandle}; use crate::{ buffer::PyBuffer, @@ -30,7 +33,7 @@ type Pending = Option>>>; /// Python stream source. enum PyStreamSource { Sync(Arc>), - Async(upload::Upload), + Async(PyAsyncStream), } /// A bytes-like object that can be extracted from Python. @@ -53,23 +56,21 @@ pub struct PyStream { pending: Pending, } +/// Adapts a Python async generator into a byte stream with bounded buffering. +/// Dropping the stream cancels its producer on the Python event loop. +struct PyAsyncStream { + rx: mpsc::Receiver>, + task: Option<(Py, Py)>, +} + +#[pyclass(frozen)] +struct Sender(mpsc::Sender>); + /// A bytes stream response. #[derive(Clone)] #[pyclass(subclass, frozen, skip_from_py_object)] pub struct Streamer(Arc>>, Runtime, Handle); -// ===== impl PyStream ===== - -impl From for PyStream { - #[inline] - fn from(inner: PyStreamSource) -> Self { - PyStream { - inner, - pending: None, - } - } -} - // ===== impl Streamer ===== impl Streamer { @@ -202,7 +203,7 @@ impl Streamer { } } -// ===== PyBytesLike ===== +// ===== impl PyBytesLike ===== impl From for Bytes { #[inline] @@ -216,12 +217,22 @@ impl From for Bytes { // ===== impl PyStream ===== +impl From for PyStream { + #[inline] + fn from(inner: PyStreamSource) -> Self { + PyStream { + inner, + pending: None, + } + } +} + impl FromPyObject<'_, '_> for PyStream { type Error = PyErr; fn extract(ob: Borrowed) -> PyResult { if ob.hasattr(intern!(ob.py(), "asend"))? { - upload::Upload::new(ob.to_owned()) + PyAsyncStream::new(ob.to_owned()) .map(PyStreamSource::Async) .map(PyStream::from) } else { @@ -271,3 +282,116 @@ impl Stream for PyStream { } } } + +// ===== impl PyAsyncStream ===== + +impl PyAsyncStream { + fn new(generator: Bound<'_, PyAny>) -> PyResult { + static FORWARD: PyOnceLock> = PyOnceLock::new(); + let py = generator.py(); + let event_loop = py.import("asyncio")?.call_method0("get_running_loop")?; + let forward = FORWARD.get_or_try_init(py, || { + PyModule::from_code( + py, + c"import asyncio + +async def forward(gen, sender): + try: + try: + async for item in gen: + if not await sender.send(item, False): + return + finally: + close = getattr(gen, 'aclose', None) + if close is not None: + await close() + except BaseException as error: + await sender.send(error, True) + if isinstance(error, asyncio.CancelledError): + raise +", + c"wreq/_async_stream.py", + c"wreq._async_stream", + )? + .getattr("forward") + .map(Bound::unbind) + })?; + let (tx, rx) = mpsc::channel(1); + let coroutine = forward.bind(py).call1((generator, Sender(tx)))?; + // create_task captures the caller's contextvars on the running loop. + let task = match event_loop.call_method1("create_task", (&coroutine,)) { + Ok(task) => task, + Err(err) => { + let _ = coroutine.call_method0("close"); + return Err(err); + } + }; + Ok(Self { + rx, + task: Some((task.unbind(), event_loop.unbind())), + }) + } +} + +impl Stream for PyAsyncStream { + type Item = PyResult; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.get_mut().rx.poll_recv(cx) + } +} + +impl Drop for PyAsyncStream { + fn drop(&mut self) { + self.rx.close(); + if let Some((task, event_loop)) = self.task.take() { + // Body drop can run on Tokio: acquire the interpreter on a blocking thread. + crate::runtime::get().1.spawn_blocking(move || { + Python::try_attach(|py| { + if let Ok(cancel) = task.bind(py).getattr(intern!(py, "cancel")) { + let _ = event_loop.call_method1( + py, + intern!(py, "call_soon_threadsafe"), + (cancel,), + ); + } + }); + }); + } + } +} + +// ===== impl Sender ===== + +#[pymethods] +impl Sender { + async fn send( + &self, + item: Py, + error: bool, + #[pyo3(cancel_handle)] mut cancel: CancelHandle, + ) -> PyResult { + let item = Python::attach(|py| { + if error { + Ok(Err(PyErr::from_value(item.into_bound(py)))) + } else { + item.extract(py).map(Ok) + } + })?; + let item = match self.0.try_send(item) { + Ok(()) => return Ok(true), + Err(mpsc::error::TrySendError::Closed(_)) => return Ok(false), + Err(mpsc::error::TrySendError::Full(item)) => item, + }; + let tx = self.0.clone(); + // Channel readiness is runtime-independent; keep this on the Python loop. + let mut send = std::pin::pin!(tx.send(item)); + tokio::select! { + biased; + exception = poll_fn(|cx| cancel.poll_cancelled(cx)) => { + Err(Python::attach(|py| PyErr::from_value(exception.into_bound(py)))) + } + result = poll_fn(|cx| nogil::poll_with_guard(send.as_mut(), cx)) => Ok(result.is_ok()), + } + } +} diff --git a/src/client/body/stream/upload.rs b/src/client/body/stream/upload.rs deleted file mode 100644 index 6d355df7..00000000 --- a/src/client/body/stream/upload.rs +++ /dev/null @@ -1,133 +0,0 @@ -use std::{ - pin::Pin, - task::{Context, Poll}, -}; - -use futures_util::{Stream, future::poll_fn}; -use pyo3::{coroutine::CancelHandle, intern, prelude::*, sync::PyOnceLock}; -use tokio::sync::mpsc; - -use super::PyBytesLike; -use crate::client::nogil::poll_with_guard; - -/// Owns the upload task; dropping the body cancels it on its Python event loop. -pub struct Upload { - rx: mpsc::Receiver>, - task: Option<(Py, Py)>, -} - -#[pyclass(frozen)] -struct Sender(mpsc::Sender>); - -// ===== impl Upload ===== - -impl Upload { - pub fn new(generator: Bound<'_, PyAny>) -> PyResult { - static FORWARD: PyOnceLock> = PyOnceLock::new(); - let py = generator.py(); - let event_loop = py.import("asyncio")?.call_method0("get_running_loop")?; - let forward = FORWARD.get_or_try_init(py, || { - PyModule::from_code( - py, - c"import asyncio - -async def forward(gen, sender): - try: - try: - async for item in gen: - if not await sender.send(item, False): - return - finally: - close = getattr(gen, 'aclose', None) - if close is not None: - await close() - except BaseException as error: - await sender.send(error, True) - if isinstance(error, asyncio.CancelledError): - raise -", - c"wreq/_upload.py", - c"wreq._upload", - )? - .getattr("forward") - .map(Bound::unbind) - })?; - let (tx, rx) = mpsc::channel(1); - let coroutine = forward.bind(py).call1((generator, Sender(tx)))?; - // create_task captures the caller's contextvars on the running loop. - let task = match event_loop.call_method1("create_task", (&coroutine,)) { - Ok(task) => task, - Err(err) => { - let _ = coroutine.call_method0("close"); - return Err(err); - } - }; - Ok(Self { - rx, - task: Some((task.unbind(), event_loop.unbind())), - }) - } -} - -impl Stream for Upload { - type Item = PyResult; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.get_mut().rx.poll_recv(cx) - } -} - -impl Drop for Upload { - fn drop(&mut self) { - self.rx.close(); - if let Some((task, event_loop)) = self.task.take() { - // Body drop can run on Tokio: acquire the interpreter on a blocking thread. - crate::runtime::get().1.spawn_blocking(move || { - Python::try_attach(|py| { - if let Ok(cancel) = task.bind(py).getattr(intern!(py, "cancel")) { - let _ = event_loop.call_method1( - py, - intern!(py, "call_soon_threadsafe"), - (cancel,), - ); - } - }); - }); - } - } -} - -// ===== impl Sender ===== - -#[pymethods] -impl Sender { - async fn send( - &self, - item: Py, - error: bool, - #[pyo3(cancel_handle)] mut cancel: CancelHandle, - ) -> PyResult { - let item = Python::attach(|py| { - if error { - Ok(Err(PyErr::from_value(item.into_bound(py)))) - } else { - item.extract(py).map(Ok) - } - })?; - let item = match self.0.try_send(item) { - Ok(()) => return Ok(true), - Err(mpsc::error::TrySendError::Closed(_)) => return Ok(false), - Err(mpsc::error::TrySendError::Full(item)) => item, - }; - let tx = self.0.clone(); - // Channel readiness is runtime-independent; keep this on the Python loop. - let mut send = std::pin::pin!(tx.send(item)); - tokio::select! { - biased; - exception = poll_fn(|cx| cancel.poll_cancelled(cx)) => { - Err(Python::attach(|py| PyErr::from_value(exception.into_bound(py)))) - } - result = poll_fn(|cx| poll_with_guard(send.as_mut(), cx)) => Ok(result.is_ok()), - } - } -} From d186e7c089fb89466a0a1c349fbd9b15480db6cf Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 30 Sep 2026 09:43:38 +0800 Subject: [PATCH 12/15] refactor(runtime): retain selected handles in runtime --- src/client.rs | 14 ++------- src/client/body/multipart.rs | 2 +- src/client/body/stream.rs | 23 ++++++--------- src/client/nogil.rs | 14 ++++----- src/client/req.rs | 6 ++-- src/client/resp/http.rs | 18 ++++++------ src/client/resp/ws.rs | 43 +++++---------------------- src/runtime.rs | 56 ++++++++++++++++++++++-------------- 8 files changed, 70 insertions(+), 106 deletions(-) diff --git a/src/client.rs b/src/client.rs index b4452928..870ee59f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -13,7 +13,6 @@ use std::{ use pyo3::{IntoPyObjectExt, coroutine::CancelHandle, prelude::*, pybacked::PyBackedStr}; use req::{Request, WebSocketRequest}; -use tokio::runtime::Handle; use tokio_util::sync::CancellationToken; use wreq::tls::trust::CertStore; @@ -237,7 +236,6 @@ impl FromPyObject<'_, '_> for Builder { pub struct Client { inner: wreq::Client, runtime: runtime::Runtime, - handle: Handle, cancel: CancellationToken, raise_for_status: bool, @@ -255,11 +253,10 @@ pub struct BlockingClient(Client); impl Default for Client { fn default() -> Self { - let (runtime, handle) = runtime::get(); + let runtime = runtime::get(); Self { inner: wreq::Client::default(), runtime: runtime.clone(), - handle: handle.clone(), cancel: CancellationToken::new(), raise_for_status: false, cookie_jar: None, @@ -274,8 +271,8 @@ impl Client { #[pyo3(signature = (**kwds))] fn new(py: Python, kwds: Option) -> PyResult { py.detach(|| { - let (runtime, handle) = match kwds.as_ref().and_then(|config| config.runtime.as_ref()) { - Some(runtime) => (runtime.clone(), runtime.handle()?.clone()), + let runtime = match kwds.as_ref().and_then(|config| config.runtime.as_ref()) { + Some(runtime) => runtime.select()?, None => runtime::get().clone(), }; // Create the client builder. @@ -488,7 +485,6 @@ impl Client { .map(|inner| Client { inner, runtime, - handle, cancel: CancellationToken::new(), cookie_jar, raise_for_status, @@ -618,7 +614,6 @@ impl Client { ) -> PyResult { NoGIL::new_with_token( &self.runtime, - &self.handle, execute_request(self.clone(), method, url, kwds), cancel, self.cancel.clone(), @@ -637,7 +632,6 @@ impl Client { ) -> PyResult { NoGIL::new_with_token( &self.runtime, - &self.handle, execute_websocket_request(self.clone(), url, kwds), cancel, self.cancel.clone(), @@ -796,7 +790,6 @@ impl BlockingClient { py.detach(|| { nogil::block_on( &self.0.runtime, - &self.0.handle, execute_request(self.0.clone(), method, url, kwds), ) .map(Into::into) @@ -814,7 +807,6 @@ impl BlockingClient { py.detach(|| { nogil::block_on( &self.0.runtime, - &self.0.handle, execute_websocket_request(self.0.clone(), url, kwds), ) .map(Into::into) diff --git a/src/client/body/multipart.rs b/src/client/body/multipart.rs index 963b2a1e..a073da18 100644 --- a/src/client/body/multipart.rs +++ b/src/client/body/multipart.rs @@ -133,7 +133,7 @@ impl Part { let mut inner = match value { Value::Text(text) => multipart::Part::stream(Bytes::from_owner(text)), Value::Bytes(bytes) => multipart::Part::stream(Bytes::from_owner(bytes)), - Value::File(path) => crate::runtime::get().1.block_on(async move { + Value::File(path) => crate::runtime::get().handle().block_on(async move { multipart::Part::file(path).await.map_err(Error::from) })?, Value::Stream(stream) => { diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index 143aa939..67fe57c8 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -15,7 +15,6 @@ use pyo3::{ sync::PyOnceLock, }; use tokio::{ - runtime::Handle, sync::{Mutex, mpsc}, task::JoinHandle, }; @@ -69,15 +68,15 @@ struct Sender(mpsc::Sender>); /// A bytes stream response. #[derive(Clone)] #[pyclass(subclass, frozen, skip_from_py_object)] -pub struct Streamer(Arc>>, Runtime, Handle); +pub struct Streamer(Arc>>, Runtime); // ===== impl Streamer ===== impl Streamer { /// Create a new [`Streamer`] instance. #[inline] - pub fn new(resp: wreq::Response, runtime: Runtime, handle: Handle) -> Streamer { - Streamer(Arc::new(Mutex::new(Some(resp))), runtime, handle) + pub fn new(resp: wreq::Response, runtime: Runtime) -> Streamer { + Streamer(Arc::new(Mutex::new(Some(resp))), runtime) } async fn next(self, error: fn() -> Error) -> PyResult { @@ -117,7 +116,7 @@ impl Streamer { #[inline] fn __next__(&self, py: Python) -> PyResult { - py.detach(|| nogil::block_on(&self.1, &self.2, self.clone().next(|| Error::StopIteration))) + py.detach(|| nogil::block_on(&self.1, self.clone().next(|| Error::StopIteration))) } #[inline] @@ -159,14 +158,9 @@ impl Streamer { Some(cancel.throw_callback()), async move { let runtime = this.1.clone(); - let handle = this.2.clone(); - let frame = NoGIL::new( - &runtime, - &handle, - this.next(|| Error::StopAsyncIteration), - cancel, - ) - .await?; + let frame = + NoGIL::new(&runtime, this.next(|| Error::StopAsyncIteration), cancel) + .await?; // PyO3 polls this coroutine while attached, outside the Tokio task. Python::attach(|py| frame.into_pyobject(py).map(|obj| obj.unbind())) }, @@ -190,7 +184,6 @@ impl Streamer { let this = self.0.clone(); NoGIL::new( &self.1, - &self.2, async move { if let Some(resp) = this.lock().await.take() { drop(resp) @@ -346,7 +339,7 @@ impl Drop for PyAsyncStream { self.rx.close(); if let Some((task, event_loop)) = self.task.take() { // Body drop can run on Tokio: acquire the interpreter on a blocking thread. - crate::runtime::get().1.spawn_blocking(move || { + crate::runtime::get().handle().spawn_blocking(move || { Python::try_attach(|py| { if let Ok(cancel) = task.bind(py).getattr(intern!(py, "cancel")) { let _ = event_loop.call_method1( diff --git a/src/client/nogil.rs b/src/client/nogil.rs index 97a411ae..244f2e29 100644 --- a/src/client/nogil.rs +++ b/src/client/nogil.rs @@ -11,7 +11,6 @@ use pyo3::{ exceptions::{PyRuntimeError, asyncio::CancelledError}, prelude::*, }; -use tokio::runtime::Handle; use tokio_util::{sync::CancellationToken, task::AbortOnDropHandle}; use crate::runtime::Runtime; @@ -36,7 +35,7 @@ where { /// Spawn on the selected handle, keeping the runtime alive until the task ends. #[inline] - pub fn new(runtime: &Runtime, handle: &Handle, fut: Fut, cancel: CancelHandle) -> Self + pub fn new(runtime: &Runtime, fut: Fut, cancel: CancelHandle) -> Self where Fut: Future> + Send + 'static, { @@ -44,7 +43,7 @@ where Self { handle: AbortOnDropHandle::new(Python::attach(|py| { py.detach(|| { - handle.spawn(async move { + runtime.handle().spawn(async move { let _owner = owner; fut.await }) @@ -58,7 +57,6 @@ where #[inline] pub fn new_with_token( runtime: &Runtime, - handle: &Handle, fut: Fut, cancel: CancelHandle, cancel_token: CancellationToken, @@ -68,7 +66,6 @@ where { Self::new( runtime, - handle, async move { tokio::select! { result = fut => result, @@ -112,17 +109,18 @@ where /// Run network work on its selected worker; only wait for completion on the caller. /// The caller must be detached from Python and outside an async Tokio context. -pub fn block_on(runtime: &Runtime, handle: &Handle, future: F) -> PyResult +pub fn block_on(runtime: &Runtime, future: F) -> PyResult where F: Future> + Send + 'static, T: Send + 'static, { let owner = runtime.clone(); - let task = handle.spawn(async move { + let task = runtime.handle().spawn(async move { let _owner = owner; future.await }); - handle + runtime + .handle() .block_on(AbortOnDropHandle::new(task)) .map_err(|err| PyRuntimeError::new_err(err.to_string()))? } diff --git a/src/client/req.rs b/src/client/req.rs index 9689ea64..639497bb 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -419,7 +419,7 @@ where Ok(r) } }) - .map(|response| Response::new(response, client.runtime.clone(), client.handle.clone())) + .map(|response| Response::new(response, client.runtime.clone())) .map_err(Error::Library) .map_err(Into::into) } @@ -547,9 +547,7 @@ where // Send the WebSocket request. builder .send() - .and_then(|response| { - WebSocket::new(response, client.runtime.clone(), client.handle.clone()) - }) + .and_then(|response| WebSocket::new(response, client.runtime.clone())) .await .map_err(Error::Library) .map_err(Into::into) diff --git a/src/client/resp/http.rs b/src/client/resp/http.rs index 58aae0df..56ef6518 100644 --- a/src/client/resp/http.rs +++ b/src/client/resp/http.rs @@ -35,7 +35,6 @@ pub struct Response { parts: Parts, body: Arc>, runtime: Runtime, - handle: tokio::runtime::Handle, } /// Represents the state of the HTTP response body. @@ -54,7 +53,7 @@ pub struct BlockingResponse(Response); impl Response { /// Create a new [`Response`] instance. - pub fn new(response: wreq::Response, runtime: Runtime, handle: tokio::runtime::Handle) -> Self { + pub fn new(response: wreq::Response, runtime: Runtime) -> Self { let uri = response.uri().clone(); let response = HttpResponse::from(response) .map(Body::Streamable) @@ -66,7 +65,6 @@ impl Response { parts, body, runtime, - handle, } } @@ -221,7 +219,7 @@ impl Response { /// Get the response into a `Stream` of `Bytes` from the body. pub fn stream(&self) -> PyResult { self.stream_response() - .map(|response| Streamer::new(response, self.runtime.clone(), self.handle.clone())) + .map(|response| Streamer::new(response, self.runtime.clone())) .map_err(Into::into) } @@ -236,7 +234,7 @@ impl Response { .cache_response() .and_then(|resp| ResponseExt::text(resp, encoding)) .map_err(Into::into); - NoGIL::new(&self.runtime, &self.handle, fut, cancel).await + NoGIL::new(&self.runtime, fut, cancel).await } /// Get the JSON content of the response. @@ -245,7 +243,7 @@ impl Response { .cache_response() .and_then(ResponseExt::json::) .map_err(Into::into); - NoGIL::new(&self.runtime, &self.handle, fut, cancel).await + NoGIL::new(&self.runtime, fut, cancel).await } /// Get the bytes content of the response. @@ -255,7 +253,7 @@ impl Response { .and_then(ResponseExt::bytes) .map_ok(PyBuffer::from) .map_err(Into::into); - NoGIL::new(&self.runtime, &self.handle, fut, cancel).await + NoGIL::new(&self.runtime, fut, cancel).await } /// Close the response. @@ -390,7 +388,7 @@ impl BlockingResponse { .cache_response() .and_then(|resp| ResponseExt::text(resp, encoding)) .map_err(Into::into); - crate::client::nogil::block_on(&self.0.runtime, &self.0.handle, fut) + crate::client::nogil::block_on(&self.0.runtime, fut) }) } @@ -402,7 +400,7 @@ impl BlockingResponse { .cache_response() .and_then(ResponseExt::json::) .map_err(Into::into); - crate::client::nogil::block_on(&self.0.runtime, &self.0.handle, fut) + crate::client::nogil::block_on(&self.0.runtime, fut) }) } @@ -415,7 +413,7 @@ impl BlockingResponse { .and_then(ResponseExt::bytes) .map_ok(PyBuffer::from) .map_err(Into::into); - crate::client::nogil::block_on(&self.0.runtime, &self.0.handle, fut) + crate::client::nogil::block_on(&self.0.runtime, fut) }) } diff --git a/src/client/resp/ws.rs b/src/client/resp/ws.rs index 4af8ce45..620d99ac 100644 --- a/src/client/resp/ws.rs +++ b/src/client/resp/ws.rs @@ -45,7 +45,6 @@ pub struct WebSocket { protocol: Option, cmd: mpsc::UnboundedSender, runtime: Runtime, - handle: tokio::runtime::Handle, } /// A blocking WebSocket response. @@ -56,11 +55,7 @@ pub struct BlockingWebSocket(WebSocket); impl WebSocket { /// Creates a new [`WebSocket`] instance. - pub async fn new( - response: WebSocketResponse, - runtime: Runtime, - handle: tokio::runtime::Handle, - ) -> wreq::Result { + pub async fn new(response: WebSocketResponse, runtime: Runtime) -> wreq::Result { let (version, status, remote_addr, local_addr, headers) = ( Version::from_ffi(response.version()), StatusCode(response.status()), @@ -75,7 +70,6 @@ impl WebSocket { Ok(WebSocket { runtime, - handle, version, status, remote_addr, @@ -114,7 +108,7 @@ impl WebSocket { timeout: Option, ) -> PyResult> { let tx = self.cmd.clone(); - NoGIL::new(&self.runtime, &self.handle, cmd::recv(tx, timeout), cancel).await + NoGIL::new(&self.runtime, cmd::recv(tx, timeout), cancel).await } /// Send a message to the WebSocket. @@ -125,7 +119,7 @@ impl WebSocket { message: Message, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(&self.runtime, &self.handle, cmd::send(tx, message), cancel).await + NoGIL::new(&self.runtime, cmd::send(tx, message), cancel).await } /// Send multiple messages to the WebSocket. @@ -136,13 +130,7 @@ impl WebSocket { messages: Vec, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new( - &self.runtime, - &self.handle, - cmd::send_all(tx, messages), - cancel, - ) - .await + NoGIL::new(&self.runtime, cmd::send_all(tx, messages), cancel).await } /// Close the WebSocket connection. @@ -154,13 +142,7 @@ impl WebSocket { reason: Option, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new( - &self.runtime, - &self.handle, - cmd::close(tx, code, reason), - cancel, - ) - .await + NoGIL::new(&self.runtime, cmd::close(tx, code, reason), cancel).await } } @@ -181,7 +163,6 @@ impl WebSocket { let tx = self.cmd.clone(); NoGIL::new( &self.runtime, - &self.handle, cmd::close(tx, None, None), CancelHandle::new(), ) @@ -245,11 +226,7 @@ impl BlockingWebSocket { #[pyo3(signature = (timeout=None))] pub fn recv(&self, py: Python, timeout: Option) -> PyResult> { py.detach(|| { - crate::client::nogil::block_on( - &self.0.runtime, - &self.0.handle, - cmd::recv(self.0.cmd.clone(), timeout), - ) + crate::client::nogil::block_on(&self.0.runtime, cmd::recv(self.0.cmd.clone(), timeout)) }) } @@ -257,11 +234,7 @@ impl BlockingWebSocket { #[pyo3(signature = (message))] pub fn send(&self, py: Python, message: Message) -> PyResult<()> { py.detach(|| { - crate::client::nogil::block_on( - &self.0.runtime, - &self.0.handle, - cmd::send(self.0.cmd.clone(), message), - ) + crate::client::nogil::block_on(&self.0.runtime, cmd::send(self.0.cmd.clone(), message)) }) } @@ -271,7 +244,6 @@ impl BlockingWebSocket { py.detach(|| { crate::client::nogil::block_on( &self.0.runtime, - &self.0.handle, cmd::send_all(self.0.cmd.clone(), messages), ) }) @@ -288,7 +260,6 @@ impl BlockingWebSocket { py.detach(|| { crate::client::nogil::block_on( &self.0.runtime, - &self.0.handle, cmd::close(self.0.cmd.clone(), code, reason), ) }) diff --git a/src/runtime.rs b/src/runtime.rs index ea6b8d79..59db0a35 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -13,23 +13,38 @@ use tokio::runtime::Handle; /// Shared Tokio runtime, released after its clients and active work are dropped. #[derive(Clone)] #[pyclass(frozen, skip_from_py_object)] -pub struct Runtime(Option>); +pub struct Runtime { + inner: Option>, + handle: Handle, +} impl Runtime { - /// Borrow a Tokio handle; in no-steal mode, select once per client and retain it. - pub fn handle(&self) -> PyResult<&Handle> { - self.0 - .as_deref() - .map(PingoraRuntime::get_handle) - .ok_or_else(|| PyRuntimeError::new_err("Runtime is unavailable")) + /// Borrow the selected worker's handle without changing the selection. + pub fn handle(&self) -> &Handle { + &self.handle + } + + /// Share the runtime and select a worker for a new client. + pub fn select(&self) -> PyResult { + let inner = self + .inner + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("Runtime is unavailable"))?; + Ok(Self { + inner: Some(inner.clone()), + handle: inner.get_handle().clone(), + }) } } impl From for Runtime { fn from(runtime: PingoraRuntime) -> Self { // No-steal workers are lazy upstream. Start them before sharing the runtime. - runtime.get_handle(); - Self(Some(Arc::new(runtime))) + let handle = runtime.get_handle().clone(); + Self { + inner: Some(Arc::new(runtime)), + handle, + } } } @@ -96,7 +111,7 @@ impl Runtime { impl Drop for Runtime { fn drop(&mut self) { // The final owner may be released on a worker or while holding the GIL. - if let Some(runtime) = self.0.take().and_then(Arc::into_inner) { + if let Some(runtime) = self.inner.take().and_then(Arc::into_inner) { match runtime { PingoraRuntime::Steal { runtime, .. } => runtime.shutdown_background(), PingoraRuntime::NoSteal(runtime) => drop(runtime), @@ -106,14 +121,13 @@ impl Drop for Runtime { } /// Create the shared runtime on first use and retain it for the process lifetime. -pub fn get() -> &'static (Runtime, Handle) { - static RUNTIME: OnceLock<(Runtime, Handle)> = OnceLock::new(); +pub fn get() -> &'static Runtime { + static RUNTIME: OnceLock = OnceLock::new(); - fn create() -> (Runtime, Handle) { + fn create() -> Runtime { let workers = std::thread::available_parallelism().map_or(1, usize::from); let runtime = RuntimeBuilder::new(workers, env!("CARGO_PKG_NAME")).build(); - let handle = runtime.get_handle().clone(); - (runtime.into(), handle) + runtime.into() } if let Some(runtime) = RUNTIME.get() { @@ -136,12 +150,12 @@ mod tests { .work_steal(false) .build(), ); - let first = runtime.handle().unwrap(); - let second = runtime.handle().unwrap(); + let first = runtime.select().unwrap(); + let second = runtime.select().unwrap(); let caller = std::thread::current().id(); let mut ids = Vec::new(); - for handle in [first, second, first, second] { - let id = crate::client::nogil::block_on(&runtime, handle, async { + for worker in [&first, &second, &first.clone(), &second.clone()] { + let id = crate::client::nogil::block_on(worker, async { let thread = std::thread::current().id(); for _ in 0..8 { tokio::task::yield_now().await; @@ -178,8 +192,8 @@ mod tests { .work_steal(steal) .build(), ); - let weak = Arc::downgrade(runtime.0.as_ref().unwrap()); - let handle = runtime.handle().unwrap().clone(); + let weak = Arc::downgrade(runtime.inner.as_ref().unwrap()); + let handle = runtime.handle().clone(); let (dropped, released) = std::sync::mpsc::channel(); let guard = NotifyOnDrop(dropped); let background = handle.spawn(async move { From e952bbeef596dd047be927c695619443bf40f649 Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 30 Sep 2026 10:05:00 +0800 Subject: [PATCH 13/15] fix(client): propagate blocking client cancellation --- docs/source/guide/advanced.md | 12 +++++++--- docs/source/guide/blocking.md | 21 ++++++++++++++++ python/wreq/blocking.py | 6 ++--- python/wreq/exceptions.py | 2 +- python/wreq/runtime.py | 5 +++- python/wreq/wreq.py | 9 +++---- src/client.rs | 18 ++++++++++---- src/client/nogil.rs | 23 ++++++++++-------- tests/runtime_test.py | 45 +++++++++++++++++++++++++++++++++++ 9 files changed, 115 insertions(+), 26 deletions(-) diff --git a/docs/source/guide/advanced.md b/docs/source/guide/advanced.md index e09cb34d..f8961f93 100644 --- a/docs/source/guide/advanced.md +++ b/docs/source/guide/advanced.md @@ -12,6 +12,10 @@ Async upload generators run on the caller's running event loop with its context Their exceptions fail the request. When an upload ends, the generator is closed; cancelling or dropping the upload schedules producer cancellation and cleanup on that loop. Keep the loop running until generator cleanup has finished. This also applies to async multipart parts. +Construct async-generator `Part` objects inside a running event loop; their producers +start at construction, with bounded buffering before the request consumes them. +Use synchronous iterators for blocking uploads. A blocking call on the producer's +event-loop thread prevents async generators from progressing. ```python import asyncio @@ -95,7 +99,7 @@ if __name__ == "__main__": ### Custom runtimes Clients share a global multi-thread runtime when `runtime` is omitted or `None`. -It starts when the first default client is created. Construct a `Runtime` to +It starts on first use. Construct a `Runtime` to start a separate worker pool for an async or blocking client: ```python @@ -125,7 +129,7 @@ CPU pinning. Sharing the same `Runtime` between clients is supported, and Custom runtimes start their threads during construction, before any client is bound or request is sent. -`thread_name=None` uses the package name, `wreq-python`, as the thread-name prefix. +`thread_name=None` uses the package name, `wreq-python`, as the thread name. `thread_keep_alive` accepts a nonnegative `datetime.timedelta`. `max_blocking_threads` and `thread_keep_alive` default to Tokio's settings @@ -137,7 +141,9 @@ shared runtime; a dedicated client runtime does not isolate Python's GIL or every process resource. DNS resolvers are owned by individual clients so their connections are not shared across runtimes. -Closing a client cancels its requests but does not shut down its runtime. +Closing a client cancels pending requests and rejects new requests with +`asyncio.CancelledError`, for both async and blocking APIs. It does not shut down +the runtime or invalidate existing responses and WebSockets. Clients, responses, streams and active tasks share ownership. Dropping the last owner automatically releases a custom runtime without synchronously waiting for its workers; already running blocking work may finish later. The default runtime diff --git a/docs/source/guide/blocking.md b/docs/source/guide/blocking.md index c8e4901f..68dcf59d 100644 --- a/docs/source/guide/blocking.md +++ b/docs/source/guide/blocking.md @@ -65,6 +65,27 @@ if __name__ == "__main__": main() ``` +### Custom Runtime + +The blocking client accepts the same `Runtime` as the async client. Without one, +it uses the shared global multi-thread runtime. + +```python +from wreq.blocking import Client +from wreq.runtime import Runtime + +runtime = Runtime(workers=1, work_steal=False) +with Client(runtime=runtime) as client: + with client.get("https://httpbin.io/get") as response: + print(response.text()) +``` + +Network work runs on the selected worker while the calling thread waits. +`client.runtime` is read-only. Closing the client does not shut down a shared +runtime; it cancels pending requests and rejects new ones with +`asyncio.CancelledError`. See [custom runtimes](advanced.md#custom-runtimes) for +configuration and lifetime details. + ### Cookies ```python diff --git a/python/wreq/blocking.py b/python/wreq/blocking.py index 3459357a..432a61b7 100644 --- a/python/wreq/blocking.py +++ b/python/wreq/blocking.py @@ -213,6 +213,7 @@ class Client: """ runtime: Runtime + """Read-only shared runtime used by this client and its responses.""" cookie_jar: Jar | None r""" @@ -248,9 +249,8 @@ def __init__( def close(self) -> None: r""" - Closes the client and any associated resources. - - After calling this method, the client should not be used to make further requests. + Cancels pending requests and rejects new ones with `asyncio.CancelledError`. + Existing responses, WebSockets and the shared runtime remain usable. Examples: diff --git a/python/wreq/exceptions.py b/python/wreq/exceptions.py index 68aaa5d8..b50190c0 100644 --- a/python/wreq/exceptions.py +++ b/python/wreq/exceptions.py @@ -29,7 +29,7 @@ class RustPanic(Exception): r""" - A panic occurred in the underlying Rust code. + Compatibility exception; Rust panics are not translated to this type. """ diff --git a/python/wreq/runtime.py b/python/wreq/runtime.py index 6d6360d7..1a3c6ab7 100644 --- a/python/wreq/runtime.py +++ b/python/wreq/runtime.py @@ -8,7 +8,8 @@ class Runtime: """Shared Tokio runtime whose workers start when constructed. - Clients and active work keep it alive; the last owner releases it automatically. + Clients, responses and active work keep it alive; the last owner releases + it automatically. No-steal clients keep a fixed worker, without CPU pinning. """ @@ -24,6 +25,8 @@ def __init__( """Thread counts must be positive; thread_keep_alive is a nonnegative timedelta. workers defaults to available CPU parallelism, or 1 if unavailable. + work_steal=True uses a multi-thread pool; False uses independent + single-thread workers. thread_name defaults to the package name, "wreq-python". Thread names cannot contain NUL characters. Blocking-pool settings apply to each worker runtime in no-steal mode. diff --git a/python/wreq/wreq.py b/python/wreq/wreq.py index 76abedd0..cb70b281 100644 --- a/python/wreq/wreq.py +++ b/python/wreq/wreq.py @@ -162,7 +162,8 @@ def __init__( r""" Creates a new part. - Async generators run on the caller's running event loop and are closed after use. + Construct async-generator parts inside a running event loop. Their producers + start immediately on that loop and are closed after use. # Arguments - `name` - The name of the part. @@ -1111,6 +1112,7 @@ class Client: """ runtime: Runtime + """Read-only shared runtime used by this client and its responses.""" cookie_jar: Jar | None r""" @@ -1149,9 +1151,8 @@ async def main(): def close(self) -> None: r""" - Closes the client and any associated resources. - - After calling this method, the client should not be used to make further requests. + Cancels pending requests and rejects new ones with `asyncio.CancelledError`. + Existing responses, WebSockets and the shared runtime remain usable. Examples: diff --git a/src/client.rs b/src/client.rs index 870ee59f..977cd646 100644 --- a/src/client.rs +++ b/src/client.rs @@ -494,7 +494,8 @@ impl Client { }) } - /// Close the client, preventing any new requests. + /// Cancel pending requests and reject new ones with asyncio.CancelledError. + /// Existing responses, WebSockets and the shared runtime remain usable. #[inline] pub fn close(&self) { self.cancel.cancel(); @@ -657,10 +658,12 @@ impl Client { #[pymethods] impl BlockingClient { + /// The runtime used by this client and its responses. #[getter] pub fn runtime(&self) -> runtime::Runtime { self.0.runtime() } + /// Creates a new blocking Client instance. #[new] #[inline] @@ -676,7 +679,8 @@ impl BlockingClient { self.0.cookie_jar.clone() } - /// Close the client, preventing any new requests. + /// Cancel pending requests and reject new ones with asyncio.CancelledError. + /// Existing responses, WebSockets and the shared runtime remain usable. #[inline] pub fn close(&self) { self.0.close(); @@ -790,7 +794,10 @@ impl BlockingClient { py.detach(|| { nogil::block_on( &self.0.runtime, - execute_request(self.0.clone(), method, url, kwds), + nogil::cancel_on_close( + execute_request(self.0.clone(), method, url, kwds), + self.0.cancel.clone(), + ), ) .map(Into::into) }) @@ -807,7 +814,10 @@ impl BlockingClient { py.detach(|| { nogil::block_on( &self.0.runtime, - execute_websocket_request(self.0.clone(), url, kwds), + nogil::cancel_on_close( + execute_websocket_request(self.0.clone(), url, kwds), + self.0.cancel.clone(), + ), ) .map(Into::into) }) diff --git a/src/client/nogil.rs b/src/client/nogil.rs index 244f2e29..702f95d4 100644 --- a/src/client/nogil.rs +++ b/src/client/nogil.rs @@ -64,16 +64,7 @@ where where Fut: Future> + Send + 'static, { - Self::new( - runtime, - async move { - tokio::select! { - result = fut => result, - _ = cancel_token.cancelled() => Err(CancelledError::new_err("Operation was cancelled: client has been closed")), - } - }, - cancel, - ) + Self::new(runtime, cancel_on_close(fut, cancel_token), cancel) } } @@ -107,6 +98,18 @@ where } } +/// Cancel pending requests and reject new work after their client is closed. +pub async fn cancel_on_close(future: F, cancel: CancellationToken) -> PyResult +where + F: Future>, +{ + tokio::select! { + biased; + _ = cancel.cancelled() => Err(CancelledError::new_err("Operation was cancelled: client has been closed")), + result = future => result, + } +} + /// Run network work on its selected worker; only wait for completion on the caller. /// The caller must be detached from Python and outside an async Tokio context. pub fn block_on(runtime: &Runtime, future: F) -> PyResult diff --git a/tests/runtime_test.py b/tests/runtime_test.py index 783f634e..3563c0d9 100644 --- a/tests/runtime_test.py +++ b/tests/runtime_test.py @@ -42,6 +42,8 @@ def test_runtime_configuration(): for factory in (wreq.Client, wreq.blocking.Client): client = factory(runtime=runtime) alias = client.runtime + with pytest.raises(AttributeError): + client.runtime = runtime client.close() del client # Releasing a client does not close a shared runtime. @@ -137,6 +139,49 @@ def request(url): del task +@pytest.mark.asyncio +@pytest.mark.parametrize("blocking", [False, True]) +@pytest.mark.parametrize("operation", ["get", "websocket"]) +async def test_client_close_cancels_requests(blocking, operation): + factory = wreq.blocking.Client if blocking else wreq.Client + client = factory( + runtime=Runtime(workers=1, work_steal=False), + proxies=[], + timeout=timedelta(seconds=10), + ) + task = None + try: + async with local_server() as (url, connections): + if operation == "websocket": + url = url.replace("http://", "ws://", 1) + request = getattr(client, operation) + + def start(): + return asyncio.create_task( + asyncio.to_thread(request, url) if blocking else request(url) + ) + + task = start() + reader, _ = await asyncio.wait_for(connections.get(), 5) + client.close() + done, _ = await asyncio.wait({task}, timeout=5) + assert task in done, "close() did not cancel the pending request" + with pytest.raises(asyncio.CancelledError): + await task + assert await asyncio.wait_for(reader.read(), 5) == b"" + + task = start() + done, _ = await asyncio.wait({task}, timeout=5) + assert task in done, "a closed client started another request" + with pytest.raises(asyncio.CancelledError): + await task + assert connections.empty() + finally: + client.close() + if task is not None: + await asyncio.gather(task, return_exceptions=True) + + @pytest.mark.asyncio @pytest.mark.parametrize("blocking", [False, True]) async def test_websocket_outlives_client(blocking): From 1a4c0e14d31f4faa418e545cb3ee1b337ff2cad5 Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 30 Sep 2026 11:06:51 +0800 Subject: [PATCH 14/15] refactor(client): simplify cancellation and stream cleanup --- Cargo.lock | 1 - Cargo.toml | 1 - src/client.rs | 16 +- src/client/body/stream.rs | 32 +-- src/client/nogil.rs | 80 ++---- src/client/req.rs | 510 +++++++++++++++++++------------------ src/client/resp/http.rs | 6 +- src/client/resp/ws.rs | 15 +- tests/cancellation_test.py | 24 +- tests/runtime_test.py | 78 +++--- tests/shutdown_test.py | 48 ++++ tests/upload_test.py | 45 ---- 12 files changed, 414 insertions(+), 442 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5aaea141..cc403a56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2465,7 +2465,6 @@ dependencies = [ "http-body-util", "indexmap", "mimalloc", - "pin-project-lite", "pingora-runtime", "pyo3", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3461ea82..837f4d70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,6 @@ pyo3 = { version = "0.29.0", features = [ "generate-import-lib", "experimental-async", ] } -pin-project-lite = "0.2.16" futures-util = { version = "0.3.33", default-features = false } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1" diff --git a/src/client.rs b/src/client.rs index 977cd646..c545dda4 100644 --- a/src/client.rs +++ b/src/client.rs @@ -613,11 +613,10 @@ impl Client { url: PyBackedStr, kwds: Option, ) -> PyResult { - NoGIL::new_with_token( + NoGIL::with_cancel( &self.runtime, execute_request(self.clone(), method, url, kwds), cancel, - self.cancel.clone(), ) .await } @@ -631,11 +630,10 @@ impl Client { url: PyBackedStr, kwds: Option, ) -> PyResult { - NoGIL::new_with_token( + NoGIL::with_cancel( &self.runtime, execute_websocket_request(self.clone(), url, kwds), cancel, - self.cancel.clone(), ) .await } @@ -794,10 +792,7 @@ impl BlockingClient { py.detach(|| { nogil::block_on( &self.0.runtime, - nogil::cancel_on_close( - execute_request(self.0.clone(), method, url, kwds), - self.0.cancel.clone(), - ), + execute_request(self.0.clone(), method, url, kwds), ) .map(Into::into) }) @@ -814,10 +809,7 @@ impl BlockingClient { py.detach(|| { nogil::block_on( &self.0.runtime, - nogil::cancel_on_close( - execute_websocket_request(self.0.clone(), url, kwds), - self.0.cancel.clone(), - ), + execute_websocket_request(self.0.clone(), url, kwds), ) .map(Into::into) }) diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index 67fe57c8..a7d817fe 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -158,9 +158,12 @@ impl Streamer { Some(cancel.throw_callback()), async move { let runtime = this.1.clone(); - let frame = - NoGIL::new(&runtime, this.next(|| Error::StopAsyncIteration), cancel) - .await?; + let frame = NoGIL::with_cancel( + &runtime, + this.next(|| Error::StopAsyncIteration), + cancel, + ) + .await?; // PyO3 polls this coroutine while attached, outside the Tokio task. Python::attach(|py| frame.into_pyobject(py).map(|obj| obj.unbind())) }, @@ -182,16 +185,12 @@ impl Streamer { _traceback: Py, ) -> PyResult<()> { let this = self.0.clone(); - NoGIL::new( - &self.1, - async move { - if let Some(resp) = this.lock().await.take() { - drop(resp) - } - Ok(()) - }, - CancelHandle::new(), - ) + NoGIL::new(&self.1, async move { + if let Some(resp) = this.lock().await.take() { + drop(resp) + } + Ok(()) + }) .await } } @@ -298,10 +297,13 @@ async def forward(gen, sender): close = getattr(gen, 'aclose', None) if close is not None: await close() + except asyncio.CancelledError as error: + # Task cancellation must not wait for space in a retained body. + if not asyncio.current_task().cancelling(): + await sender.send(error, True) + raise except BaseException as error: await sender.send(error, True) - if isinstance(error, asyncio.CancelledError): - raise ", c"wreq/_async_stream.py", c"wreq._async_stream", diff --git a/src/client/nogil.rs b/src/client/nogil.rs index 702f95d4..8afe8ff1 100644 --- a/src/client/nogil.rs +++ b/src/client/nogil.rs @@ -5,24 +5,16 @@ use std::{ task::{Context, Poll, Wake, Waker}, }; -use pin_project_lite::pin_project; -use pyo3::{ - coroutine::CancelHandle, - exceptions::{PyRuntimeError, asyncio::CancelledError}, - prelude::*, -}; -use tokio_util::{sync::CancellationToken, task::AbortOnDropHandle}; +use pyo3::{coroutine::CancelHandle, exceptions::PyRuntimeError, prelude::*}; +use tokio_util::task::AbortOnDropHandle; use crate::runtime::Runtime; -pin_project! { - /// A future that allows Python threads to run while it is being polled or executed. - /// It also handles cancellation and spawns the task in tokio runtime. - pub struct NoGIL { - #[pin] - handle: AbortOnDropHandle>, - cancel: CancelHandle, - } +/// A future that allows Python threads to run while it is being polled or executed. +/// It also handles cancellation and spawns the task in tokio runtime. +pub struct NoGIL { + handle: AbortOnDropHandle>, + cancel: CancelHandle, } struct GuardedWaker(Waker); @@ -33,9 +25,18 @@ impl NoGIL where T: Send + 'static, { - /// Spawn on the selected handle, keeping the runtime alive until the task ends. + /// Spawn internal work without a Python cancellation source. + #[inline] + pub fn new(runtime: &Runtime, fut: Fut) -> Self + where + Fut: Future> + Send + 'static, + { + Self::with_cancel(runtime, fut, CancelHandle::new()) + } + + /// Spawn with Python cancellation, keeping the runtime alive until the task ends. #[inline] - pub fn new(runtime: &Runtime, fut: Fut, cancel: CancelHandle) -> Self + pub fn with_cancel(runtime: &Runtime, fut: Fut, cancel: CancelHandle) -> Self where Fut: Future> + Send + 'static, { @@ -52,20 +53,6 @@ where cancel, } } - - /// Also cancel the task when its client is closed. - #[inline] - pub fn new_with_token( - runtime: &Runtime, - fut: Fut, - cancel: CancelHandle, - cancel_token: CancellationToken, - ) -> Self - where - Fut: Future> + Send + 'static, - { - Self::new(runtime, cancel_on_close(fut, cancel_token), cancel) - } } impl Future for NoGIL @@ -76,7 +63,7 @@ where #[inline] fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let this = self.project(); + let this = self.get_mut(); // A Python throw must win even when the Tokio task has already finished. if let Poll::Ready(exc) = this.cancel.poll_cancelled(cx) { this.handle.abort(); @@ -87,44 +74,29 @@ where let waker = cx.waker(); Python::attach(|py| { - py.detach( - || match poll_with_guard(this.handle, &mut Context::from_waker(waker)) { + py.detach(|| { + match poll_with_guard(Pin::new(&mut this.handle), &mut Context::from_waker(waker)) { Poll::Ready(Ok(result)) => Poll::Ready(result), Poll::Ready(Err(e)) => Poll::Ready(Err(PyRuntimeError::new_err(e.to_string()))), Poll::Pending => Poll::Pending, - }, - ) + } + }) }) } } -/// Cancel pending requests and reject new work after their client is closed. -pub async fn cancel_on_close(future: F, cancel: CancellationToken) -> PyResult -where - F: Future>, -{ - tokio::select! { - biased; - _ = cancel.cancelled() => Err(CancelledError::new_err("Operation was cancelled: client has been closed")), - result = future => result, - } -} - /// Run network work on its selected worker; only wait for completion on the caller. /// The caller must be detached from Python and outside an async Tokio context. +/// Its runtime borrow keeps the workers alive until the join completes. pub fn block_on(runtime: &Runtime, future: F) -> PyResult where F: Future> + Send + 'static, T: Send + 'static, { - let owner = runtime.clone(); - let task = runtime.handle().spawn(async move { - let _owner = owner; - future.await - }); + let task = runtime.handle().spawn(future); runtime .handle() - .block_on(AbortOnDropHandle::new(task)) + .block_on(task) .map_err(|err| PyRuntimeError::new_err(err.to_string()))? } diff --git a/src/client/req.rs b/src/client/req.rs index 639497bb..fa8bc77a 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -5,7 +5,7 @@ use std::{ use futures_util::TryFutureExt; use http::header::COOKIE; -use pyo3::{PyResult, prelude::*, pybacked::PyBackedStr}; +use pyo3::{PyResult, exceptions::asyncio::CancelledError, prelude::*, pybacked::PyBackedStr}; use crate::{ client::{ @@ -293,135 +293,143 @@ pub async fn execute_request( where U: AsRef, { - // Create the request builder. - let mut builder = client.inner.request(method.into_ffi(), url.as_ref()); - - if let Some(mut request) = request { - // Emulation options. - apply_option!(set_if_some, builder, request.emulation, emulation); - - // Version options. - apply_option!( - set_if_some_map, - builder, - request.version, - version, - Version::into_ffi - ); - - // Timeout options. - apply_option!(set_if_some, builder, request.timeout, timeout); - apply_option!(set_if_some, builder, request.read_timeout, read_timeout); - - // Network options. - apply_option!(set_if_some_inner, builder, request.proxy, proxy); - apply_option!(set_if_some, builder, request.local_address, local_address); - apply_option!( - set_if_some_tuple_inner, - builder, - request.local_addresses, - local_addresses - ); - - #[cfg(any( - target_os = "android", - target_os = "fuchsia", - target_os = "illumos", - target_os = "ios", - target_os = "linux", - target_os = "macos", - target_os = "solaris", - target_os = "tvos", - target_os = "visionos", - target_os = "watchos", - ))] - apply_option!(set_if_some, builder, request.interface, interface); - - // Headers options. - apply_option!(set_if_some_inner, builder, request.headers, headers); - apply_option!( - set_if_some_inner, - builder, - request.orig_headers, - orig_headers - ); - apply_option!( - set_if_some, - builder, - request.default_headers, - default_headers - ); - - // Cookies options. - apply_option!( - set_if_some_iter_inner_with_key, - builder, - request.cookies, - header, - COOKIE - ); - apply_option!( - set_if_some_inner, - builder, - request.cookie_provider, - cookie_provider - ); - - // Authentication options. - apply_option!( - set_if_some_map_ref, - builder, - request.auth, - auth, - AsRef::::as_ref - ); - apply_option!(set_if_some, builder, request.bearer_auth, bearer_auth); - apply_option!(set_if_some_tuple, builder, request.basic_auth, basic_auth); - - // Allow redirects options. - apply_option!(set_if_some_inner, builder, request.redirect, redirect); - - // Compression options. - apply_option!(set_if_some, builder, request.gzip, gzip); - apply_option!(set_if_some, builder, request.brotli, brotli); - apply_option!(set_if_some, builder, request.deflate, deflate); - apply_option!(set_if_some, builder, request.zstd, zstd); - - // Query options. - apply_option!(set_if_some_ref, builder, request.query, query); - - // Body options. - apply_option!(set_if_some_ref, builder, request.form, form); - apply_option!(set_if_some_ref, builder, request.json, json); - apply_option!( - set_if_some, - builder, - request.multipart.and_then(|form| form.form), - multipart - ); - apply_option!( - set_if_some_map_try, - builder, - request.body, - body, - wreq::Body::try_from - ); + let future = async { + // Create the request builder. + let mut builder = client.inner.request(method.into_ffi(), url.as_ref()); + + if let Some(mut request) = request { + // Emulation options. + apply_option!(set_if_some, builder, request.emulation, emulation); + + // Version options. + apply_option!( + set_if_some_map, + builder, + request.version, + version, + Version::into_ffi + ); + + // Timeout options. + apply_option!(set_if_some, builder, request.timeout, timeout); + apply_option!(set_if_some, builder, request.read_timeout, read_timeout); + + // Network options. + apply_option!(set_if_some_inner, builder, request.proxy, proxy); + apply_option!(set_if_some, builder, request.local_address, local_address); + apply_option!( + set_if_some_tuple_inner, + builder, + request.local_addresses, + local_addresses + ); + + #[cfg(any( + target_os = "android", + target_os = "fuchsia", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos", + ))] + apply_option!(set_if_some, builder, request.interface, interface); + + // Headers options. + apply_option!(set_if_some_inner, builder, request.headers, headers); + apply_option!( + set_if_some_inner, + builder, + request.orig_headers, + orig_headers + ); + apply_option!( + set_if_some, + builder, + request.default_headers, + default_headers + ); + + // Cookies options. + apply_option!( + set_if_some_iter_inner_with_key, + builder, + request.cookies, + header, + COOKIE + ); + apply_option!( + set_if_some_inner, + builder, + request.cookie_provider, + cookie_provider + ); + + // Authentication options. + apply_option!( + set_if_some_map_ref, + builder, + request.auth, + auth, + AsRef::::as_ref + ); + apply_option!(set_if_some, builder, request.bearer_auth, bearer_auth); + apply_option!(set_if_some_tuple, builder, request.basic_auth, basic_auth); + + // Allow redirects options. + apply_option!(set_if_some_inner, builder, request.redirect, redirect); + + // Compression options. + apply_option!(set_if_some, builder, request.gzip, gzip); + apply_option!(set_if_some, builder, request.brotli, brotli); + apply_option!(set_if_some, builder, request.deflate, deflate); + apply_option!(set_if_some, builder, request.zstd, zstd); + + // Query options. + apply_option!(set_if_some_ref, builder, request.query, query); + + // Body options. + apply_option!(set_if_some_ref, builder, request.form, form); + apply_option!(set_if_some_ref, builder, request.json, json); + apply_option!( + set_if_some, + builder, + request.multipart.and_then(|form| form.form), + multipart + ); + apply_option!( + set_if_some_map_try, + builder, + request.body, + body, + wreq::Body::try_from + ); + } + + // Send request. + builder + .send() + .await + .and_then(|r| { + if client.raise_for_status { + r.error_for_status() + } else { + Ok(r) + } + }) + .map(|response| Response::new(response, client.runtime.clone())) + .map_err(Error::Library) + .map_err(Into::into) + }; + + tokio::select! { + biased; + _ = client.cancel.cancelled() => Err(CancelledError::new_err("Operation was cancelled: client has been closed")), + result = future => result, } - - // Send request. - builder - .send() - .await - .and_then(|r| { - if client.raise_for_status { - r.error_for_status() - } else { - Ok(r) - } - }) - .map(|response| Response::new(response, client.runtime.clone())) - .map_err(Error::Library) - .map_err(Into::into) } pub async fn execute_websocket_request( @@ -432,123 +440,131 @@ pub async fn execute_websocket_request( where U: AsRef, { - // Create the WebSocket builder. - let mut builder = client.inner.websocket(url.as_ref()); - - if let Some(mut request) = request { - // Emulation options. - apply_option!(set_if_some, builder, request.emulation, emulation); - - // Version options. - apply_option!( - set_if_some_map, - builder, - request.version, - version, - Version::into_ffi - ); - - // Subprotocols options. - apply_option!(set_if_some, builder, request.protocols, protocols); - - // WebSocket config - apply_option!( - set_if_some, - builder, - request.read_buffer_size, - read_buffer_size - ); - apply_option!( - set_if_some, - builder, - request.write_buffer_size, - write_buffer_size - ); - apply_option!( - set_if_some, - builder, - request.max_write_buffer_size, - max_write_buffer_size - ); - apply_option!(set_if_some, builder, request.max_frame_size, max_frame_size); - apply_option!( - set_if_some, - builder, - request.max_message_size, - max_message_size - ); - apply_option!( - set_if_some, - builder, - request.accept_unmasked_frames, - accept_unmasked_frames - ); - - // Network options. - apply_option!(set_if_some_inner, builder, request.proxy, proxy); - apply_option!(set_if_some, builder, request.local_address, local_address); - apply_option!( - set_if_some_tuple_inner, - builder, - request.local_addresses, - local_addresses - ); - #[cfg(any( - target_os = "android", - target_os = "fuchsia", - target_os = "illumos", - target_os = "ios", - target_os = "linux", - target_os = "macos", - target_os = "solaris", - target_os = "tvos", - target_os = "visionos", - target_os = "watchos", - ))] - apply_option!(set_if_some, builder, request.interface, interface); - - // Headers options. - apply_option!(set_if_some_inner, builder, request.headers, headers); - apply_option!( - set_if_some_inner, - builder, - request.orig_headers, - orig_headers - ); - apply_option!( - set_if_some, - builder, - request.default_headers, - default_headers - ); - apply_option!( - set_if_some_iter_inner_with_key, - builder, - request.cookies, - header, - COOKIE - ); - - // Authentication options. - apply_option!( - set_if_some_map_ref, - builder, - request.auth, - auth, - AsRef::::as_ref - ); - apply_option!(set_if_some, builder, request.bearer_auth, bearer_auth); - apply_option!(set_if_some_tuple, builder, request.basic_auth, basic_auth); - - // Query options. - apply_option!(set_if_some_ref, builder, request.query, query); + let future = async { + // Create the WebSocket builder. + let mut builder = client.inner.websocket(url.as_ref()); + + if let Some(mut request) = request { + // Emulation options. + apply_option!(set_if_some, builder, request.emulation, emulation); + + // Version options. + apply_option!( + set_if_some_map, + builder, + request.version, + version, + Version::into_ffi + ); + + // Subprotocols options. + apply_option!(set_if_some, builder, request.protocols, protocols); + + // WebSocket config + apply_option!( + set_if_some, + builder, + request.read_buffer_size, + read_buffer_size + ); + apply_option!( + set_if_some, + builder, + request.write_buffer_size, + write_buffer_size + ); + apply_option!( + set_if_some, + builder, + request.max_write_buffer_size, + max_write_buffer_size + ); + apply_option!(set_if_some, builder, request.max_frame_size, max_frame_size); + apply_option!( + set_if_some, + builder, + request.max_message_size, + max_message_size + ); + apply_option!( + set_if_some, + builder, + request.accept_unmasked_frames, + accept_unmasked_frames + ); + + // Network options. + apply_option!(set_if_some_inner, builder, request.proxy, proxy); + apply_option!(set_if_some, builder, request.local_address, local_address); + apply_option!( + set_if_some_tuple_inner, + builder, + request.local_addresses, + local_addresses + ); + #[cfg(any( + target_os = "android", + target_os = "fuchsia", + target_os = "illumos", + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "solaris", + target_os = "tvos", + target_os = "visionos", + target_os = "watchos", + ))] + apply_option!(set_if_some, builder, request.interface, interface); + + // Headers options. + apply_option!(set_if_some_inner, builder, request.headers, headers); + apply_option!( + set_if_some_inner, + builder, + request.orig_headers, + orig_headers + ); + apply_option!( + set_if_some, + builder, + request.default_headers, + default_headers + ); + apply_option!( + set_if_some_iter_inner_with_key, + builder, + request.cookies, + header, + COOKIE + ); + + // Authentication options. + apply_option!( + set_if_some_map_ref, + builder, + request.auth, + auth, + AsRef::::as_ref + ); + apply_option!(set_if_some, builder, request.bearer_auth, bearer_auth); + apply_option!(set_if_some_tuple, builder, request.basic_auth, basic_auth); + + // Query options. + apply_option!(set_if_some_ref, builder, request.query, query); + } + + // Send the WebSocket request. + builder + .send() + .and_then(|response| WebSocket::new(response, client.runtime.clone())) + .await + .map_err(Error::Library) + .map_err(Into::into) + }; + + tokio::select! { + biased; + _ = client.cancel.cancelled() => Err(CancelledError::new_err("Operation was cancelled: client has been closed")), + result = future => result, } - - // Send the WebSocket request. - builder - .send() - .and_then(|response| WebSocket::new(response, client.runtime.clone())) - .await - .map_err(Error::Library) - .map_err(Into::into) } diff --git a/src/client/resp/http.rs b/src/client/resp/http.rs index 56ef6518..50ad6d79 100644 --- a/src/client/resp/http.rs +++ b/src/client/resp/http.rs @@ -234,7 +234,7 @@ impl Response { .cache_response() .and_then(|resp| ResponseExt::text(resp, encoding)) .map_err(Into::into); - NoGIL::new(&self.runtime, fut, cancel).await + NoGIL::with_cancel(&self.runtime, fut, cancel).await } /// Get the JSON content of the response. @@ -243,7 +243,7 @@ impl Response { .cache_response() .and_then(ResponseExt::json::) .map_err(Into::into); - NoGIL::new(&self.runtime, fut, cancel).await + NoGIL::with_cancel(&self.runtime, fut, cancel).await } /// Get the bytes content of the response. @@ -253,7 +253,7 @@ impl Response { .and_then(ResponseExt::bytes) .map_ok(PyBuffer::from) .map_err(Into::into); - NoGIL::new(&self.runtime, fut, cancel).await + NoGIL::with_cancel(&self.runtime, fut, cancel).await } /// Close the response. diff --git a/src/client/resp/ws.rs b/src/client/resp/ws.rs index 620d99ac..08242d9e 100644 --- a/src/client/resp/ws.rs +++ b/src/client/resp/ws.rs @@ -108,7 +108,7 @@ impl WebSocket { timeout: Option, ) -> PyResult> { let tx = self.cmd.clone(); - NoGIL::new(&self.runtime, cmd::recv(tx, timeout), cancel).await + NoGIL::with_cancel(&self.runtime, cmd::recv(tx, timeout), cancel).await } /// Send a message to the WebSocket. @@ -119,7 +119,7 @@ impl WebSocket { message: Message, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(&self.runtime, cmd::send(tx, message), cancel).await + NoGIL::with_cancel(&self.runtime, cmd::send(tx, message), cancel).await } /// Send multiple messages to the WebSocket. @@ -130,7 +130,7 @@ impl WebSocket { messages: Vec, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(&self.runtime, cmd::send_all(tx, messages), cancel).await + NoGIL::with_cancel(&self.runtime, cmd::send_all(tx, messages), cancel).await } /// Close the WebSocket connection. @@ -142,7 +142,7 @@ impl WebSocket { reason: Option, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new(&self.runtime, cmd::close(tx, code, reason), cancel).await + NoGIL::with_cancel(&self.runtime, cmd::close(tx, code, reason), cancel).await } } @@ -161,12 +161,7 @@ impl WebSocket { _traceback: Py, ) -> PyResult<()> { let tx = self.cmd.clone(); - NoGIL::new( - &self.runtime, - cmd::close(tx, None, None), - CancelHandle::new(), - ) - .await + NoGIL::new(&self.runtime, cmd::close(tx, None, None)).await } } diff --git a/tests/cancellation_test.py b/tests/cancellation_test.py index 1589e8e1..cbf4b267 100644 --- a/tests/cancellation_test.py +++ b/tests/cancellation_test.py @@ -35,9 +35,7 @@ async def accept(reader, writer): @pytest.mark.asyncio -@pytest.mark.parametrize( - "operation", ["request", "request_error", "bytes", "text", "json", "stream"] -) +@pytest.mark.parametrize("operation", ["request", "request_error", "stream"]) async def test_cancellation_after_rust_completion(operation): async with local_server() as (url, connections), wreq.Client(proxies=[]) as client: response = None @@ -51,11 +49,7 @@ async def test_cancellation_after_rust_completion(operation): writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n") await writer.drain() response = await asyncio.wait_for(task, 5) - coroutine = ( - anext(response.stream()) - if operation == "stream" - else getattr(response, operation)() - ) + coroutine = anext(response.stream()) waiter = coroutine.send(None) try: @@ -87,7 +81,7 @@ async def test_cancellation_after_rust_completion(operation): @pytest.mark.asyncio -@pytest.mark.parametrize("action", ["cancel", "close_coroutine", "close_client"]) +@pytest.mark.parametrize("action", ["cancel", "close_coroutine"]) async def test_pending_request_cancellation(action): async with local_server() as (url, connections), wreq.Client(proxies=[]) as client: coroutine = client.get(url) @@ -100,20 +94,12 @@ async def test_pending_request_cancellation(action): if action == "close_coroutine": coroutine.close() else: - if action == "cancel": - task.cancel("caller cancellation message") - else: - client.close() + task.cancel("caller cancellation message") done, _ = await asyncio.wait({task}, timeout=5) assert task in done, "Cancellation did not finish" with pytest.raises(asyncio.CancelledError) as caught: await task - expected = ( - "caller cancellation message" - if action == "cancel" - else "Operation was cancelled: client has been closed" - ) - assert caught.value.args == (expected,) + assert caught.value.args == ("caller cancellation message",) # The cancelled operation must release its pending network request. assert await asyncio.wait_for(reader.read(), 5) == b"" diff --git a/tests/runtime_test.py b/tests/runtime_test.py index 3563c0d9..178e685a 100644 --- a/tests/runtime_test.py +++ b/tests/runtime_test.py @@ -23,9 +23,8 @@ def test_runtime_configuration(): ): with pytest.raises(ValueError): Runtime(**kwargs) - for invalid in (0.25, "1s"): - with pytest.raises(TypeError): - Runtime(thread_keep_alive=invalid) + with pytest.raises(TypeError): + Runtime(thread_keep_alive=0.25) with pytest.raises(TypeError): wreq.Client(runtime=object()) for duration in (None, timedelta(), timedelta(microseconds=250001)): @@ -36,24 +35,19 @@ def test_runtime_configuration(): max_blocking_threads=3, thread_keep_alive=duration, ) - assert not hasattr(runtime, "default") - assert not hasattr(runtime, "closed") - assert not hasattr(runtime, "shutdown_timeout") - for factory in (wreq.Client, wreq.blocking.Client): - client = factory(runtime=runtime) - alias = client.runtime - with pytest.raises(AttributeError): - client.runtime = runtime - client.close() - del client - # Releasing a client does not close a shared runtime. - other = factory(runtime=alias) - other.close() for factory in (wreq.Client, wreq.blocking.Client): - for kwargs in ({}, {"runtime": None}): - client = factory(**kwargs) - assert isinstance(client.runtime, Runtime) - client.close() + client = factory(runtime=runtime) + alias = client.runtime + with pytest.raises(AttributeError): + client.runtime = runtime + client.close() + del client + # Releasing a client does not close a shared runtime. + other = factory(runtime=alias) + other.close() + client = factory(runtime=None) + assert isinstance(client.runtime, Runtime) + client.close() @pytest.mark.asyncio @@ -81,9 +75,8 @@ async def test_response_and_stream_keep_runtime_alive(steal): @pytest.mark.asyncio -@pytest.mark.parametrize("steal", [False, True]) -async def test_shared_runtime_cancellation_and_upload(steal): - runtime = wreq.Runtime(workers=2, work_steal=steal, max_blocking_threads=2) +async def test_shared_runtime_cancellation_and_upload(): + runtime = wreq.Runtime(workers=2, work_steal=False, max_blocking_threads=2) async with local_server() as (url, connections): first = wreq.Client(runtime=runtime, proxies=[]) second = wreq.Client(runtime=runtime, proxies=[]) @@ -93,6 +86,8 @@ async def test_shared_runtime_cancellation_and_upload(steal): first.close() with pytest.raises(asyncio.CancelledError): await asyncio.wait_for(pending, 5) + with pytest.raises(asyncio.CancelledError): + await first.get("invalid URL") del pending, first finalized = asyncio.Event() @@ -118,30 +113,40 @@ async def chunks(): @pytest.mark.asyncio -@pytest.mark.parametrize("steal", [False, True]) -async def test_blocking_client_uses_custom_runtime(steal): - runtime = wreq.Runtime(workers=1, work_steal=steal, max_blocking_threads=2) +@pytest.mark.parametrize("steal", [None, False, True]) +async def test_blocking_client_runtime(steal): + runtime = ( + None + if steal is None + else wreq.Runtime(workers=1, work_steal=steal, max_blocking_threads=2) + ) def request(url): with wreq.blocking.Client(runtime=runtime, proxies=[]) as client: assert isinstance(client.runtime, Runtime) - with client.post(url, body=iter((b"blocking",))) as response: + with client.post(url, body=iter((b"sync", b" upload"))) as response: + assert response.bytes() == b"{}" + assert response.json() == {} + with client.post(url, body=iter((b"sync", b" upload"))) as response: with response.stream() as stream: return b"".join(stream) async with local_server() as (url, connections): task = asyncio.create_task(asyncio.to_thread(request, url)) - reader, writer = await asyncio.wait_for(connections.get(), 5) - assert await asyncio.wait_for(read_chunked(reader), 5) == b"blocking" - writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") - await writer.drain() - assert await asyncio.wait_for(task, 5) == b"ok" + for _ in range(2): + reader, writer = await asyncio.wait_for(connections.get(), 5) + assert await asyncio.wait_for(read_chunked(reader), 5) == b"sync upload" + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + await writer.drain() + assert await asyncio.wait_for(task, 5) == b"{}" del task @pytest.mark.asyncio -@pytest.mark.parametrize("blocking", [False, True]) -@pytest.mark.parametrize("operation", ["get", "websocket"]) +@pytest.mark.parametrize( + ("blocking", "operation"), + [(True, "get"), (False, "websocket"), (True, "websocket")], +) async def test_client_close_cancels_requests(blocking, operation): factory = wreq.blocking.Client if blocking else wreq.Client client = factory( @@ -166,8 +171,11 @@ def start(): client.close() done, _ = await asyncio.wait({task}, timeout=5) assert task in done, "close() did not cancel the pending request" - with pytest.raises(asyncio.CancelledError): + with pytest.raises(asyncio.CancelledError) as caught: await task + assert caught.value.args == ( + "Operation was cancelled: client has been closed", + ) assert await asyncio.wait_for(reader.read(), 5) == b"" task = start() diff --git a/tests/shutdown_test.py b/tests/shutdown_test.py index 61305958..e4b5b345 100644 --- a/tests/shutdown_test.py +++ b/tests/shutdown_test.py @@ -89,3 +89,51 @@ def test_shutdown_wake_without_panic(operation): assert "teardown: wait complete" in proc.stderr assert "panicked" not in proc.stderr assert "Exception ignored" not in proc.stderr + + +def test_unconsumed_upload_cleanup(): + script = """ +import asyncio +import wreq + +async def main(retain): + produced = [] + full = asyncio.Event() + closed = asyncio.Event() + + async def chunks(): + try: + for index in range(100): + produced.append(index) + if index == 1: + full.set() + yield b"chunk" + finally: + closed.set() + print("generator closed", flush=True) + + # A part retains the body without polling its Rust stream. + part = wreq.Part(name="file", value=chunks()) + try: + await asyncio.wait_for(full.wait(), 5) + await asyncio.sleep(0.05) + assert produced == [0, 1] + if retain: + return part + finally: + if not retain: + del part + await asyncio.wait_for(closed.wait(), 5) + +for retain in (False, True): + part = asyncio.run(main(retain)) + print("runner closed", flush=True) +""" + proc = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=15, + ) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.splitlines() == ["generator closed", "runner closed"] * 2 diff --git a/tests/upload_test.py b/tests/upload_test.py index 4102833c..4577c6d6 100644 --- a/tests/upload_test.py +++ b/tests/upload_test.py @@ -76,51 +76,6 @@ async def chunks(): await asyncio.wait_for(closed.wait(), 5) -@pytest.mark.asyncio -async def test_unconsumed_upload_backpressure(): - produced = [] - full = asyncio.Event() - closed = asyncio.Event() - - async def chunks(): - try: - for index in range(100): - produced.append(index) - if index == 1: - full.set() - yield b"chunk" - finally: - closed.set() - - # A multipart part retains the body without polling its Rust stream. - part = wreq.Part(name="file", value=chunks()) - await asyncio.wait_for(full.wait(), 5) - await asyncio.sleep(0.05) - assert produced == [0, 1] - del part - await asyncio.wait_for(closed.wait(), 5) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("read", ["bytes", "json", "stream"]) -async def test_blocking_runtime(read): - def request(url): - with wreq.blocking.Client(proxies=[]) as client: - with client.post(url, body=iter((b"sync", b" upload"))) as response: - if read == "stream": - with response.stream() as stream: - return b"".join(stream) - return getattr(response, read)() - - async with local_server() as (url, connections): - task = asyncio.create_task(asyncio.to_thread(request, url)) - reader, writer = await asyncio.wait_for(connections.get(), 5) - assert await asyncio.wait_for(read_chunked(reader), 5) == b"sync upload" - writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") - await writer.drain() - assert await asyncio.wait_for(task, 5) == ({} if read == "json" else b"{}") - - @pytest.mark.asyncio @pytest.mark.parametrize("action", ["cancel", "close_client"]) async def test_upload_cancellation(action): From dfe1ae4a8c4cafd881be75150bc0f8b1532ac765 Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 30 Sep 2026 17:01:44 +0800 Subject: [PATCH 15/15] ci: bound PyPy tests and expose hang diagnostics --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67cf1880..fef4c7ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,8 +129,10 @@ jobs: *) echo "Expected a pp311 wheel, got: $wheel"; exit 1 ;; esac - name: Run tests - run: .venv-pypy/bin/python -m pytest + timeout-minutes: 15 + run: .venv-pypy/bin/python -u -m pytest -vv -x -o faulthandler_timeout=60 - name: Upload wheel + if: always() uses: actions/upload-artifact@v7 with: name: wheels-linux-x86_64-pypy311